feat: kanban: coments
runner nix smoke / nix label and flake smoke (push) Failing after 1m17s

This commit is contained in:
2026-09-29 19:48:14 +00:00
parent c93dee27b3
commit e22dcdda80
6 changed files with 253 additions and 3 deletions
+15
View File
@@ -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 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`
Delete an issue assigned to selected project. This is irreversible and requires Delete an issue assigned to selected project. This is irreversible and requires
+4
View File
@@ -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 edit 42 --title "Updated" --body "Changed"
gitea-kanban --project Kanban owner repo move 123 --column-id 7 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 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 `delete` requires repository admin permission. `move` takes global issue and
+62 -2
View File
@@ -9,8 +9,8 @@ use serde::de::DeserializeOwned;
use crate::config::Config; use crate::config::Config;
use crate::model::{ use crate::model::{
CreateIssuePayload, EditIssuePayload, Issue, MoveProjectIssuePayload, Project, ProjectBoard, Comment, CommentPayload, CreateIssuePayload, EditIssuePayload, Issue, MoveProjectIssuePayload,
ProjectColumn, Project, ProjectBoard, ProjectColumn,
}; };
pub trait GiteaApi { pub trait GiteaApi {
@@ -30,6 +30,10 @@ pub trait GiteaApi {
fn create_issue(&self, payload: &CreateIssuePayload) -> Result<Issue, ApiError>; fn create_issue(&self, payload: &CreateIssuePayload) -> Result<Issue, ApiError>;
fn edit_issue(&self, issue_number: u64, payload: &EditIssuePayload) -> Result<Issue, ApiError>; fn edit_issue(&self, issue_number: u64, payload: &EditIssuePayload) -> Result<Issue, ApiError>;
fn delete_issue(&self, issue_number: u64) -> Result<(), ApiError>; fn delete_issue(&self, issue_number: u64) -> Result<(), ApiError>;
fn list_issue_comments(&self, issue_number: u64) -> Result<Vec<Comment>, ApiError>;
fn create_issue_comment(&self, issue_number: u64, body: String) -> Result<Comment, ApiError>;
fn edit_issue_comment(&self, comment_id: u64, body: String) -> Result<Comment, ApiError>;
fn delete_issue_comment(&self, comment_id: u64) -> Result<(), ApiError>;
} }
pub struct GiteaClient { pub struct GiteaClient {
@@ -279,6 +283,62 @@ impl GiteaApi for GiteaClient {
check_response(response, &format!("deleting issue #{issue_number}"))?; check_response(response, &format!("deleting issue #{issue_number}"))?;
Ok(()) Ok(())
} }
fn list_issue_comments(&self, issue_number: u64) -> Result<Vec<Comment>, 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<Comment, ApiError> {
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<Comment, ApiError> {
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<Response, ApiError> { fn check_response(response: Response, operation: &str) -> Result<Response, ApiError> {
@@ -55,6 +55,37 @@ enum Command {
#[arg(long)] #[arg(long)]
sorting: Option<u64>, sorting: Option<u64>,
}, },
/// 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() { fn main() {
@@ -140,6 +171,81 @@ fn run() -> Result<(), Box<dyn Error>> {
)?; )?;
println!("moved issue {issue_id} to column {column_id}"); 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<Vec<gitea_kanban_tui::model::Comment>, Box<dyn Error>> {
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<dyn Error>> {
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(()) Ok(())
} }
+38
View File
@@ -9,6 +9,24 @@ pub struct Issue {
pub body: Option<String>, pub body: Option<String>,
} }
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct Comment {
pub id: u64,
pub body: String,
#[serde(default)]
pub user: Option<CommentUser>,
}
#[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)] #[derive(Debug, Serialize, PartialEq, Eq)]
pub struct CreateIssuePayload { pub struct CreateIssuePayload {
pub title: String, pub title: String,
@@ -117,4 +135,24 @@ mod tests {
serde_json::json!({"title": "Updated", "body": "Changed"}) 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"})
);
}
} }
+28 -1
View File
@@ -1,5 +1,5 @@
use crate::api::{ApiError, GiteaApi}; 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( pub fn create_project_issue(
client: &impl GiteaApi, client: &impl GiteaApi,
@@ -34,3 +34,30 @@ pub fn move_project_issue(
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
client.move_project_issue(project_id, issue_id, payload) client.move_project_issue(project_id, issue_id, payload)
} }
pub fn list_issue_comments(
client: &impl GiteaApi,
issue_number: u64,
) -> Result<Vec<Comment>, ApiError> {
client.list_issue_comments(issue_number)
}
pub fn create_issue_comment(
client: &impl GiteaApi,
issue_number: u64,
body: String,
) -> Result<Comment, ApiError> {
client.create_issue_comment(issue_number, body)
}
pub fn edit_issue_comment(
client: &impl GiteaApi,
comment_id: u64,
body: String,
) -> Result<Comment, ApiError> {
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)
}