This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user