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

This commit is contained in:
2026-09-28 09:25:04 +00:00
parent 54eb69d1c7
commit 5b0ae6be5e
9 changed files with 272 additions and 55 deletions
+20 -4
View File
@@ -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 ## Configuration
@@ -27,7 +28,7 @@ accepted only for loopback development.
```sh ```sh
export GITEA_URL=https://gitea.hectic-lab.com export GITEA_URL=https://gitea.hectic-lab.com
export GITEA_TOKEN_FILE="$HOME/.config/gitea/token" 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 --project Kanban yukkop util.nix
``` ```
@@ -35,7 +36,7 @@ Alternatively:
```sh ```sh
nix develop .#ratatui 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 --project-id 1 owner repo
``` ```
@@ -47,6 +48,21 @@ cargo run --manifest-path package/gitea-kanban-tui/Cargo.toml -- \
- `?`: toggle help - `?`: toggle help
- `q`: quit - `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 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 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; supported by the server API. Project names are exact, case-sensitive matches;
+8 -1
View File
@@ -14,9 +14,16 @@ in
inherit nativeBuildInputs; inherit nativeBuildInputs;
cargoLock.lockFile = ./Cargo.lock; cargoLock.lockFile = ./Cargo.lock;
doCheck = true; doCheck = true;
postBuild = ''
cargo build --release --offline --bin gitea-kanban
'';
postInstall = ''
install -Dm755 target/*/release/gitea-kanban $out/bin/gitea-kanban
'';
meta = { meta = {
description = cargo.package.description; description = cargo.package.description;
license = lib.licenses.mit; license = lib.licenses.mit;
+25 -21
View File
@@ -9,9 +9,9 @@ use serde::de::DeserializeOwned;
use crate::config::Config; use crate::config::Config;
use crate::model::{ 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 { pub trait GiteaApi {
fn list_projects(&self) -> Result<Vec<Project>, ApiError>; fn list_projects(&self) -> Result<Vec<Project>, ApiError>;
@@ -78,6 +78,24 @@ pub fn resolve_project(
} }
} }
pub fn load_project_board(
client: &impl GiteaApi,
config: &Config,
) -> Result<ProjectBoard, ApiError> {
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::<Result<Vec<_>, _>>()?;
Ok(ProjectBoard {
project,
columns,
issues_by_column,
})
}
impl GiteaClient { impl GiteaClient {
pub fn new(config: &Config) -> Result<Self, ApiError> { pub fn new(config: &Config) -> Result<Self, ApiError> {
let mut api_base = Url::parse(&config.base_url) let mut api_base = Url::parse(&config.base_url)
@@ -123,7 +141,7 @@ impl GiteaClient {
response: Response, response: Response,
operation: &str, operation: &str,
) -> Result<T, ApiError> { ) -> Result<T, ApiError> {
let response = check_response(response, operation, &self.token)?; let response = check_response(response, operation)?;
response.json().map_err(|error| { response.json().map_err(|error| {
ApiError(format!( ApiError(format!(
"Gitea returned invalid JSON while {operation}: {error}" "Gitea returned invalid JSON while {operation}: {error}"
@@ -215,7 +233,7 @@ impl GiteaApi for GiteaClient {
"cannot reach Gitea while moving project issue: {error}" "cannot reach Gitea while moving project issue: {error}"
)) ))
})?; })?;
check_response(response, "moving project issue", &self.token)?; check_response(response, "moving project issue")?;
Ok(()) Ok(())
} }
@@ -258,30 +276,17 @@ impl GiteaApi for GiteaClient {
"cannot reach Gitea while deleting issue #{issue_number}: {error}" "cannot reach Gitea while deleting issue #{issue_number}: {error}"
)) ))
})?; })?;
check_response( check_response(response, &format!("deleting issue #{issue_number}"))?;
response,
&format!("deleting issue #{issue_number}"),
&self.token,
)?;
Ok(()) Ok(())
} }
} }
fn check_response(response: Response, operation: &str, token: &str) -> Result<Response, ApiError> { fn check_response(response: Response, operation: &str) -> Result<Response, ApiError> {
let status = response.status(); let status = response.status();
if status.is_success() { if status.is_success() {
return Ok(response); return Ok(response);
} }
let message = response
.text()
.ok()
.and_then(|body| serde_json::from_str::<serde_json::Value>(&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 { let guidance = match status {
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
" Check token validity and repository issue permissions." " Check token validity and repository issue permissions."
@@ -292,7 +297,7 @@ fn check_response(response: Response, operation: &str, token: &str) -> Result<Re
_ => "", _ => "",
}; };
Err(ApiError(format!( 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) .list_project_columns(4)
.expect_err("unsupported API must fail"); .expect_err("unsupported API must fail");
assert!(error.to_string().contains("404 Not Found")); assert!(error.to_string().contains("404 Not Found"));
assert!(error.to_string().contains("projects unavailable"));
assert!(!error.to_string().contains("secret")); assert!(!error.to_string().contains("secret"));
} }
@@ -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<u64>,
},
}
fn main() {
if let Err(error) = run() {
eprintln!("error: {}", sanitize_terminal_text(&error.to_string()));
std::process::exit(1);
}
}
fn run() -> Result<(), Box<dyn Error>> {
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(())
}
+21 -8
View File
@@ -5,15 +5,10 @@ use std::fs;
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf; use std::path::PathBuf;
use clap::Parser; use clap::{Args as ClapArgs, Parser};
#[derive(Parser)] #[derive(Clone, Debug, ClapArgs)]
#[command( pub struct SharedArgs {
name = "gitea-kanban-tui",
version,
about = "Browse and move Gitea issues using native projects"
)]
pub struct Args {
/// Gitea base URL; falls back to GITEA_URL /// Gitea base URL; falls back to GITEA_URL
#[arg(long)] #[arg(long)]
pub url: Option<String>, pub url: Option<String>,
@@ -37,6 +32,17 @@ pub struct Args {
pub repo: Option<String>, pub repo: Option<String>,
} }
#[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)] #[derive(Debug)]
pub struct Config { pub struct Config {
pub base_url: String, pub base_url: String,
@@ -64,6 +70,13 @@ impl Config {
} }
pub fn from_args_with<F>(args: Args, env_var: F) -> Result<Self, ConfigError> pub fn from_args_with<F>(args: Args, env_var: F) -> Result<Self, ConfigError>
where
F: Fn(&str) -> Option<String>,
{
Self::from_shared_args_with(args.shared, env_var)
}
pub fn from_shared_args_with<F>(args: SharedArgs, env_var: F) -> Result<Self, ConfigError>
where where
F: Fn(&str) -> Option<String>, F: Fn(&str) -> Option<String>,
{ {
+1
View File
@@ -2,6 +2,7 @@ pub mod api;
pub mod app; pub mod app;
pub mod config; pub mod config;
pub mod model; pub mod model;
pub mod operations;
pub mod terminal; pub mod terminal;
pub mod text; pub mod text;
pub mod ui; pub mod ui;
+9 -21
View File
@@ -3,10 +3,10 @@ use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEventKind}; 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::app::{App, IssueAction, MoveAction};
use gitea_kanban_tui::config::Config; 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::terminal::TerminalGuard;
use gitea_kanban_tui::text::sanitize_terminal_text; use gitea_kanban_tui::text::sanitize_terminal_text;
use gitea_kanban_tui::ui; use gitea_kanban_tui::ui;
@@ -75,21 +75,14 @@ fn issue_action(client: &impl GiteaApi, config: &Config, app: &mut App, action:
project_id, project_id,
title, title,
body, body,
} => client } => operations::create_project_issue(client, project_id, title, body)
.create_issue(&CreateIssuePayload {
title,
body,
projects: vec![project_id],
})
.map(|issue| format!("Created issue #{}", issue.number)), .map(|issue| format!("Created issue #{}", issue.number)),
IssueAction::Edit { IssueAction::Edit {
issue_number, issue_number,
payload, payload,
} => client } => operations::edit_issue(client, issue_number, &payload)
.edit_issue(issue_number, &payload)
.map(|_| format!("Updated issue #{issue_number}")), .map(|_| format!("Updated issue #{issue_number}")),
IssueAction::Delete { issue_number } => client IssueAction::Delete { issue_number } => operations::delete_issue(client, issue_number)
.delete_issue(issue_number)
.map(|_| format!("Deleted issue #{issue_number}")), .map(|_| format!("Deleted issue #{issue_number}")),
}; };
match message { 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<App, Box<dyn Error>> { fn load_board(client: &impl GiteaApi, config: &Config) -> Result<App, Box<dyn Error>> {
let projects = client.list_projects()?; let board = load_project_board(client, config)?;
let project = resolve_project(&projects, config.project.as_deref(), config.project_id)?; App::new_project(board.project, board.columns, board.issues_by_column)
let columns = client.list_project_columns(project.id)?; .map_err(|error| error.into())
let issues_by_column = columns
.iter()
.map(|column| client.list_project_column_issues(project.id, column.id))
.collect::<Result<Vec<_>, _>>()?;
App::new_project(project, columns, issues_by_column).map_err(|error| error.into())
} }
fn move_card(client: &impl GiteaApi, app: &mut App, offset: isize) { 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, issue_id,
project_id, project_id,
payload, payload,
} => client.move_project_issue(*project_id, *issue_id, payload), } => operations::move_project_issue(client, *project_id, *issue_id, payload),
}; };
match result { match result {
Ok(()) => app.apply_move(request), Ok(()) => app.apply_move(request),
+7
View File
@@ -46,6 +46,13 @@ pub struct ProjectColumn {
pub sorting: i8, pub sorting: i8,
} }
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProjectBoard {
pub project: Project,
pub columns: Vec<ProjectColumn>,
pub issues_by_column: Vec<Vec<Issue>>,
}
#[derive(Debug, Serialize, PartialEq, Eq)] #[derive(Debug, Serialize, PartialEq, Eq)]
pub struct MoveProjectIssuePayload { pub struct MoveProjectIssuePayload {
pub column_id: u64, pub column_id: u64,
@@ -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<Issue, ApiError> {
client.create_issue(&CreateIssuePayload {
title,
body,
projects: vec![project_id],
})
}
pub fn edit_issue(
client: &impl GiteaApi,
issue_number: u64,
payload: &EditIssuePayload,
) -> Result<Issue, ApiError> {
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)
}