refactor(api): reorder imports and update code formatting across repository endpoints

- Reordered actix-web imports to standardize import order
- Reordered crate module imports to follow alphabetical ordering
- Updated function calls to use multi-line formatting for better readability
- Standardized blank lines around documentation comments
- Applied consistent formatting to response handling methods
- Normalized import organization across all repository-related API files
- Improved code consistency and maintainability through standardized formatting
- Applied formatting updates to all repository endpoint implementations
This commit is contained in:
zhenyi
2026-06-07 19:41:33 +08:00
parent 7368ba676c
commit 4028f0d943
149 changed files with 4962 additions and 369 deletions
+60
View File
@@ -0,0 +1,60 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueAssignee;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
/// User ID (UUID) to assign
pub user_id: uuid::Uuid,
}
/// Assign a user to an issue
///
/// Assigns a workspace member to the given issue.
/// Requires write access to the issue (author or workspace member).
///
/// Effects:
/// - User is assigned to the issue
/// - Assignee is automatically subscribed to the issue
/// - Issue assignee count is incremented
///
/// Returns the created assignment record.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/assignees/{user_id}",
tag = "Issues",
operation_id = "issueAssign",
params(PathParams),
responses(
(status = 201, description = "User assigned successfully. Returns the created assignment record.", body = ApiResponse<IssueAssignee>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
(status = 404, description = "Issue or user not found", body = ApiErrorResponse),
(status = 409, description = "User is already assigned to this issue", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn assign_issue(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let assignee = service
.issue
.issue_assign(&session, &path.workspace_name, path.number, path.user_id)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(assignee)))
}
+59
View File
@@ -0,0 +1,59 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueLabelRelation;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
/// Label ID (UUID) to assign
pub label_id: uuid::Uuid,
}
/// Assign a label to an issue
///
/// Attaches a label to the given issue. The label must belong to a repository in the same workspace.
/// Requires write access to the issue (author or workspace member).
///
/// Effects:
/// - Label is attached to the issue
/// - Issue label count is incremented
///
/// Returns the created label relation.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/labels/{label_id}",
tag = "Issues",
operation_id = "issueAssignLabel",
params(PathParams),
responses(
(status = 200, description = "Label assigned successfully. Returns the created label relation.", body = ApiResponse<IssueLabelRelation>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
(status = 404, description = "Issue or label not found", body = ApiErrorResponse),
(status = 409, description = "Label is already assigned to this issue", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn assign_label(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let rel = service
.issue
.issue_assign_label(&session, &path.workspace_name, path.number, path.label_id)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(rel)))
}
+56
View File
@@ -0,0 +1,56 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::Issue;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub number: i64,
}
/// Close an issue
///
/// Closes an open issue. The issue is marked as closed and the closing user is recorded.
/// Requires write access to the issue (author or workspace member).
///
/// Effects:
/// - Issue state changes to "closed"
/// - Closed by and closed at are recorded
/// - A "Closed" event is logged
///
/// Returns the closed issue with updated metadata.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/close",
tag = "Issues",
operation_id = "issueClose",
params(PathParams),
responses(
(status = 200, description = "Issue closed successfully. Returns the closed issue with updated metadata.", body = ApiResponse<Issue>),
(status = 400, description = "Issue is already closed", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to close this issue", body = ApiErrorResponse),
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn close(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let issue = service
.issue
.issue_close(&session, &path.workspace_name, path.number)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(issue)))
}
+75
View File
@@ -0,0 +1,75 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::Issue;
use crate::service::AppService;
use crate::service::issues::core::CreateIssueParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
}
/// Create an issue
///
/// Creates a new issue in the specified workspace.
/// Requires at least Member role in the workspace.
///
/// Parameters:
/// - title: Issue title (required)
/// - body: Issue body in markdown (optional)
/// - priority: Priority level (optional, defaults to "none")
/// - visibility: Visibility setting (optional, defaults to "public")
/// - due_at: Due date (optional)
/// - repo_ids: Related repository IDs
/// - label_ids: Label IDs to apply
/// - assignee_ids: User IDs to assign
/// - milestone_id: Milestone ID to attach
///
/// Effects:
/// - Issue is created with auto-incrementing number
/// - Author is automatically subscribed
/// - Relations, labels, and assignees are attached
/// - Workspace stats are updated
///
/// Returns the created issue with full metadata.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues",
tag = "Issues",
operation_id = "issueCreate",
params(PathParams),
request_body(
content = CreateIssueParams,
description = "Issue creation parameters",
content_type = "application/json"
),
responses(
(status = 201, description = "Issue created successfully. Returns the newly created issue with full metadata.", body = ApiResponse<Issue>),
(status = 400, description = "Invalid parameters: empty title, invalid repository/label/milestone references", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Member role or higher)", body = ApiErrorResponse),
(status = 404, description = "Workspace or referenced resource not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn create(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<CreateIssueParams>,
) -> Result<HttpResponse, AppError> {
let issue = service
.issue
.issue_create(&session, &path.workspace_name, params.into_inner())
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(issue)))
}
+66
View File
@@ -0,0 +1,66 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueComment;
use crate::service::AppService;
use crate::service::issues::comments::CreateCommentParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub number: i64,
}
/// Create a comment on an issue
///
/// Adds a new comment to an issue. Users with read access can comment unless the issue is locked
/// (in which case only users with write access can comment).
///
/// Parameters:
/// - body: Comment body in markdown format (required)
/// - reply_to_comment_id: ID of parent comment for threaded replies (optional)
///
/// Effects:
/// - Comment is created and attached to the issue
/// - Commenter is automatically subscribed to the issue
/// - Issue comment count is incremented
///
/// Returns the created comment with metadata.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/comments",
tag = "Issues",
operation_id = "issueCreateComment",
params(PathParams),
request_body(content = CreateCommentParams, description = "Comment creation parameters", content_type = "application/json"),
responses(
(status = 201, description = "Comment created successfully.", body = ApiResponse<IssueComment>),
(status = 400, description = "Invalid parameters: empty body or issue is locked", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (issue locked and user lacks write access)", body = ApiErrorResponse),
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn create_comment(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<CreateCommentParams>,
) -> Result<HttpResponse, AppError> {
let comment = service
.issue
.issue_create_comment(
&session,
&path.workspace_name,
path.number,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(comment)))
}
+62
View File
@@ -0,0 +1,62 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueLabel;
use crate::service::AppService;
use crate::service::issues::labels::CreateLabelParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub repo_name: String,
}
/// Create a label
///
/// Creates a new issue label in a repository.
/// Requires at least Member role in the repository.
///
/// Parameters:
/// - name: Label name (required, e.g., "bug", "feature")
/// - color: Hex color code (required, e.g., "#FF0000")
/// - description: Label description (optional)
///
/// Returns the created label with metadata.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/labels",
tag = "Issues",
operation_id = "issueCreateLabel",
params(PathParams),
request_body(content = CreateLabelParams, description = "Label creation parameters", content_type = "application/json"),
responses(
(status = 201, description = "Label created successfully.", body = ApiResponse<IssueLabel>),
(status = 400, description = "Invalid parameters: empty name or invalid color", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Member role)", body = ApiErrorResponse),
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn create_label(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<CreateLabelParams>,
) -> Result<HttpResponse, AppError> {
let label = service
.issue
.issue_create_label(
&session,
&path.workspace_name,
&path.repo_name,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(label)))
}
+70
View File
@@ -0,0 +1,70 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueMilestone;
use crate::service::AppService;
use crate::service::issues::milestones::CreateMilestoneParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
}
/// Create a milestone
///
/// Creates a new milestone in a repository for tracking issue progress.
/// Requires at least Member role in the repository.
///
/// Parameters:
/// - title: Milestone title (required)
/// - description: Description of the milestone (optional)
/// - due_at: Target due date (optional)
///
/// Returns the created milestone with metadata.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/milestones",
tag = "Issues",
operation_id = "issueCreateMilestone",
params(PathParams),
request_body(
content = CreateMilestoneParams,
description = "Milestone creation parameters",
content_type = "application/json"
),
responses(
(status = 201, description = "Milestone created successfully. Returns the newly created milestone with metadata.", body = ApiResponse<IssueMilestone>),
(status = 400, description = "Invalid parameters: empty title", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Member role or higher)", body = ApiErrorResponse),
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn create_milestone(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<CreateMilestoneParams>,
) -> Result<HttpResponse, AppError> {
let milestone = service
.issue
.issue_create_milestone(
&session,
&path.workspace_name,
&path.repo_name,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(milestone)))
}
+53
View File
@@ -0,0 +1,53 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub number: i64,
}
/// Delete an issue
///
/// Soft-deletes an issue. The issue is marked as deleted but remains in the database.
/// Requires Admin role in the workspace (or issue author).
///
/// Effects:
/// - Issue is marked as deleted (soft-delete)
/// - Workspace issue count is decremented
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}",
tag = "Issues",
operation_id = "issueDelete",
params(PathParams),
responses(
(status = 200, description = "Issue deleted successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Admin role or issue author)", body = ApiErrorResponse),
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn delete(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_delete(&session, &path.workspace_name, path.number)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Issue deleted successfully".to_string())))
}
+52
View File
@@ -0,0 +1,52 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub number: i64,
pub comment_id: uuid::Uuid,
}
/// Delete an issue comment
///
/// Soft-deletes a comment. The comment author can delete their own comments.
/// Workspace admins can delete any comment.
///
/// Effects:
/// - Comment is marked as deleted
/// - Issue comment count is decremented
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/comments/{comment_id}",
tag = "Issues",
operation_id = "issueDeleteComment",
params(PathParams),
responses(
(status = 200, description = "Comment deleted successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Cannot delete other users' comments (requires admin)", body = ApiErrorResponse),
(status = 404, description = "Workspace, issue, or comment not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn delete_comment(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_delete_comment(&session, &path.workspace_name, path.number, path.comment_id)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Comment deleted successfully".to_string())))
}
+57
View File
@@ -0,0 +1,57 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub repo_name: String,
pub label_id: uuid::Uuid,
}
/// Delete a label
///
/// Permanently removes an issue label from a repository.
/// Requires Admin role in the repository.
///
/// Effects:
/// - Label is permanently deleted
/// - All issue-label relations using this label are removed
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/labels/{label_id}",
tag = "Issues",
operation_id = "issueDeleteLabel",
params(PathParams),
responses(
(status = 200, description = "Label deleted successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Admin role)", body = ApiErrorResponse),
(status = 404, description = "Repository, workspace, or label not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn delete_label(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_delete_label(
&session,
&path.workspace_name,
&path.repo_name,
path.label_id,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Label deleted successfully".to_string())))
}
+62
View File
@@ -0,0 +1,62 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// Milestone ID (UUID)
pub milestone_id: uuid::Uuid,
}
/// Delete a milestone
///
/// Permanently removes a milestone from the repository.
/// Requires Admin role in the repository.
///
/// Effects:
/// - Milestone is permanently deleted
/// - Issues attached to this milestone lose their milestone association
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/milestones/{milestone_id}",
tag = "Issues",
operation_id = "issueDeleteMilestone",
params(PathParams),
responses(
(status = 200, description = "Milestone deleted successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Admin role)", body = ApiErrorResponse),
(status = 404, description = "Repository, workspace, or milestone not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn delete_milestone(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_delete_milestone(
&session,
&path.workspace_name,
&path.repo_name,
path.milestone_id,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Milestone deleted".to_string())))
}
+50
View File
@@ -0,0 +1,50 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::Issue;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
/// Get an issue by number
///
/// Returns detailed information about a specific issue, identified by workspace name and issue number.
/// Requires read access to the issue (public or workspace member).
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}",
tag = "Issues",
operation_id = "issueGet",
params(PathParams),
responses(
(status = 200, description = "Issue retrieved successfully. Returns complete issue with all metadata.", body = ApiResponse<Issue>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn get(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let issue = service
.issue
.issue_get(&session, &path.workspace_name, path.number)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(issue)))
}
+85
View File
@@ -0,0 +1,85 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::Issue;
use crate::service::AppService;
use crate::service::issues::core::IssueListFilters;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Filter by issue state ("open" or "closed")
pub state: Option<String>,
/// Filter by priority level
pub priority: Option<String>,
/// Filter by author user ID
pub author_id: Option<uuid::Uuid>,
/// Filter by assignee user ID
pub assignee_id: Option<uuid::Uuid>,
/// Filter by milestone ID
pub milestone_id: Option<uuid::Uuid>,
/// Filter by label ID
pub label_id: Option<uuid::Uuid>,
/// Maximum number of issues to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of issues to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List issues in a workspace
///
/// Returns a paginated list of issues in the workspace, sorted by issue number (newest first).
/// Supports filtering by state, priority, author, assignee, milestone, and label.
/// Only returns issues visible to the authenticated user (public + workspace member access).
/// Requires authentication.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues",
tag = "Issues",
operation_id = "issueList",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Issues listed successfully. Returns filtered array of issue objects with metadata.", body = ApiResponse<Vec<Issue>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 404, description = "Workspace not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let filters = IssueListFilters {
state: query.state.clone(),
priority: query.priority.clone(),
author_id: query.author_id,
assignee_id: query.assignee_id,
milestone_id: query.milestone_id,
label_id: query.label_id,
};
let issues = service
.issue
.issue_list(
&session,
&path.workspace_name,
filters,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(issues)))
}
+66
View File
@@ -0,0 +1,66 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueAssignee;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of assignees to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of assignees to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List assignees of an issue
///
/// Returns a paginated list of all users assigned to the given issue.
/// Shows who is assigned, when they were assigned, and who assigned them.
/// Requires read access to the issue.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/assignees",
tag = "Issues",
operation_id = "issueListAssignees",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Assignees listed successfully. Returns array of assignee objects with assignment metadata.", body = ApiResponse<Vec<IssueAssignee>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_assignees(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let assignees = service
.issue
.issue_assignees(
&session,
&path.workspace_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(assignees)))
}
+59
View File
@@ -0,0 +1,59 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueComment;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
pub limit: Option<i64>,
pub offset: Option<i64>,
}
/// List issue comments
///
/// Returns a paginated list of comments on an issue, sorted by creation date (oldest first).
/// Requires read access to the issue.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/comments",
tag = "Issues",
operation_id = "issueListComments",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Comments listed successfully.", body = ApiResponse<Vec<IssueComment>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn list_comments(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let comments = service
.issue
.issue_comments(
&session,
&path.workspace_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(comments)))
}
+67
View File
@@ -0,0 +1,67 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueEvent;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of events to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of events to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List issue events
///
/// Returns a chronological timeline of all events for the given issue.
/// Events include creation, updates, state changes, assignments, label changes, etc.
/// Sorted by creation date (oldest first for timeline display).
/// Requires read access to the issue.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/events",
tag = "Issues",
operation_id = "issueListEvents",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Events listed successfully. Returns chronological array of event objects.", body = ApiResponse<Vec<IssueEvent>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_events(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let events = service
.issue
.issue_list_events(
&session,
&path.workspace_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(events)))
}
+66
View File
@@ -0,0 +1,66 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueLabelRelation;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of label relations to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of label relations to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List labels assigned to an issue
///
/// Returns a paginated list of all label relations for the given issue.
/// Shows which labels are attached to the issue, with assignment metadata.
/// Requires read access to the issue.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/labels",
tag = "Issues",
operation_id = "issueListLabelRelations",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Label relations listed successfully. Returns array of label relation objects with metadata.", body = ApiResponse<Vec<IssueLabelRelation>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_issue_labels(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let rels = service
.issue
.issue_label_relations(
&session,
&path.workspace_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(rels)))
}
+46
View File
@@ -0,0 +1,46 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueLabel;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub repo_name: String,
}
/// List labels in a repository
///
/// Returns all issue labels defined in the repository, sorted alphabetically.
/// Requires read access to the repository.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/labels",
tag = "Issues",
operation_id = "issueListLabels",
params(PathParams),
responses(
(status = 200, description = "Labels listed successfully.", body = ApiResponse<Vec<IssueLabel>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions", body = ApiErrorResponse),
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn list_labels(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let labels = service
.issue
.issue_labels(&session, &path.workspace_name, &path.repo_name)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(labels)))
}
+66
View File
@@ -0,0 +1,66 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueMilestone;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of milestones to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of milestones to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List milestones in a repository
///
/// Returns a paginated list of milestones in the repository, sorted by state (open first) then by due date.
/// Includes milestone metadata such as title, description, state, due date, and progress.
/// Requires read access to the repository.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/milestones",
tag = "Issues",
operation_id = "issueListMilestones",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Milestones listed successfully. Returns array of milestone objects with metadata.", body = ApiResponse<Vec<IssueMilestone>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to access this repository", body = ApiErrorResponse),
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_milestones(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let milestones = service
.issue
.issue_milestones(
&session,
&path.workspace_name,
&path.repo_name,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(milestones)))
}
+62
View File
@@ -0,0 +1,62 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::Issue;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub number: i64,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct LockIssueParams {
/// Whether to lock (true) or unlock (false) the issue
pub locked: bool,
}
/// Lock or unlock an issue
///
/// Locks or unlocks conversation on an issue. When locked, only users with write access can comment.
/// Requires write access to the issue (author or workspace member).
///
/// Returns the updated issue with lock status.
#[utoipa::path(
put,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/lock",
tag = "Issues",
operation_id = "issueLock",
params(PathParams),
request_body(
content = LockIssueParams,
description = "Lock/unlock parameters",
content_type = "application/json"
),
responses(
(status = 200, description = "Issue lock status updated successfully.", body = ApiResponse<Issue>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to manage this issue", body = ApiErrorResponse),
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn lock(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<LockIssueParams>,
) -> Result<HttpResponse, AppError> {
let issue = service
.issue
.issue_lock(&session, &path.workspace_name, path.number, params.locked)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(issue)))
}
+185
View File
@@ -0,0 +1,185 @@
pub mod assign_issue;
pub mod assign_label;
pub mod close;
pub mod create;
pub mod create_comment;
pub mod create_label;
pub mod create_milestone;
pub mod delete;
pub mod delete_comment;
pub mod delete_label;
pub mod delete_milestone;
pub mod get;
pub mod list;
pub mod list_assignees;
pub mod list_comments;
pub mod list_events;
pub mod list_issue_labels;
pub mod list_labels;
pub mod list_milestones;
pub mod lock;
pub mod pr_relations;
pub mod reactions;
pub mod reopen;
pub mod repo_relations;
pub mod subscribers;
pub mod templates;
pub mod transfer;
pub mod unassign_issue;
pub mod unassign_label;
pub mod update;
pub mod update_comment;
pub mod update_label;
pub mod update_milestone;
use actix_web::web;
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/issues")
// Core
.route("", web::get().to(list::list))
.route("", web::post().to(create::create))
.route("/{number}", web::get().to(get::get))
.route("/{number}", web::put().to(update::update))
.route("/{number}", web::delete().to(delete::delete))
.route("/{number}/close", web::post().to(close::close))
.route("/{number}/reopen", web::post().to(reopen::reopen))
.route("/{number}/lock", web::put().to(lock::lock))
.route("/{number}/transfer", web::post().to(transfer::transfer))
// Comments
.route(
"/{number}/comments",
web::get().to(list_comments::list_comments),
)
.route(
"/{number}/comments",
web::post().to(create_comment::create_comment),
)
.route(
"/{number}/comments/{comment_id}",
web::put().to(update_comment::update_comment),
)
.route(
"/{number}/comments/{comment_id}",
web::delete().to(delete_comment::delete_comment),
)
// Labels (issue-level)
.route(
"/{number}/labels",
web::get().to(list_issue_labels::list_issue_labels),
)
.route(
"/{number}/labels/{label_id}",
web::post().to(assign_label::assign_label),
)
.route(
"/{number}/labels/{label_id}",
web::delete().to(unassign_label::unassign_label),
)
// Assignees
.route(
"/{number}/assignees",
web::get().to(list_assignees::list_assignees),
)
.route(
"/{number}/assignees/{user_id}",
web::post().to(assign_issue::assign_issue),
)
.route(
"/{number}/assignees/{user_id}",
web::delete().to(unassign_issue::unassign_issue),
)
// Events
.route("/{number}/events", web::get().to(list_events::list_events))
// Reactions
.route(
"/{number}/reactions",
web::get().to(reactions::list_reactions),
)
.route(
"/{number}/reactions",
web::post().to(reactions::add_reaction),
)
.route(
"/{number}/reactions/{reaction_id}",
web::delete().to(reactions::remove_reaction),
)
// Subscribers
.route(
"/{number}/subscribers",
web::get().to(subscribers::list_subscribers),
)
.route(
"/{number}/subscribe",
web::post().to(subscribers::subscribe),
)
.route(
"/{number}/subscribe",
web::delete().to(subscribers::unsubscribe),
)
.route("/{number}/mute", web::put().to(subscribers::mute))
// Repo relations
.route(
"/{number}/repos",
web::get().to(repo_relations::list_repo_relations),
)
.route("/{number}/repos", web::post().to(repo_relations::link_repo))
.route(
"/{number}/repos/{relation_id}",
web::delete().to(repo_relations::unlink_repo),
)
// PR relations
.route(
"/{number}/prs",
web::get().to(pr_relations::list_pr_relations),
)
.route("/{number}/prs", web::post().to(pr_relations::link_pr))
.route(
"/{number}/prs/{relation_id}",
web::delete().to(pr_relations::unlink_pr),
),
);
}
pub fn configure_repo_level(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/issues")
.route("/labels", web::get().to(list_labels::list_labels))
.route("/labels", web::post().to(create_label::create_label))
.route(
"/labels/{label_id}",
web::put().to(update_label::update_label),
)
.route(
"/labels/{label_id}",
web::delete().to(delete_label::delete_label),
)
.route(
"/milestones",
web::get().to(list_milestones::list_milestones),
)
.route(
"/milestones",
web::post().to(create_milestone::create_milestone),
)
.route(
"/milestones/{milestone_id}",
web::put().to(update_milestone::update_milestone),
)
.route(
"/milestones/{milestone_id}",
web::delete().to(delete_milestone::delete_milestone),
)
.route("/templates", web::get().to(templates::list_templates))
.route("/templates", web::post().to(templates::create_template))
.route(
"/templates/{template_id}",
web::put().to(templates::update_template),
)
.route(
"/templates/{template_id}",
web::delete().to(templates::delete_template),
),
);
}
+170
View File
@@ -0,0 +1,170 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssuePrRelation;
use crate::service::AppService;
use crate::service::issues::pr_relations::LinkPrParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of relations to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of relations to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List pull request relations for an issue
///
/// Returns a paginated list of all pull requests linked to the given issue.
/// Shows relation type (closes, references, depends_on, etc.) and link metadata.
/// Requires read access to the issue.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/prs",
tag = "Issues",
operation_id = "issueListPrRelations",
params(PathParams, QueryParams),
responses(
(status = 200, description = "PR relations listed successfully. Returns array of PR relation objects.", body = ApiResponse<Vec<IssuePrRelation>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_pr_relations(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let relations = service
.issue
.issue_pr_relations(
&session,
&path.workspace_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(relations)))
}
/// Link a pull request to an issue
///
/// Creates a relation between the given issue and a pull request.
/// Commonly used to mark a PR as closing or referencing an issue.
/// Requires write access to the issue.
///
/// Parameters:
/// - pull_request_id: Pull request ID (UUID) to link
/// - relation_type: Relation type ("closes", "references", "depends_on", default: "references")
///
/// Returns the created relation.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/prs",
tag = "Issues",
operation_id = "issueLinkPr",
params(PathParams),
request_body(
content = LinkPrParams,
description = "Link pull request parameters",
content_type = "application/json"
),
responses(
(status = 200, description = "Pull request linked successfully. Returns the created relation.", body = ApiResponse<IssuePrRelation>),
(status = 400, description = "Invalid parameters: invalid relation type", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
(status = 404, description = "Issue or pull request not found", body = ApiErrorResponse),
(status = 409, description = "Pull request is already linked to this issue", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn link_pr(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<LinkPrParams>,
) -> Result<HttpResponse, AppError> {
let relation = service
.issue
.issue_link_pr(
&session,
&path.workspace_name,
path.number,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(relation)))
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct RelationPathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
/// Relation ID (UUID)
pub relation_id: uuid::Uuid,
}
/// Unlink a pull request from an issue
///
/// Removes a pull request relation from the given issue.
/// Requires write access to the issue.
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/prs/{relation_id}",
tag = "Issues",
operation_id = "issueUnlinkPr",
params(RelationPathParams),
responses(
(status = 200, description = "Pull request unlinked successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
(status = 404, description = "PR relation not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn unlink_pr(
service: web::Data<AppService>,
session: Session,
path: web::Path<RelationPathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_unlink_pr(
&session,
&path.workspace_name,
path.number,
path.relation_id,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("PR unlinked".to_string())))
}
+169
View File
@@ -0,0 +1,169 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueReaction;
use crate::service::AppService;
use crate::service::issues::reactions::CreateIssueReactionParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of reactions to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of reactions to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List reactions on an issue
///
/// Returns a paginated list of all emoji reactions on the given issue.
/// Includes reaction content, target type, and user who added each reaction.
/// Requires read access to the issue.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/reactions",
tag = "Issues",
operation_id = "issueListReactions",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Reactions listed successfully. Returns array of reaction objects.", body = ApiResponse<Vec<IssueReaction>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_reactions(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let reactions = service
.issue
.issue_reactions(
&session,
&path.workspace_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(reactions)))
}
/// Add a reaction to an issue
///
/// Adds an emoji reaction to the given issue.
/// Requires read access to the issue.
///
/// Parameters:
/// - content: Reaction content (e.g., "👍", "❤️", "🎉")
/// - target_type: Target type for the reaction (defaults to "Issue")
/// - target_id: Target ID for reactions on specific comments (optional)
///
/// Returns the created reaction.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/reactions",
tag = "Issues",
operation_id = "issueAddReaction",
params(PathParams),
request_body(
content = CreateIssueReactionParams,
description = "Reaction creation parameters",
content_type = "application/json"
),
responses(
(status = 201, description = "Reaction added successfully. Returns the created reaction.", body = ApiResponse<IssueReaction>),
(status = 400, description = "Invalid parameters: empty content or invalid target type", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions", body = ApiErrorResponse),
(status = 404, description = "Issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn add_reaction(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<CreateIssueReactionParams>,
) -> Result<HttpResponse, AppError> {
let reaction = service
.issue
.issue_add_reaction(
&session,
&path.workspace_name,
path.number,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(reaction)))
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct ReactionPathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
/// Reaction ID (UUID)
pub reaction_id: uuid::Uuid,
}
/// Remove a reaction from an issue
///
/// Removes a previously added reaction. Only the user who added the reaction can remove it.
/// Requires read access to the issue.
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/reactions/{reaction_id}",
tag = "Issues",
operation_id = "issueRemoveReaction",
params(ReactionPathParams),
responses(
(status = 200, description = "Reaction removed successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Cannot remove another user's reaction", body = ApiErrorResponse),
(status = 404, description = "Reaction not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn remove_reaction(
service: web::Data<AppService>,
session: Session,
path: web::Path<ReactionPathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_remove_reaction(
&session,
&path.workspace_name,
path.number,
path.reaction_id,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Reaction removed".to_string())))
}
+51
View File
@@ -0,0 +1,51 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::Issue;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub number: i64,
}
/// Reopen an issue
///
/// Reopens a closed issue. The issue state changes back to "open" and closed metadata is cleared.
/// Requires write access to the issue (author or workspace member).
///
/// Returns the reopened issue with updated metadata.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/reopen",
tag = "Issues",
operation_id = "issueReopen",
params(PathParams),
responses(
(status = 200, description = "Issue reopened successfully. Returns the reopened issue with updated metadata.", body = ApiResponse<Issue>),
(status = 400, description = "Issue is not closed", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to reopen this issue", body = ApiErrorResponse),
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn reopen(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let issue = service
.issue
.issue_reopen(&session, &path.workspace_name, path.number)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(issue)))
}
+169
View File
@@ -0,0 +1,169 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueRepoRelation;
use crate::service::AppService;
use crate::service::issues::repo_relations::LinkRepoParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of relations to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of relations to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List repository relations for an issue
///
/// Returns a paginated list of all repositories linked to the given issue.
/// Shows relation type (references, duplicates, blocks, etc.) and link metadata.
/// Requires read access to the issue.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/repos",
tag = "Issues",
operation_id = "issueListRepoRelations",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Repository relations listed successfully. Returns array of relation objects.", body = ApiResponse<Vec<IssueRepoRelation>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_repo_relations(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let relations = service
.issue
.issue_repo_relations(
&session,
&path.workspace_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(relations)))
}
/// Link a repository to an issue
///
/// Creates a relation between the given issue and a repository.
/// Requires write access to the issue.
///
/// Parameters:
/// - repo_id: Repository ID (UUID) to link
/// - relation_type: Relation type ("references", "duplicates", "blocks", "depends_on", default: "references")
///
/// Returns the created relation.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/repos",
tag = "Issues",
operation_id = "issueLinkRepo",
params(PathParams),
request_body(
content = LinkRepoParams,
description = "Link repository parameters",
content_type = "application/json"
),
responses(
(status = 200, description = "Repository linked successfully. Returns the created relation.", body = ApiResponse<IssueRepoRelation>),
(status = 400, description = "Invalid parameters: invalid relation type", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
(status = 404, description = "Issue or repository not found", body = ApiErrorResponse),
(status = 409, description = "Repository is already linked to this issue", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn link_repo(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<LinkRepoParams>,
) -> Result<HttpResponse, AppError> {
let relation = service
.issue
.issue_link_repo(
&session,
&path.workspace_name,
path.number,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(relation)))
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct RelationPathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
/// Relation ID (UUID)
pub relation_id: uuid::Uuid,
}
/// Unlink a repository from an issue
///
/// Removes a repository relation from the given issue.
/// Requires write access to the issue.
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/repos/{relation_id}",
tag = "Issues",
operation_id = "issueUnlinkRepo",
params(RelationPathParams),
responses(
(status = 200, description = "Repository unlinked successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
(status = 404, description = "Repository relation not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn unlink_repo(
service: web::Data<AppService>,
session: Session,
path: web::Path<RelationPathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_unlink_repo(
&session,
&path.workspace_name,
path.number,
path.relation_id,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Repo unlinked".to_string())))
}
+186
View File
@@ -0,0 +1,186 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueSubscriber;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of subscribers to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of subscribers to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List subscribers of an issue
///
/// Returns a paginated list of all users subscribed to the given issue.
/// Shows who receives notifications and their subscription reason (author, assignee, manual).
/// Requires read access to the issue.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/subscribers",
tag = "Issues",
operation_id = "issueListSubscribers",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Subscribers listed successfully. Returns array of subscriber objects.", body = ApiResponse<Vec<IssueSubscriber>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_subscribers(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let subscribers = service
.issue
.issue_subscribers(
&session,
&path.workspace_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(subscribers)))
}
/// Subscribe to an issue
///
/// Subscribes the authenticated user to the given issue to receive notifications.
/// Requires read access to the issue.
///
/// Effects:
/// - User is added as a subscriber with "manual" reason
/// - User receives notifications for all issue activity
///
/// Returns the created subscription record.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/subscribe",
tag = "Issues",
operation_id = "issueSubscribe",
params(PathParams),
responses(
(status = 200, description = "Subscribed successfully. Returns the subscription record.", body = ApiResponse<IssueSubscriber>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Issue not found", body = ApiErrorResponse),
(status = 409, description = "Already subscribed to this issue", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn subscribe(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let sub = service
.issue
.issue_subscribe(&session, &path.workspace_name, path.number)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(sub)))
}
/// Unsubscribe from an issue
///
/// Removes the authenticated user's subscription to the given issue.
/// Stops all notifications for this issue.
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/subscribe",
tag = "Issues",
operation_id = "issueUnsubscribe",
params(PathParams),
responses(
(status = 200, description = "Unsubscribed successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 404, description = "Not currently subscribed to this issue", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn unsubscribe(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_unsubscribe(&session, &path.workspace_name, path.number)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Unsubscribed".to_string())))
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct MuteIssueParams {
/// Whether to mute (true) or unmute (false) notifications
pub muted: bool,
}
/// Mute or unmute issue notifications
///
/// Mutes or unmutes notifications for the given issue without unsubscribing.
/// Requires an active subscription to the issue.
///
/// Returns success message on completion.
#[utoipa::path(
put,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/mute",
tag = "Issues",
operation_id = "issueMute",
params(PathParams),
request_body(
content = MuteIssueParams,
description = "Mute/unmute parameters",
content_type = "application/json"
),
responses(
(status = 200, description = "Mute status updated successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 404, description = "Not currently subscribed to this issue", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn mute(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<MuteIssueParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_mute(&session, &path.workspace_name, path.number, params.muted)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Mute status updated".to_string())))
}
+220
View File
@@ -0,0 +1,220 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueTemplate;
use crate::service::AppService;
use crate::service::issues::templates::{CreateTemplateParams, UpdateTemplateParams};
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of templates to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of templates to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List issue templates in a repository
///
/// Returns a paginated list of all active issue templates in the repository.
/// Templates provide pre-filled content for creating new issues.
/// Sorted alphabetically by name.
/// Requires read access to the repository.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/templates",
tag = "Issues",
operation_id = "issueListTemplates",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Templates listed successfully. Returns array of template objects.", body = ApiResponse<Vec<IssueTemplate>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to access this repository", body = ApiErrorResponse),
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_templates(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let templates = service
.issue
.issue_templates(
&session,
&path.workspace_name,
&path.repo_name,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(templates)))
}
/// Create an issue template
///
/// Creates a new issue template in the repository.
/// Requires at least Member role in the repository.
///
/// Parameters:
/// - name: Template name (required)
/// - description: Template description (optional)
/// - title_template: Default title for issues (optional, supports placeholders)
/// - body_template: Default body content in markdown (required)
/// - labels: List of label names to auto-apply (optional)
///
/// Returns the created template with metadata.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/templates",
tag = "Issues",
operation_id = "issueCreateTemplate",
params(PathParams),
request_body(
content = CreateTemplateParams,
description = "Template creation parameters",
content_type = "application/json"
),
responses(
(status = 201, description = "Template created successfully. Returns the newly created template.", body = ApiResponse<IssueTemplate>),
(status = 400, description = "Invalid parameters: empty name or body template", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Member role or higher)", body = ApiErrorResponse),
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn create_template(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<CreateTemplateParams>,
) -> Result<HttpResponse, AppError> {
let template = service
.issue
.issue_create_template(
&session,
&path.workspace_name,
&path.repo_name,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(template)))
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct TemplatePathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// Template ID (UUID)
pub template_id: uuid::Uuid,
}
/// Update an issue template
///
/// Updates an existing issue template's metadata and content.
/// Requires Admin role in the repository.
///
/// All fields are optional; only provided fields are updated.
/// Returns the updated template.
#[utoipa::path(
put,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/templates/{template_id}",
tag = "Issues",
operation_id = "issueUpdateTemplate",
params(TemplatePathParams),
request_body(
content = UpdateTemplateParams,
description = "Template update parameters (all fields optional)",
content_type = "application/json"
),
responses(
(status = 200, description = "Template updated successfully. Returns the updated template.", body = ApiResponse<IssueTemplate>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Admin role)", body = ApiErrorResponse),
(status = 404, description = "Repository, workspace, or template not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn update_template(
service: web::Data<AppService>,
session: Session,
path: web::Path<TemplatePathParams>,
params: web::Json<UpdateTemplateParams>,
) -> Result<HttpResponse, AppError> {
let template = service
.issue
.issue_update_template(
&session,
&path.workspace_name,
&path.repo_name,
path.template_id,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(template)))
}
/// Delete an issue template
///
/// Permanently removes an issue template from the repository.
/// Requires Admin role in the repository.
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/templates/{template_id}",
tag = "Issues",
operation_id = "issueDeleteTemplate",
params(TemplatePathParams),
responses(
(status = 200, description = "Template deleted successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Admin role)", body = ApiErrorResponse),
(status = 404, description = "Template not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn delete_template(
service: web::Data<AppService>,
session: Session,
path: web::Path<TemplatePathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_delete_template(
&session,
&path.workspace_name,
&path.repo_name,
path.template_id,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Template deleted".to_string())))
}
+75
View File
@@ -0,0 +1,75 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::Issue;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct TransferIssueParams {
/// Target workspace name to transfer the issue to
pub target_workspace_name: String,
}
/// Transfer an issue to another workspace
///
/// Moves an issue from the current workspace to a different workspace.
/// Requires Admin role in both the source and target workspaces.
///
/// Effects:
/// - Issue is transferred to the target workspace with a new number
/// - Source workspace issue count is decremented
/// - Target workspace issue count is incremented
///
/// Returns the transferred issue with updated workspace and number.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/transfer",
tag = "Issues",
operation_id = "issueTransfer",
params(PathParams),
request_body(
content = TransferIssueParams,
description = "Transfer parameters",
content_type = "application/json"
),
responses(
(status = 200, description = "Issue transferred successfully. Returns the issue with new workspace assignment.", body = ApiResponse<Issue>),
(status = 400, description = "Invalid target workspace", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions in source or target workspace", body = ApiErrorResponse),
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn transfer(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<TransferIssueParams>,
) -> Result<HttpResponse, AppError> {
let issue = service
.issue
.issue_transfer(
&session,
&path.workspace_name,
path.number,
&params.target_workspace_name,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(issue)))
}
+57
View File
@@ -0,0 +1,57 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
/// User ID (UUID) to unassign
pub user_id: uuid::Uuid,
}
/// Unassign a user from an issue
///
/// Removes a user from the issue's assignee list.
/// Requires write access to the issue (author or workspace member).
///
/// Effects:
/// - User is removed from the issue's assignees
/// - Issue assignee count is decremented
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/assignees/{user_id}",
tag = "Issues",
operation_id = "issueUnassign",
params(PathParams),
responses(
(status = 200, description = "User unassigned successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
(status = 404, description = "User is not assigned to this issue or not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn unassign_issue(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_unassign(&session, &path.workspace_name, path.number, path.user_id)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("User unassigned".to_string())))
}
+57
View File
@@ -0,0 +1,57 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
/// Label ID (UUID) to unassign
pub label_id: uuid::Uuid,
}
/// Unassign a label from an issue
///
/// Removes a label from the given issue.
/// Requires write access to the issue (author or workspace member).
///
/// Effects:
/// - Label relation is removed from the issue
/// - Issue label count is decremented
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/labels/{label_id}",
tag = "Issues",
operation_id = "issueUnassignLabel",
params(PathParams),
responses(
(status = 200, description = "Label unassigned successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
(status = 404, description = "Label is not assigned to this issue or not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn unassign_label(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_unassign_label(&session, &path.workspace_name, path.number, path.label_id)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Label unassigned".to_string())))
}
+66
View File
@@ -0,0 +1,66 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::Issue;
use crate::service::AppService;
use crate::service::issues::core::UpdateIssueParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
/// Update an issue
///
/// Updates an existing issue's metadata such as title, body, priority, visibility, due date, and milestone.
/// Requires write access to the issue (author or workspace member).
///
/// All fields are optional; only provided fields are updated.
/// Returns the updated issue with full metadata.
#[utoipa::path(
put,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}",
tag = "Issues",
operation_id = "issueUpdate",
params(PathParams),
request_body(
content = UpdateIssueParams,
description = "Issue update parameters (all fields optional)",
content_type = "application/json"
),
responses(
(status = 200, description = "Issue updated successfully. Returns the updated issue with full metadata.", body = ApiResponse<Issue>),
(status = 400, description = "Invalid parameters: invalid priority, visibility, or milestone", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
(status = 404, description = "Workspace, issue, or referenced resource not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn update(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<UpdateIssueParams>,
) -> Result<HttpResponse, AppError> {
let issue = service
.issue
.issue_update(
&session,
&path.workspace_name,
path.number,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(issue)))
}
+59
View File
@@ -0,0 +1,59 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueComment;
use crate::service::AppService;
use crate::service::issues::comments::UpdateCommentParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub number: i64,
pub comment_id: uuid::Uuid,
}
/// Update an issue comment
///
/// Updates the body of an existing comment. Only the comment author can update their own comments.
/// Requires read access to the issue.
///
/// Returns the updated comment with edit timestamp.
#[utoipa::path(
put,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/comments/{comment_id}",
tag = "Issues",
operation_id = "issueUpdateComment",
params(PathParams),
request_body(content = UpdateCommentParams, description = "Comment update parameters", content_type = "application/json"),
responses(
(status = 200, description = "Comment updated successfully.", body = ApiResponse<IssueComment>),
(status = 400, description = "Invalid parameters: empty body", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Cannot edit other users' comments", body = ApiErrorResponse),
(status = 404, description = "Workspace, issue, or comment not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn update_comment(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<UpdateCommentParams>,
) -> Result<HttpResponse, AppError> {
let comment = service
.issue
.issue_update_comment(
&session,
&path.workspace_name,
path.number,
path.comment_id,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(comment)))
}
+59
View File
@@ -0,0 +1,59 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueLabel;
use crate::service::AppService;
use crate::service::issues::labels::UpdateLabelParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub repo_name: String,
pub label_id: uuid::Uuid,
}
/// Update a label
///
/// Updates an existing issue label's name, color, or description.
/// Requires Admin role in the repository.
///
/// All fields are optional; only provided fields are updated.
/// Returns the updated label.
#[utoipa::path(
put,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/labels/{label_id}",
tag = "Issues",
operation_id = "issueUpdateLabel",
params(PathParams),
request_body(content = UpdateLabelParams, description = "Label update parameters (all fields optional)", content_type = "application/json"),
responses(
(status = 200, description = "Label updated successfully.", body = ApiResponse<IssueLabel>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Admin role)", body = ApiErrorResponse),
(status = 404, description = "Repository, workspace, or label not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn update_label(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<UpdateLabelParams>,
) -> Result<HttpResponse, AppError> {
let label = service
.issue
.issue_update_label(
&session,
&path.workspace_name,
&path.repo_name,
path.label_id,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(label)))
}
+75
View File
@@ -0,0 +1,75 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueMilestone;
use crate::service::AppService;
use crate::service::issues::milestones::UpdateMilestoneParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// Milestone ID (UUID)
pub milestone_id: uuid::Uuid,
}
/// Update a milestone
///
/// Updates an existing milestone's metadata. Can also close or reopen the milestone via the state field.
/// Requires at least Member role in the repository.
///
/// Updatable fields:
/// - title: Milestone title (optional)
/// - description: Description (optional)
/// - due_at: Target due date (optional)
/// - state: State ("open" or "closed") for closing/reopening the milestone (optional)
///
/// All fields are optional; only provided fields are updated.
/// Returns the updated milestone with full metadata.
#[utoipa::path(
put,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/milestones/{milestone_id}",
tag = "Issues",
operation_id = "issueUpdateMilestone",
params(PathParams),
request_body(
content = UpdateMilestoneParams,
description = "Milestone update parameters (all fields optional)",
content_type = "application/json"
),
responses(
(status = 200, description = "Milestone updated successfully. Returns the updated milestone with full metadata.", body = ApiResponse<IssueMilestone>),
(status = 400, description = "Invalid parameters", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Member role or higher)", body = ApiErrorResponse),
(status = 404, description = "Repository, workspace, or milestone not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn update_milestone(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<UpdateMilestoneParams>,
) -> Result<HttpResponse, AppError> {
let milestone = service
.issue
.issue_update_milestone(
&session,
&path.workspace_name,
&path.repo_name,
path.milestone_id,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(milestone)))
}