From 5b0ae6be5e15fc0d595ace3cc5697fe7aa36ecb3 Mon Sep 17 00:00:00 2001 From: yukkop Date: Mon, 28 Sep 2026 09:25:00 +0000 Subject: [PATCH] feat: kanban cli --- package/gitea-kanban-tui/README.md | 24 ++- package/gitea-kanban-tui/default.nix | 9 +- package/gitea-kanban-tui/src/api.rs | 46 +++--- .../gitea-kanban-tui/src/bin/gitea-kanban.rs | 145 ++++++++++++++++++ package/gitea-kanban-tui/src/config.rs | 29 +++- package/gitea-kanban-tui/src/lib.rs | 1 + package/gitea-kanban-tui/src/main.rs | 30 ++-- package/gitea-kanban-tui/src/model.rs | 7 + package/gitea-kanban-tui/src/operations.rs | 36 +++++ 9 files changed, 272 insertions(+), 55 deletions(-) create mode 100644 package/gitea-kanban-tui/src/bin/gitea-kanban.rs create mode 100644 package/gitea-kanban-tui/src/operations.rs diff --git a/package/gitea-kanban-tui/README.md b/package/gitea-kanban-tui/README.md index 5a742021..df412d8d 100644 --- a/package/gitea-kanban-tui/README.md +++ b/package/gitea-kanban-tui/README.md @@ -1,6 +1,7 @@ -# gitea-kanban-tui +# gitea-kanban -Keyboard-driven Kanban board for native repository Projects in custom Gitea fork. +Native Projects Kanban tools for custom Gitea fork. `gitea-kanban-tui` provides +the keyboard interface; `gitea-kanban` provides the command-line interface. ## Configuration @@ -27,7 +28,7 @@ accepted only for loopback development. ```sh export GITEA_URL=https://gitea.hectic-lab.com export GITEA_TOKEN_FILE="$HOME/.config/gitea/token" -cargo run --manifest-path package/gitea-kanban-tui/Cargo.toml -- \ +cargo run --manifest-path package/gitea-kanban-tui/Cargo.toml --bin gitea-kanban-tui -- \ --project Kanban yukkop util.nix ``` @@ -35,7 +36,7 @@ Alternatively: ```sh nix develop .#ratatui -cargo run --manifest-path package/gitea-kanban-tui/Cargo.toml -- \ +cargo run --manifest-path package/gitea-kanban-tui/Cargo.toml --bin gitea-kanban-tui -- \ --project-id 1 owner repo ``` @@ -47,6 +48,21 @@ cargo run --manifest-path package/gitea-kanban-tui/Cargo.toml -- \ - `?`: toggle help - `q`: quit +## CLI + +Both binaries use same configuration and API client. CLI commands: + +```sh +gitea-kanban --project Kanban owner repo board +gitea-kanban --project Kanban owner repo create --title "Fix issue" --body "Details" +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 +``` + +`delete` requires repository admin permission. `move` takes global issue and +project-column IDs. + 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; diff --git a/package/gitea-kanban-tui/default.nix b/package/gitea-kanban-tui/default.nix index b3dce918..4638bca6 100644 --- a/package/gitea-kanban-tui/default.nix +++ b/package/gitea-kanban-tui/default.nix @@ -14,9 +14,16 @@ in inherit nativeBuildInputs; cargoLock.lockFile = ./Cargo.lock; - doCheck = true; + postBuild = '' + cargo build --release --offline --bin gitea-kanban + ''; + + postInstall = '' + install -Dm755 target/*/release/gitea-kanban $out/bin/gitea-kanban + ''; + meta = { description = cargo.package.description; license = lib.licenses.mit; diff --git a/package/gitea-kanban-tui/src/api.rs b/package/gitea-kanban-tui/src/api.rs index 2f02cbd0..bda4eb2e 100644 --- a/package/gitea-kanban-tui/src/api.rs +++ b/package/gitea-kanban-tui/src/api.rs @@ -9,9 +9,9 @@ use serde::de::DeserializeOwned; use crate::config::Config; use crate::model::{ - CreateIssuePayload, EditIssuePayload, Issue, MoveProjectIssuePayload, Project, ProjectColumn, + CreateIssuePayload, EditIssuePayload, Issue, MoveProjectIssuePayload, Project, ProjectBoard, + ProjectColumn, }; -use crate::text::sanitize_terminal_text; pub trait GiteaApi { fn list_projects(&self) -> Result, ApiError>; @@ -78,6 +78,24 @@ pub fn resolve_project( } } +pub fn load_project_board( + client: &impl GiteaApi, + config: &Config, +) -> Result { + let projects = client.list_projects()?; + let project = resolve_project(&projects, config.project.as_deref(), config.project_id)?; + let columns = client.list_project_columns(project.id)?; + let issues_by_column = columns + .iter() + .map(|column| client.list_project_column_issues(project.id, column.id)) + .collect::, _>>()?; + Ok(ProjectBoard { + project, + columns, + issues_by_column, + }) +} + impl GiteaClient { pub fn new(config: &Config) -> Result { let mut api_base = Url::parse(&config.base_url) @@ -123,7 +141,7 @@ impl GiteaClient { response: Response, operation: &str, ) -> Result { - let response = check_response(response, operation, &self.token)?; + let response = check_response(response, operation)?; response.json().map_err(|error| { ApiError(format!( "Gitea returned invalid JSON while {operation}: {error}" @@ -215,7 +233,7 @@ impl GiteaApi for GiteaClient { "cannot reach Gitea while moving project issue: {error}" )) })?; - check_response(response, "moving project issue", &self.token)?; + check_response(response, "moving project issue")?; Ok(()) } @@ -258,30 +276,17 @@ impl GiteaApi for GiteaClient { "cannot reach Gitea while deleting issue #{issue_number}: {error}" )) })?; - check_response( - response, - &format!("deleting issue #{issue_number}"), - &self.token, - )?; + check_response(response, &format!("deleting issue #{issue_number}"))?; Ok(()) } } -fn check_response(response: Response, operation: &str, token: &str) -> Result { +fn check_response(response: Response, operation: &str) -> Result { let status = response.status(); if status.is_success() { return Ok(response); } - let message = response - .text() - .ok() - .and_then(|body| serde_json::from_str::(&body).ok()) - .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(); let guidance = match status { StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { " Check token validity and repository issue permissions." @@ -292,7 +297,7 @@ fn check_response(response: Response, operation: &str, token: &str) -> Result "", }; Err(ApiError(format!( - "Gitea API failed while {operation} ({status}){detail}.{guidance}" + "Gitea API failed while {operation} ({status}).{guidance}" ))) } @@ -452,7 +457,6 @@ mod tests { .list_project_columns(4) .expect_err("unsupported API must fail"); assert!(error.to_string().contains("404 Not Found")); - assert!(error.to_string().contains("projects unavailable")); assert!(!error.to_string().contains("secret")); } diff --git a/package/gitea-kanban-tui/src/bin/gitea-kanban.rs b/package/gitea-kanban-tui/src/bin/gitea-kanban.rs new file mode 100644 index 00000000..a3d3671e --- /dev/null +++ b/package/gitea-kanban-tui/src/bin/gitea-kanban.rs @@ -0,0 +1,145 @@ +use std::error::Error; + +use clap::{Parser, Subcommand}; + +use gitea_kanban_tui::api::{load_project_board, GiteaClient}; +use gitea_kanban_tui::config::{Config, SharedArgs}; +use gitea_kanban_tui::model::EditIssuePayload; +use gitea_kanban_tui::operations; +use gitea_kanban_tui::text::sanitize_terminal_text; + +#[derive(Debug, Parser)] +#[command( + version, + about = "Control native Gitea project boards from the command line" +)] +struct Cli { + #[command(flatten)] + shared: SharedArgs, + + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Print project columns and their issues. + Board, + /// Create an issue assigned to selected project. + Create { + #[arg(long)] + title: String, + #[arg(long, default_value = "")] + body: String, + }, + /// Replace issue title and body. + Edit { + issue: u64, + #[arg(long)] + title: String, + #[arg(long)] + body: String, + }, + /// Delete an issue. Requires repository admin permission. + Delete { + issue: u64, + /// Confirm irreversible deletion. + #[arg(long)] + yes: bool, + }, + /// Move an issue to a project column. + Move { + issue_id: u64, + #[arg(long)] + column_id: u64, + #[arg(long)] + sorting: Option, + }, +} + +fn main() { + if let Err(error) = run() { + eprintln!("error: {}", sanitize_terminal_text(&error.to_string())); + std::process::exit(1); + } +} + +fn run() -> Result<(), Box> { + let cli = Cli::parse(); + let config = Config::from_shared_args_with(cli.shared, |name| std::env::var(name).ok())?; + let client = GiteaClient::new(&config)?; + + match cli.command { + Command::Board => { + let board = load_project_board(&client, &config)?; + println!( + "{} (project {})", + sanitize_terminal_text(&board.project.title), + board.project.id + ); + for (column, issues) in board.columns.iter().zip(board.issues_by_column) { + println!( + "\n{} ({})", + sanitize_terminal_text(&column.title), + column.id + ); + for issue in issues { + println!( + " #{} {}", + issue.number, + sanitize_terminal_text(&issue.title) + ); + } + } + } + Command::Create { title, body } => { + let board = load_project_board(&client, &config)?; + let issue = operations::create_project_issue(&client, board.project.id, title, body)?; + println!("created issue #{}", issue.number); + } + Command::Edit { issue, title, body } => { + 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()); + } + operations::edit_issue(&client, issue, &EditIssuePayload { title, body })?; + println!("updated issue #{issue}"); + } + Command::Delete { issue, yes } => { + if !yes { + return Err("deletion requires --yes".into()); + } + 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()); + } + operations::delete_issue(&client, issue)?; + println!("deleted issue #{issue}"); + } + Command::Move { + issue_id, + column_id, + sorting, + } => { + let board = load_project_board(&client, &config)?; + operations::move_project_issue( + &client, + board.project.id, + issue_id, + &gitea_kanban_tui::model::MoveProjectIssuePayload { column_id, sorting }, + )?; + println!("moved issue {issue_id} to column {column_id}"); + } + } + Ok(()) +} diff --git a/package/gitea-kanban-tui/src/config.rs b/package/gitea-kanban-tui/src/config.rs index 556ca8e3..7a3459be 100644 --- a/package/gitea-kanban-tui/src/config.rs +++ b/package/gitea-kanban-tui/src/config.rs @@ -5,15 +5,10 @@ use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; -use clap::Parser; +use clap::{Args as ClapArgs, Parser}; -#[derive(Parser)] -#[command( - name = "gitea-kanban-tui", - version, - about = "Browse and move Gitea issues using native projects" -)] -pub struct Args { +#[derive(Clone, Debug, ClapArgs)] +pub struct SharedArgs { /// Gitea base URL; falls back to GITEA_URL #[arg(long)] pub url: Option, @@ -37,6 +32,17 @@ pub struct Args { pub repo: Option, } +#[derive(Debug, Parser)] +#[command( + name = "gitea-kanban-tui", + version, + about = "Browse and move Gitea issues using native projects" +)] +pub struct Args { + #[command(flatten)] + pub shared: SharedArgs, +} + #[derive(Debug)] pub struct Config { pub base_url: String, @@ -64,6 +70,13 @@ impl Config { } pub fn from_args_with(args: Args, env_var: F) -> Result + where + F: Fn(&str) -> Option, + { + Self::from_shared_args_with(args.shared, env_var) + } + + pub fn from_shared_args_with(args: SharedArgs, env_var: F) -> Result where F: Fn(&str) -> Option, { diff --git a/package/gitea-kanban-tui/src/lib.rs b/package/gitea-kanban-tui/src/lib.rs index 91007bc4..788f632e 100644 --- a/package/gitea-kanban-tui/src/lib.rs +++ b/package/gitea-kanban-tui/src/lib.rs @@ -2,6 +2,7 @@ pub mod api; pub mod app; pub mod config; pub mod model; +pub mod operations; pub mod terminal; pub mod text; pub mod ui; diff --git a/package/gitea-kanban-tui/src/main.rs b/package/gitea-kanban-tui/src/main.rs index 6e693018..6a9680f6 100644 --- a/package/gitea-kanban-tui/src/main.rs +++ b/package/gitea-kanban-tui/src/main.rs @@ -3,10 +3,10 @@ use std::time::Duration; use crossterm::event::{self, Event, KeyCode, KeyEventKind}; -use gitea_kanban_tui::api::{resolve_project, GiteaApi, GiteaClient}; +use gitea_kanban_tui::api::{load_project_board, GiteaApi, GiteaClient}; use gitea_kanban_tui::app::{App, IssueAction, MoveAction}; use gitea_kanban_tui::config::Config; -use gitea_kanban_tui::model::CreateIssuePayload; +use gitea_kanban_tui::operations; use gitea_kanban_tui::terminal::TerminalGuard; use gitea_kanban_tui::text::sanitize_terminal_text; use gitea_kanban_tui::ui; @@ -75,21 +75,14 @@ fn issue_action(client: &impl GiteaApi, config: &Config, app: &mut App, action: project_id, title, body, - } => client - .create_issue(&CreateIssuePayload { - title, - body, - projects: vec![project_id], - }) + } => operations::create_project_issue(client, project_id, title, body) .map(|issue| format!("Created issue #{}", issue.number)), IssueAction::Edit { issue_number, payload, - } => client - .edit_issue(issue_number, &payload) + } => operations::edit_issue(client, issue_number, &payload) .map(|_| format!("Updated issue #{issue_number}")), - IssueAction::Delete { issue_number } => client - .delete_issue(issue_number) + IssueAction::Delete { issue_number } => operations::delete_issue(client, issue_number) .map(|_| format!("Deleted issue #{issue_number}")), }; match message { @@ -108,14 +101,9 @@ fn issue_action(client: &impl GiteaApi, config: &Config, app: &mut App, action: } fn load_board(client: &impl GiteaApi, config: &Config) -> Result> { - let projects = client.list_projects()?; - let project = resolve_project(&projects, config.project.as_deref(), config.project_id)?; - let columns = client.list_project_columns(project.id)?; - let issues_by_column = columns - .iter() - .map(|column| client.list_project_column_issues(project.id, column.id)) - .collect::, _>>()?; - App::new_project(project, columns, issues_by_column).map_err(|error| error.into()) + let board = load_project_board(client, config)?; + App::new_project(board.project, board.columns, board.issues_by_column) + .map_err(|error| error.into()) } fn move_card(client: &impl GiteaApi, app: &mut App, offset: isize) { @@ -128,7 +116,7 @@ fn move_card(client: &impl GiteaApi, app: &mut App, offset: isize) { issue_id, project_id, payload, - } => client.move_project_issue(*project_id, *issue_id, payload), + } => operations::move_project_issue(client, *project_id, *issue_id, payload), }; match result { Ok(()) => app.apply_move(request), diff --git a/package/gitea-kanban-tui/src/model.rs b/package/gitea-kanban-tui/src/model.rs index 2276692b..b6da55b3 100644 --- a/package/gitea-kanban-tui/src/model.rs +++ b/package/gitea-kanban-tui/src/model.rs @@ -46,6 +46,13 @@ pub struct ProjectColumn { pub sorting: i8, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProjectBoard { + pub project: Project, + pub columns: Vec, + pub issues_by_column: Vec>, +} + #[derive(Debug, Serialize, PartialEq, Eq)] pub struct MoveProjectIssuePayload { pub column_id: u64, diff --git a/package/gitea-kanban-tui/src/operations.rs b/package/gitea-kanban-tui/src/operations.rs new file mode 100644 index 00000000..a41feb97 --- /dev/null +++ b/package/gitea-kanban-tui/src/operations.rs @@ -0,0 +1,36 @@ +use crate::api::{ApiError, GiteaApi}; +use crate::model::{CreateIssuePayload, EditIssuePayload, Issue, MoveProjectIssuePayload}; + +pub fn create_project_issue( + client: &impl GiteaApi, + project_id: u64, + title: String, + body: String, +) -> Result { + client.create_issue(&CreateIssuePayload { + title, + body, + projects: vec![project_id], + }) +} + +pub fn edit_issue( + client: &impl GiteaApi, + issue_number: u64, + payload: &EditIssuePayload, +) -> Result { + client.edit_issue(issue_number, payload) +} + +pub fn delete_issue(client: &impl GiteaApi, issue_number: u64) -> Result<(), ApiError> { + client.delete_issue(issue_number) +} + +pub fn move_project_issue( + client: &impl GiteaApi, + project_id: u64, + issue_id: u64, + payload: &MoveProjectIssuePayload, +) -> Result<(), ApiError> { + client.move_project_issue(project_id, issue_id, payload) +}