From 0cbf267474b9a9f6f3e67196cf75ccb88eaef1eb Mon Sep 17 00:00:00 2001 From: yukkop Date: Mon, 28 Sep 2026 00:08:44 +0000 Subject: [PATCH] feat: more crud for kanban --- package/gitea-kanban-tui/README.md | 11 +- package/gitea-kanban-tui/src/api.rs | 125 ++++++++- package/gitea-kanban-tui/src/app.rs | 256 +++++++++++++++++- package/gitea-kanban-tui/src/main.rs | 55 +++- package/gitea-kanban-tui/src/model.rs | 34 +++ package/gitea-kanban-tui/src/text.rs | 21 ++ package/gitea-kanban-tui/src/ui.rs | 83 +++++- package/gitea/source/routers/api/v1/api.go | 12 +- .../gitea/source/routers/api/v1/repo/issue.go | 53 ++++ .../tests/integration/api_project_test.go | 13 + 10 files changed, 639 insertions(+), 24 deletions(-) diff --git a/package/gitea-kanban-tui/README.md b/package/gitea-kanban-tui/README.md index d3c04e73..a41ded9c 100644 --- a/package/gitea-kanban-tui/README.md +++ b/package/gitea-kanban-tui/README.md @@ -24,8 +24,10 @@ Required configuration can come from flags or environment variables: | `--label-prefix` | `GITEA_LABEL_PREFIX` | Column prefix; default `kanban/` | Native mode requires an enabled repository Projects unit, project read/write -repository permission, and a token with `read:issue`/`write:issue` scope. Label -mode requires repository issue read/write permission. Token values are sent +repository permission, and a token with `read:issue`/`write:issue` scope. Issue +creation/editing follows Gitea issue permissions; deletion requires repository +admin permission in this fork. Label mode requires repository issue read/write +permission. Token values are sent only through Gitea's `Authorization` header and are never printed. Token-file input takes precedence over environment variables. Remote URLs must use HTTPS; plain HTTP is accepted only for loopback development. @@ -57,7 +59,10 @@ Native mode reads `/projects`, project columns, and each column's issues. Moves use the issue's global API `id` and destination column `id`; optional sorting is supported by the server API. Project names are exact, case-sensitive matches; use `--project-id` when duplicate names exist. Empty native boards render -normally. Closed projects, archived repositories, disabled Projects units, +normally. `n` creates an issue assigned to the selected project, `e` edits the +focused issue title/body, and `d` deletes it after confirmation. In the editor, +`Enter` switches from title to body, `Tab` switches fields, `Ctrl-S` saves, and +`Esc` cancels. Closed projects, archived repositories, disabled Projects units, unassigned issues, and cross-repository IDs are rejected by the server. Explicit fallback command: diff --git a/package/gitea-kanban-tui/src/api.rs b/package/gitea-kanban-tui/src/api.rs index a0fd99fa..efa278d9 100644 --- a/package/gitea-kanban-tui/src/api.rs +++ b/package/gitea-kanban-tui/src/api.rs @@ -1,4 +1,5 @@ use std::fmt; +use std::net::IpAddr; use std::time::Duration; use reqwest::blocking::{Client, Response}; @@ -8,7 +9,8 @@ use serde::de::DeserializeOwned; use crate::config::Config; use crate::model::{ - Issue, Label, MoveProjectIssuePayload, Project, ProjectColumn, ReplaceLabelsPayload, + CreateIssuePayload, EditIssuePayload, Issue, Label, MoveProjectIssuePayload, Project, + ProjectColumn, ReplaceLabelsPayload, }; use crate::text::sanitize_terminal_text; @@ -33,6 +35,9 @@ pub trait GiteaApi { issue_number: u64, payload: &ReplaceLabelsPayload, ) -> Result<(), ApiError>; + 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>; } pub struct GiteaClient { @@ -85,6 +90,11 @@ impl GiteaClient { pub fn new(config: &Config) -> Result { let mut api_base = Url::parse(&config.base_url) .map_err(|error| ApiError(format!("invalid Gitea URL: {error}")))?; + if !api_base.username().is_empty() || api_base.password().is_some() { + return Err(ApiError( + "Gitea URL must not contain embedded username or password".to_owned(), + )); + } require_secure_transport(&api_base)?; api_base.set_query(None); api_base.set_fragment(None); @@ -121,7 +131,7 @@ impl GiteaClient { response: Response, operation: &str, ) -> Result { - let response = check_response(response, operation)?; + let response = check_response(response, operation, &self.token)?; response.json().map_err(|error| { ApiError(format!( "Gitea returned invalid JSON while {operation}: {error}" @@ -213,7 +223,7 @@ impl GiteaApi for GiteaClient { "cannot reach Gitea while moving project issue: {error}" )) })?; - check_response(response, "moving project issue")?; + check_response(response, "moving project issue", &self.token)?; Ok(()) } @@ -248,12 +258,60 @@ impl GiteaApi for GiteaClient { check_response( response, &format!("replacing labels on issue #{issue_number}"), + &self.token, + )?; + Ok(()) + } + + fn create_issue(&self, payload: &CreateIssuePayload) -> Result { + let response = self + .client + .post(self.endpoint("issues")?) + .header("Authorization", format!("token {}", self.token)) + .json(payload) + .send() + .map_err(|error| { + ApiError(format!("cannot reach Gitea while creating issue: {error}")) + })?; + self.decode(response, "creating issue") + } + + fn edit_issue(&self, issue_number: u64, payload: &EditIssuePayload) -> Result { + let response = self + .client + .patch(self.endpoint(&format!("issues/{issue_number}"))?) + .header("Authorization", format!("token {}", self.token)) + .json(payload) + .send() + .map_err(|error| { + ApiError(format!( + "cannot reach Gitea while editing issue #{issue_number}: {error}" + )) + })?; + self.decode(response, &format!("editing issue #{issue_number}")) + } + + fn delete_issue(&self, issue_number: u64) -> Result<(), ApiError> { + let response = self + .client + .delete(self.endpoint(&format!("issues/{issue_number}"))?) + .header("Authorization", format!("token {}", self.token)) + .send() + .map_err(|error| { + ApiError(format!( + "cannot reach Gitea while deleting issue #{issue_number}: {error}" + )) + })?; + check_response( + response, + &format!("deleting issue #{issue_number}"), + &self.token, )?; Ok(()) } } -fn check_response(response: Response, operation: &str) -> Result { +fn check_response(response: Response, operation: &str, token: &str) -> Result { let status = response.status(); if status.is_success() { return Ok(response); @@ -263,7 +321,8 @@ fn check_response(response: Response, operation: &str) -> Result(&body).ok()) - .and_then(|value| value.get("message")?.as_str().map(sanitize_terminal_text)); + .and_then(|value| value.get("message")?.as_str().map(sanitize_terminal_text)) + .map(|message| message.replace(token, "[redacted token]")); let detail = message .map(|message| format!(": {message}")) .unwrap_or_default(); @@ -285,7 +344,15 @@ fn require_secure_transport(url: &Url) -> Result<(), ApiError> { if url.scheme() == "https" { return Ok(()); } - let loopback = matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "::1")); + let loopback = url + .host_str() + .and_then(|host| { + host.trim_start_matches('[') + .trim_end_matches(']') + .parse::() + .ok() + }) + .is_some_and(|address| address.is_loopback()); if url.scheme() == "http" && loopback { return Ok(()); } @@ -374,7 +441,9 @@ mod tests { #[test] fn rejects_remote_plain_http_but_allows_loopback() { assert!(GiteaClient::new(&config("http://gitea.example")).is_err()); + assert!(GiteaClient::new(&config("http://localhost:3000")).is_err()); assert!(GiteaClient::new(&config("http://127.0.0.1:3000")).is_ok()); + assert!(GiteaClient::new(&config("http://[::1]:3000")).is_ok()); } #[test] @@ -422,7 +491,7 @@ mod tests { assert!(first.contains("authorization: token secret")); let (base_url, _) = mock_server(vec![ - "HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{\"message\":\"projects unavailable\"}", + "HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{\"message\":\"projects unavailable secret\"}", ]); let client = GiteaClient::new(&config(&base_url)).expect("client"); let error = client @@ -470,4 +539,46 @@ mod tests { assert!(requests.recv().expect("page one").contains("page=1")); assert!(requests.recv().expect("page two").contains("page=2")); } + + #[test] + fn creates_edits_and_deletes_issues() { + let (base_url, requests) = mock_server(vec![ + "HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{\"id\":99,\"number\":7,\"title\":\"New\"}", + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{\"id\":99,\"number\":7,\"title\":\"Updated\"}", + "HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ]); + let client = GiteaClient::new(&config(&base_url)).expect("client"); + let created = client + .create_issue(&CreateIssuePayload { + title: "New".to_owned(), + body: "Details".to_owned(), + projects: vec![4], + }) + .expect("create"); + assert_eq!(created.number, 7); + let edited = client + .edit_issue( + 7, + &EditIssuePayload { + title: "Updated".to_owned(), + body: "Changed".to_owned(), + }, + ) + .expect("edit"); + assert_eq!(edited.title, "Updated"); + client.delete_issue(7).expect("delete"); + + assert!(requests + .recv() + .expect("create request") + .ends_with("{\"title\":\"New\",\"body\":\"Details\",\"projects\":[4]}")); + assert!(requests + .recv() + .expect("edit request") + .starts_with("PATCH /api/v1/repos/owner%20name/repo%2Fname/issues/7 HTTP/1.1")); + assert!(requests + .recv() + .expect("delete request") + .starts_with("DELETE /api/v1/repos/owner%20name/repo%2Fname/issues/7 HTTP/1.1")); + } } diff --git a/package/gitea-kanban-tui/src/app.rs b/package/gitea-kanban-tui/src/app.rs index 55deca4d..22e001b9 100644 --- a/package/gitea-kanban-tui/src/app.rs +++ b/package/gitea-kanban-tui/src/app.rs @@ -1,6 +1,8 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use crate::model::{ - extract_columns, replacement_payload, ColumnSpec, Issue, Label, MoveProjectIssuePayload, - Project, ProjectColumn, ReplaceLabelsPayload, + extract_columns, replacement_payload, ColumnSpec, EditIssuePayload, Issue, Label, + MoveProjectIssuePayload, Project, ProjectColumn, ReplaceLabelsPayload, }; #[derive(Clone, Debug)] @@ -16,9 +18,47 @@ pub struct App { pub focused_cards: Vec, pub status: String, pub show_help: bool, + pub editor: Option, backend: BoardBackend, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum EditorMode { + Create { project_id: u64 }, + Edit { issue_number: u64 }, + Delete { issue_number: u64, title: String }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EditorField { + Title, + Body, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EditorState { + pub mode: EditorMode, + pub field: EditorField, + pub title: String, + pub body: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum IssueAction { + Create { + project_id: u64, + title: String, + body: String, + }, + Edit { + issue_number: u64, + payload: EditIssuePayload, + }, + Delete { + issue_number: u64, + }, +} + #[derive(Clone, Debug)] enum BoardBackend { Projects { project_id: u64 }, @@ -92,6 +132,7 @@ impl App { focused_cards, status: "Ready".to_owned(), show_help: false, + editor: None, backend: BoardBackend::Labels { prefix: label_prefix, }, @@ -133,6 +174,7 @@ impl App { focused_cards, status: "Ready".to_owned(), show_help: false, + editor: None, backend: BoardBackend::Projects { project_id: project.id, }, @@ -210,10 +252,182 @@ impl App { self.focused_column = request.target; self.status = format!("Moved issue #{issue_number}"); } + + pub fn begin_create(&mut self) { + if !matches!(&self.backend, BoardBackend::Projects { .. }) { + self.status = "Issue editing is available only with --backend projects".to_owned(); + return; + } + let project_id = match &self.backend { + BoardBackend::Projects { project_id } => *project_id, + BoardBackend::Labels { .. } => unreachable!("backend checked above"), + }; + self.editor = Some(EditorState { + mode: EditorMode::Create { project_id }, + field: EditorField::Title, + title: String::new(), + body: String::new(), + }); + } + + pub fn begin_edit(&mut self) { + if !matches!(&self.backend, BoardBackend::Projects { .. }) { + self.status = "Issue editing is available only with --backend projects".to_owned(); + return; + } + let Some(issue) = self.focused_card().cloned() else { + self.status = "No issue selected".to_owned(); + return; + }; + self.editor = Some(EditorState { + mode: EditorMode::Edit { + issue_number: issue.number, + }, + field: EditorField::Title, + title: issue.title, + body: issue.body.unwrap_or_default(), + }); + } + + pub fn begin_delete(&mut self) { + if !matches!(&self.backend, BoardBackend::Projects { .. }) { + self.status = "Issue editing is available only with --backend projects".to_owned(); + return; + } + let Some(issue) = self.focused_card() else { + self.status = "No issue selected".to_owned(); + return; + }; + self.editor = Some(EditorState { + mode: EditorMode::Delete { + issue_number: issue.number, + title: issue.title.clone(), + }, + field: EditorField::Title, + title: String::new(), + body: String::new(), + }); + } + + pub fn handle_editor_key(&mut self, key: KeyEvent) -> Option { + let mut editor = self.editor.take()?; + if matches!(editor.mode, EditorMode::Delete { .. }) { + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + if let EditorMode::Delete { issue_number, .. } = editor.mode { + return Some(IssueAction::Delete { issue_number }); + } + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + self.status = "Delete cancelled".to_owned(); + } + _ => { + self.editor = Some(editor); + } + } + return None; + } + + if key.code == KeyCode::Esc { + self.status = "Edit cancelled".to_owned(); + return None; + } + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') { + return self.submit_editor(editor); + } + match key.code { + KeyCode::Tab => { + editor.field = match editor.field { + EditorField::Title => EditorField::Body, + EditorField::Body => EditorField::Title, + }; + } + KeyCode::Enter if editor.field == EditorField::Title => { + editor.field = EditorField::Body; + } + KeyCode::Enter => editor.body.push('\n'), + KeyCode::Backspace => match editor.field { + EditorField::Title => { + editor.title.pop(); + } + EditorField::Body => { + editor.body.pop(); + } + }, + KeyCode::Char(character) if !character.is_control() => match editor.field { + EditorField::Title => editor.title.push(character), + EditorField::Body => editor.body.push(character), + }, + _ => {} + } + self.editor = Some(editor); + None + } + + pub fn restore_issue_action(&mut self, action: IssueAction) { + self.editor = Some(match action { + IssueAction::Create { + project_id, + title, + body, + } => EditorState { + mode: EditorMode::Create { project_id }, + field: EditorField::Body, + title, + body, + }, + IssueAction::Edit { + issue_number, + payload, + } => EditorState { + mode: EditorMode::Edit { issue_number }, + field: EditorField::Body, + title: payload.title, + body: payload.body, + }, + IssueAction::Delete { issue_number } => EditorState { + mode: EditorMode::Delete { + issue_number, + title: self + .focused_card() + .map(|issue| issue.title.clone()) + .unwrap_or_default(), + }, + field: EditorField::Title, + title: String::new(), + body: String::new(), + }, + }); + } + + fn submit_editor(&mut self, editor: EditorState) -> Option { + if editor.title.trim().is_empty() { + self.status = "Title cannot be empty".to_owned(); + self.editor = Some(editor); + return None; + } + match editor.mode { + EditorMode::Create { project_id } => Some(IssueAction::Create { + project_id, + title: editor.title, + body: editor.body, + }), + EditorMode::Edit { issue_number } => Some(IssueAction::Edit { + issue_number, + payload: EditIssuePayload { + title: editor.title, + body: editor.body, + }, + }), + EditorMode::Delete { .. } => None, + } + } } #[cfg(test)] mod tests { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use super::*; fn label(id: u64, name: &str) -> Label { @@ -352,6 +566,44 @@ mod tests { assert_eq!(app.columns[1].cards[0].id, 99); } + #[test] + fn native_editor_creates_project_issue_action() { + let mut app = App::new_project( + Project { + id: 8, + title: "Kanban".to_owned(), + is_closed: false, + }, + vec![ProjectColumn { + id: 10, + title: "Todo".to_owned(), + color: String::new(), + sorting: 0, + }], + vec![Vec::new()], + ) + .expect("board"); + app.begin_create(); + for character in "New issue".chars() { + app.handle_editor_key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE)); + } + app.handle_editor_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + for character in "Details".chars() { + app.handle_editor_key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE)); + } + let action = + app.handle_editor_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + assert!(matches!( + action, + Some(IssueAction::Create { + project_id: 8, + title, + body, + }) if title == "New issue" && body == "Details" + )); + assert!(app.editor.is_none()); + } + #[test] fn label_board_without_cards_keeps_actionable_error() { let result = App::new_labels( diff --git a/package/gitea-kanban-tui/src/main.rs b/package/gitea-kanban-tui/src/main.rs index 246cae88..802df64f 100644 --- a/package/gitea-kanban-tui/src/main.rs +++ b/package/gitea-kanban-tui/src/main.rs @@ -4,14 +4,16 @@ use std::time::Duration; use crossterm::event::{self, Event, KeyCode, KeyEventKind}; use gitea_kanban_tui::api::{resolve_project, GiteaApi, GiteaClient}; -use gitea_kanban_tui::app::{App, MoveAction}; +use gitea_kanban_tui::app::{App, IssueAction, MoveAction}; use gitea_kanban_tui::config::{Backend, Config}; +use gitea_kanban_tui::model::CreateIssuePayload; use gitea_kanban_tui::terminal::TerminalGuard; +use gitea_kanban_tui::text::sanitize_terminal_text; use gitea_kanban_tui::ui; fn main() { if let Err(error) = run() { - eprintln!("error: {error}"); + eprintln!("error: {}", sanitize_terminal_text(&error.to_string())); std::process::exit(1); } } @@ -37,6 +39,13 @@ fn run() -> Result<(), Box> { continue; } + if app.editor.is_some() { + if let Some(action) = app.handle_editor_key(key) { + issue_action(&client, &config, &mut app, action); + } + continue; + } + match key.code { KeyCode::Char('q') => break, KeyCode::Left | KeyCode::Char('h') => app.focus_left(), @@ -45,6 +54,9 @@ fn run() -> Result<(), Box> { KeyCode::Down | KeyCode::Char('j') => app.focus_down(), KeyCode::Char('H') => move_card(&client, &mut app, -1), KeyCode::Char('L') => move_card(&client, &mut app, 1), + KeyCode::Char('n') => app.begin_create(), + KeyCode::Char('e') => app.begin_edit(), + KeyCode::Char('d') => app.begin_delete(), KeyCode::Char('r') => match load_board(&client, &config) { Ok(board) => app = board, Err(error) => app.status = format!("Refresh failed: {error}"), @@ -56,6 +68,45 @@ fn run() -> Result<(), Box> { Ok(()) } +fn issue_action(client: &impl GiteaApi, config: &Config, app: &mut App, action: IssueAction) { + let retry_action = action.clone(); + let message = match action { + IssueAction::Create { + project_id, + title, + body, + } => client + .create_issue(&CreateIssuePayload { + title, + body, + projects: vec![project_id], + }) + .map(|issue| format!("Created issue #{}", issue.number)), + IssueAction::Edit { + issue_number, + payload, + } => client + .edit_issue(issue_number, &payload) + .map(|_| format!("Updated issue #{issue_number}")), + IssueAction::Delete { issue_number } => client + .delete_issue(issue_number) + .map(|_| format!("Deleted issue #{issue_number}")), + }; + match message { + Ok(message) => match load_board(client, config) { + Ok(mut board) => { + board.status = message; + *app = board; + } + Err(error) => app.status = format!("Saved, refresh failed: {error}"), + }, + Err(error) => { + app.restore_issue_action(retry_action); + app.status = format!("Issue operation failed: {error}"); + } + } +} + fn load_board(client: &impl GiteaApi, config: &Config) -> Result> { match config.backend { Backend::Projects => { diff --git a/package/gitea-kanban-tui/src/model.rs b/package/gitea-kanban-tui/src/model.rs index 1fad9d46..2f1e2f1a 100644 --- a/package/gitea-kanban-tui/src/model.rs +++ b/package/gitea-kanban-tui/src/model.rs @@ -19,6 +19,19 @@ pub struct Issue { pub labels: Vec