diff --git a/docs/gitea-kanban-cli.md b/docs/gitea-kanban-cli.md index 3d6077df..97308731 100644 --- a/docs/gitea-kanban-cli.md +++ b/docs/gitea-kanban-cli.md @@ -98,6 +98,21 @@ gitea-kanban --project Kanban owner repo move 123 --column-id 7 gitea-kanban --project Kanban owner repo move 123 --column-id 7 --sorting 2 ``` +### `comment` + +Comments are managed on issues assigned to the selected project. Issue values +are repository issue numbers. Comment values are global Gitea comment IDs. + +```sh +gitea-kanban --project Kanban owner repo comment list 42 +gitea-kanban --project Kanban owner repo comment add 42 --body "Investigating" +gitea-kanban --project Kanban owner repo comment edit 42 9001 --body "Resolved" +gitea-kanban --project Kanban owner repo comment delete 42 9001 --yes +``` + +The CLI verifies that the issue belongs to the selected project and that the +comment belongs to that issue before editing or deleting it. + ### `delete` Delete an issue assigned to selected project. This is irreversible and requires diff --git a/package/gitea-kanban-tui/README.md b/package/gitea-kanban-tui/README.md index df412d8d..cb5d4503 100644 --- a/package/gitea-kanban-tui/README.md +++ b/package/gitea-kanban-tui/README.md @@ -58,6 +58,10 @@ gitea-kanban --project Kanban owner repo create --title "Fix issue" --body "Deta gitea-kanban --project Kanban owner repo edit 42 --title "Updated" --body "Changed" gitea-kanban --project Kanban owner repo move 123 --column-id 7 gitea-kanban --project Kanban owner repo delete 42 --yes +gitea-kanban --project Kanban owner repo comment list 42 +gitea-kanban --project Kanban owner repo comment add 42 --body "Investigating" +gitea-kanban --project Kanban owner repo comment edit 42 9001 --body "Resolved" +gitea-kanban --project Kanban owner repo comment delete 42 9001 --yes ``` `delete` requires repository admin permission. `move` takes global issue and diff --git a/package/gitea-kanban-tui/src/api.rs b/package/gitea-kanban-tui/src/api.rs index bda4eb2e..2adf14b8 100644 --- a/package/gitea-kanban-tui/src/api.rs +++ b/package/gitea-kanban-tui/src/api.rs @@ -9,8 +9,8 @@ use serde::de::DeserializeOwned; use crate::config::Config; use crate::model::{ - CreateIssuePayload, EditIssuePayload, Issue, MoveProjectIssuePayload, Project, ProjectBoard, - ProjectColumn, + Comment, CommentPayload, CreateIssuePayload, EditIssuePayload, Issue, MoveProjectIssuePayload, + Project, ProjectBoard, ProjectColumn, }; pub trait GiteaApi { @@ -30,6 +30,10 @@ pub trait GiteaApi { fn create_issue(&self, payload: &CreateIssuePayload) -> Result; fn edit_issue(&self, issue_number: u64, payload: &EditIssuePayload) -> Result; fn delete_issue(&self, issue_number: u64) -> Result<(), ApiError>; + fn list_issue_comments(&self, issue_number: u64) -> Result, ApiError>; + fn create_issue_comment(&self, issue_number: u64, body: String) -> Result; + fn edit_issue_comment(&self, comment_id: u64, body: String) -> Result; + fn delete_issue_comment(&self, comment_id: u64) -> Result<(), ApiError>; } pub struct GiteaClient { @@ -279,6 +283,62 @@ impl GiteaApi for GiteaClient { check_response(response, &format!("deleting issue #{issue_number}"))?; Ok(()) } + + fn list_issue_comments(&self, issue_number: u64) -> Result, ApiError> { + self.get_all( + &format!("issues/{issue_number}/comments"), + &format!("listing comments for issue #{issue_number}"), + &[], + ) + } + + fn create_issue_comment(&self, issue_number: u64, body: String) -> Result { + let response = self + .client + .post(self.endpoint(&format!("issues/{issue_number}/comments"))?) + .header("Authorization", format!("token {}", self.token)) + .json(&CommentPayload { body }) + .send() + .map_err(|error| { + ApiError(format!( + "cannot reach Gitea while creating comment on issue #{issue_number}: {error}" + )) + })?; + self.decode( + response, + &format!("creating comment on issue #{issue_number}"), + ) + } + + fn edit_issue_comment(&self, comment_id: u64, body: String) -> Result { + let response = self + .client + .patch(self.endpoint(&format!("issues/comments/{comment_id}"))?) + .header("Authorization", format!("token {}", self.token)) + .json(&CommentPayload { body }) + .send() + .map_err(|error| { + ApiError(format!( + "cannot reach Gitea while editing comment #{comment_id}: {error}" + )) + })?; + self.decode(response, &format!("editing comment #{comment_id}")) + } + + fn delete_issue_comment(&self, comment_id: u64) -> Result<(), ApiError> { + let response = self + .client + .delete(self.endpoint(&format!("issues/comments/{comment_id}"))?) + .header("Authorization", format!("token {}", self.token)) + .send() + .map_err(|error| { + ApiError(format!( + "cannot reach Gitea while deleting comment #{comment_id}: {error}" + )) + })?; + check_response(response, &format!("deleting comment #{comment_id}"))?; + Ok(()) + } } fn check_response(response: Response, operation: &str) -> Result { diff --git a/package/gitea-kanban-tui/src/bin/gitea-kanban.rs b/package/gitea-kanban-tui/src/bin/gitea-kanban.rs index a3d3671e..ad250d6a 100644 --- a/package/gitea-kanban-tui/src/bin/gitea-kanban.rs +++ b/package/gitea-kanban-tui/src/bin/gitea-kanban.rs @@ -55,6 +55,37 @@ enum Command { #[arg(long)] sorting: Option, }, + /// Manage comments on an issue in selected project. + Comment { + #[command(subcommand)] + command: CommentCommand, + }, +} + +#[derive(Debug, Subcommand)] +enum CommentCommand { + /// List comments for an issue. + List { issue: u64 }, + /// Add a comment to an issue. + Add { + issue: u64, + #[arg(long)] + body: String, + }, + /// Replace a comment body. + Edit { + issue: u64, + comment_id: u64, + #[arg(long)] + body: String, + }, + /// Delete a comment. + Delete { + issue: u64, + comment_id: u64, + #[arg(long)] + yes: bool, + }, } fn main() { @@ -140,6 +171,81 @@ fn run() -> Result<(), Box> { )?; println!("moved issue {issue_id} to column {column_id}"); } + Command::Comment { command } => match command { + CommentCommand::List { issue } => { + for comment in project_issue_comments(&client, &config, issue)? { + let author = comment + .user + .as_ref() + .map(|user| user.login.as_str()) + .unwrap_or("unknown"); + println!( + "#{} {}\n{}", + comment.id, + sanitize_terminal_text(author), + sanitize_terminal_text(&comment.body) + ); + } + } + CommentCommand::Add { issue, body } => { + project_issue_comments(&client, &config, issue)?; + let comment = operations::create_issue_comment(&client, issue, body)?; + println!("created comment #{} on issue #{}", comment.id, issue); + } + CommentCommand::Edit { + issue, + comment_id, + body, + } => { + ensure_comment(&client, &config, issue, comment_id)?; + operations::edit_issue_comment(&client, comment_id, body)?; + println!("updated comment #{comment_id}"); + } + CommentCommand::Delete { + issue, + comment_id, + yes, + } => { + if !yes { + return Err("comment deletion requires --yes".into()); + } + ensure_comment(&client, &config, issue, comment_id)?; + operations::delete_issue_comment(&client, comment_id)?; + println!("deleted comment #{comment_id}"); + } + }, + } + Ok(()) +} + +fn project_issue_comments( + client: &GiteaClient, + config: &Config, + issue: u64, +) -> Result, Box> { + let board = load_project_board(client, config)?; + if !board + .issues_by_column + .iter() + .flatten() + .any(|candidate| candidate.number == issue) + { + return Err(format!("issue #{issue} is not assigned to selected project").into()); + } + Ok(operations::list_issue_comments(client, issue)?) +} + +fn ensure_comment( + client: &GiteaClient, + config: &Config, + issue: u64, + comment_id: u64, +) -> Result<(), Box> { + if !project_issue_comments(client, config, issue)? + .iter() + .any(|comment| comment.id == comment_id) + { + return Err(format!("comment #{comment_id} is not attached to issue #{issue}").into()); } Ok(()) } diff --git a/package/gitea-kanban-tui/src/model.rs b/package/gitea-kanban-tui/src/model.rs index b6da55b3..15dd69c1 100644 --- a/package/gitea-kanban-tui/src/model.rs +++ b/package/gitea-kanban-tui/src/model.rs @@ -9,6 +9,24 @@ pub struct Issue { pub body: Option, } +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct Comment { + pub id: u64, + pub body: String, + #[serde(default)] + pub user: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct CommentUser { + pub login: String, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +pub struct CommentPayload { + pub body: String, +} + #[derive(Debug, Serialize, PartialEq, Eq)] pub struct CreateIssuePayload { pub title: String, @@ -117,4 +135,24 @@ mod tests { serde_json::json!({"title": "Updated", "body": "Changed"}) ); } + + #[test] + fn serializes_and_deserializes_comment_data() { + let comment: Comment = serde_json::from_value(serde_json::json!({ + "id": 9001, + "body": "Investigating", + "user": {"login": "alice"} + })) + .expect("comment JSON"); + let payload = CommentPayload { + body: "Resolved".to_owned(), + }; + + assert_eq!(comment.id, 9001); + assert_eq!(comment.user.expect("comment user").login, "alice"); + assert_eq!( + serde_json::to_value(payload).expect("comment payload"), + serde_json::json!({"body": "Resolved"}) + ); + } } diff --git a/package/gitea-kanban-tui/src/operations.rs b/package/gitea-kanban-tui/src/operations.rs index a41feb97..09ac950c 100644 --- a/package/gitea-kanban-tui/src/operations.rs +++ b/package/gitea-kanban-tui/src/operations.rs @@ -1,5 +1,5 @@ use crate::api::{ApiError, GiteaApi}; -use crate::model::{CreateIssuePayload, EditIssuePayload, Issue, MoveProjectIssuePayload}; +use crate::model::{Comment, CreateIssuePayload, EditIssuePayload, Issue, MoveProjectIssuePayload}; pub fn create_project_issue( client: &impl GiteaApi, @@ -34,3 +34,30 @@ pub fn move_project_issue( ) -> Result<(), ApiError> { client.move_project_issue(project_id, issue_id, payload) } + +pub fn list_issue_comments( + client: &impl GiteaApi, + issue_number: u64, +) -> Result, ApiError> { + client.list_issue_comments(issue_number) +} + +pub fn create_issue_comment( + client: &impl GiteaApi, + issue_number: u64, + body: String, +) -> Result { + client.create_issue_comment(issue_number, body) +} + +pub fn edit_issue_comment( + client: &impl GiteaApi, + comment_id: u64, + body: String, +) -> Result { + client.edit_issue_comment(comment_id, body) +} + +pub fn delete_issue_comment(client: &impl GiteaApi, comment_id: u64) -> Result<(), ApiError> { + client.delete_issue_comment(comment_id) +}