This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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 {
|
||||
pub fn new(config: &Config) -> Result<Self, ApiError> {
|
||||
let mut api_base = Url::parse(&config.base_url)
|
||||
@@ -123,7 +141,7 @@ impl GiteaClient {
|
||||
response: Response,
|
||||
operation: &str,
|
||||
) -> Result<T, ApiError> {
|
||||
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<Response, ApiError> {
|
||||
fn check_response(response: Response, operation: &str) -> Result<Response, ApiError> {
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
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 {
|
||||
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<Re
|
||||
_ => "",
|
||||
};
|
||||
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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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<String>,
|
||||
@@ -37,6 +32,17 @@ pub struct Args {
|
||||
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)]
|
||||
pub struct Config {
|
||||
pub base_url: String,
|
||||
@@ -64,6 +70,13 @@ impl Config {
|
||||
}
|
||||
|
||||
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
|
||||
F: Fn(&str) -> Option<String>,
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<App, Box<dyn Error>> {
|
||||
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<_>, _>>()?;
|
||||
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),
|
||||
|
||||
@@ -46,6 +46,13 @@ pub struct ProjectColumn {
|
||||
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)]
|
||||
pub struct MoveProjectIssuePayload {
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user