feat: clear unneccessary backend from kanban
runner nix smoke / nix label and flake smoke (push) Failing after 35s

This commit is contained in:
2026-09-28 00:24:44 +00:00
parent 0cbf267474
commit 54eb69d1c7
7 changed files with 84 additions and 427 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
name = "gitea-kanban-tui" name = "gitea-kanban-tui"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "Native-project and label-backed Gitea Kanban terminal interface" description = "Native-project Gitea Kanban terminal interface"
license = "MIT" license = "MIT"
[dependencies] [dependencies]
+6 -25
View File
@@ -1,33 +1,25 @@
# gitea-kanban-tui # gitea-kanban-tui
Keyboard-driven Kanban board for the custom Gitea fork. Native repository Keyboard-driven Kanban board for native repository Projects in custom Gitea fork.
Projects are supported alongside the legacy repository-label backend. Backend
selection is explicit; API failures never trigger a silent fallback.
## Configuration ## Configuration
Create repository labels such as `kanban/01 Todo`, `kanban/02 Doing`, and
`kanban/03 Done`. Column suffixes are sorted lexically, so numeric prefixes
provide explicit ordering.
Required configuration can come from flags or environment variables: Required configuration can come from flags or environment variables:
| Flag | Environment | Meaning | | Flag | Environment | Meaning |
| --- | --- | --- | | --- | --- | --- |
| `--url` | `GITEA_URL` | Gitea base URL | | `--url` | `GITEA_URL` | Gitea base URL |
| `--token-file` | `GITEA_TOKEN_FILE` | File containing API token | | `--token-file` | `GITEA_TOKEN_FILE` | File containing API token |
| `--backend` | `GITEA_KANBAN_BACKEND` | Required: `projects` or `labels` | | — | `GITEA_TOKEN` | API token fallback when no token file is configured |
| `--project` | `GITEA_PROJECT` | Exact native project name | | `--project` | `GITEA_PROJECT` | Exact native project name |
| `--project-id` | `GITEA_PROJECT_ID` | Native project ID instead of name | | `--project-id` | `GITEA_PROJECT_ID` | Native project ID instead of name |
| positional `OWNER` | `GITEA_OWNER` | Repository owner | | positional `OWNER` | `GITEA_OWNER` | Repository owner |
| positional `REPO` | `GITEA_REPO` | Repository name | | positional `REPO` | `GITEA_REPO` | Repository name |
| `--label-prefix` | `GITEA_LABEL_PREFIX` | Column prefix; default `kanban/` |
Native mode requires an enabled repository Projects unit, project read/write Native mode requires an enabled repository Projects unit, project read/write
repository permission, and a token with `read:issue`/`write:issue` scope. Issue repository permission, and a token with `read:issue`/`write:issue` scope. Issue
creation/editing follows Gitea issue permissions; deletion requires repository creation/editing follows Gitea issue permissions; deletion requires repository
admin permission in this fork. Label mode requires repository issue read/write admin permission in this fork. Token values are sent
permission. Token values are sent
only through Gitea's `Authorization` header and are never printed. Token-file 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 input takes precedence over environment variables. Remote URLs must use HTTPS; plain HTTP is
accepted only for loopback development. accepted only for loopback development.
@@ -36,7 +28,7 @@ accepted only for loopback development.
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 -- \
--backend projects --project Kanban yukkop util.nix --project Kanban yukkop util.nix
``` ```
Alternatively: Alternatively:
@@ -44,14 +36,14 @@ 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 -- \
--backend projects --project-id 1 owner repo --project-id 1 owner repo
``` ```
## Keys ## Keys
- Arrow keys or `h`/`j`/`k`/`l`: focus column/card - Arrow keys or `h`/`j`/`k`/`l`: focus column/card
- `H`/`L`: move focused card left/right - `H`/`L`: move focused card left/right
- `r`: refresh labels and open issues - `r`: refresh project columns and issues
- `?`: toggle help - `?`: toggle help
- `q`: quit - `q`: quit
@@ -65,17 +57,6 @@ focused issue title/body, and `d` deletes it after confirmation. In the editor,
`Esc` cancels. Closed projects, archived repositories, disabled Projects units, `Esc` cancels. Closed projects, archived repositories, disabled Projects units,
unassigned issues, and cross-repository IDs are rejected by the server. unassigned issues, and cross-repository IDs are rejected by the server.
Explicit fallback command:
```sh
cargo run --manifest-path package/gitea-kanban-tui/Cargo.toml -- \
--backend labels --label-prefix kanban/ owner repo
```
In label mode, moving a card replaces its issue labels: all labels matching the configured
column prefix are removed, the target column label is added, and non-column
labels are preserved. The client uses only repository label and issue REST
endpoints under `/api/v1`.
## Development ## Development
+5 -51
View File
@@ -9,8 +9,7 @@ use serde::de::DeserializeOwned;
use crate::config::Config; use crate::config::Config;
use crate::model::{ use crate::model::{
CreateIssuePayload, EditIssuePayload, Issue, Label, MoveProjectIssuePayload, Project, CreateIssuePayload, EditIssuePayload, Issue, MoveProjectIssuePayload, Project, ProjectColumn,
ProjectColumn, ReplaceLabelsPayload,
}; };
use crate::text::sanitize_terminal_text; use crate::text::sanitize_terminal_text;
@@ -28,13 +27,6 @@ pub trait GiteaApi {
issue_id: u64, issue_id: u64,
payload: &MoveProjectIssuePayload, payload: &MoveProjectIssuePayload,
) -> Result<(), ApiError>; ) -> Result<(), ApiError>;
fn list_labels(&self) -> Result<Vec<Label>, ApiError>;
fn list_open_issues(&self) -> Result<Vec<Issue>, ApiError>;
fn replace_issue_labels(
&self,
issue_number: u64,
payload: &ReplaceLabelsPayload,
) -> Result<(), ApiError>;
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>;
@@ -227,42 +219,6 @@ impl GiteaApi for GiteaClient {
Ok(()) Ok(())
} }
fn list_labels(&self) -> Result<Vec<Label>, ApiError> {
self.get_all("labels", "listing repository labels", &[])
}
fn list_open_issues(&self) -> Result<Vec<Issue>, ApiError> {
self.get_all(
"issues",
"listing open repository issues",
&[("state", "open"), ("type", "issues")],
)
}
fn replace_issue_labels(
&self,
issue_number: u64,
payload: &ReplaceLabelsPayload,
) -> Result<(), ApiError> {
let response = self
.client
.put(self.endpoint(&format!("issues/{issue_number}/labels"))?)
.header("Authorization", format!("token {}", self.token))
.json(payload)
.send()
.map_err(|error| {
ApiError(format!(
"cannot reach Gitea while moving issue #{issue_number}: {error}"
))
})?;
check_response(
response,
&format!("replacing labels on issue #{issue_number}"),
&self.token,
)?;
Ok(())
}
fn create_issue(&self, payload: &CreateIssuePayload) -> Result<Issue, ApiError> { fn create_issue(&self, payload: &CreateIssuePayload) -> Result<Issue, ApiError> {
let response = self let response = self
.client .client
@@ -331,7 +287,7 @@ fn check_response(response: Response, operation: &str, token: &str) -> Result<Re
" Check token validity and repository issue permissions." " Check token validity and repository issue permissions."
} }
StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED => { StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED => {
" Check repository owner/name and whether this Gitea version supports repository issue-label APIs." " Check repository owner/name and whether this Gitea version supports native project APIs."
} }
_ => "", _ => "",
}; };
@@ -376,10 +332,8 @@ mod tests {
token: "secret".to_owned(), token: "secret".to_owned(),
owner: "owner name".to_owned(), owner: "owner name".to_owned(),
repo: "repo/name".to_owned(), repo: "repo/name".to_owned(),
backend: crate::config::Backend::Labels, project: Some("Kanban".to_owned()),
project: None,
project_id: None, project_id: None,
label_prefix: "kanban/".to_owned(),
} }
} }
@@ -433,8 +387,8 @@ mod tests {
.expect("client should build"); .expect("client should build");
assert_eq!( assert_eq!(
client.endpoint("labels").expect("endpoint").as_str(), client.endpoint("projects").expect("endpoint").as_str(),
"https://gitea.example/subpath/api/v1/repos/owner%20name/repo%2Fname/labels" "https://gitea.example/subpath/api/v1/repos/owner%20name/repo%2Fname/projects"
); );
} }
+31 -150
View File
@@ -1,8 +1,7 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::model::{ use crate::model::{
extract_columns, replacement_payload, ColumnSpec, EditIssuePayload, Issue, Label, ColumnSpec, EditIssuePayload, Issue, MoveProjectIssuePayload, Project, ProjectColumn,
MoveProjectIssuePayload, Project, ProjectColumn, ReplaceLabelsPayload,
}; };
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -19,7 +18,7 @@ pub struct App {
pub status: String, pub status: String,
pub show_help: bool, pub show_help: bool,
pub editor: Option<EditorState>, pub editor: Option<EditorState>,
backend: BoardBackend, project_id: u64,
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
@@ -59,12 +58,6 @@ pub enum IssueAction {
}, },
} }
#[derive(Clone, Debug)]
enum BoardBackend {
Projects { project_id: u64 },
Labels { prefix: String },
}
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
pub enum MoveAction { pub enum MoveAction {
Project { Project {
@@ -72,10 +65,6 @@ pub enum MoveAction {
project_id: u64, project_id: u64,
payload: MoveProjectIssuePayload, payload: MoveProjectIssuePayload,
}, },
Labels {
issue_number: u64,
payload: ReplaceLabelsPayload,
},
} }
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
@@ -86,59 +75,6 @@ pub struct MoveRequest {
} }
impl App { impl App {
pub fn new_labels(
labels: Vec<Label>,
issues: Vec<Issue>,
label_prefix: String,
) -> Result<Self, String> {
let specs = extract_columns(&labels, &label_prefix);
if specs.is_empty() {
return Err(format!(
"no Kanban columns found; create repository labels such as {label_prefix}Todo"
));
}
let mut columns: Vec<Column> = specs
.into_iter()
.map(|spec| Column {
spec,
cards: Vec::new(),
})
.collect();
for issue in issues {
if let Some(column) = columns
.iter_mut()
.find(|column| issue.labels.iter().any(|label| label.id == column.spec.id))
{
column.cards.push(issue);
}
}
if columns.iter().all(|column| column.cards.is_empty()) {
return Err(format!(
"no cards found; add a {label_prefix}<column> label to an open issue"
));
}
let focused_column = columns
.iter()
.position(|column| !column.cards.is_empty())
.unwrap_or(0);
let focused_cards = vec![0; columns.len()];
Ok(Self {
board_title: format!("labels {label_prefix}*"),
columns,
focused_column,
focused_cards,
status: "Ready".to_owned(),
show_help: false,
editor: None,
backend: BoardBackend::Labels {
prefix: label_prefix,
},
})
}
pub fn new_project( pub fn new_project(
project: Project, project: Project,
project_columns: Vec<ProjectColumn>, project_columns: Vec<ProjectColumn>,
@@ -156,7 +92,6 @@ impl App {
.map(|(column, cards)| Column { .map(|(column, cards)| Column {
spec: ColumnSpec { spec: ColumnSpec {
id: column.id, id: column.id,
label: None,
title: column.title, title: column.title,
}, },
cards, cards,
@@ -175,9 +110,7 @@ impl App {
status: "Ready".to_owned(), status: "Ready".to_owned(),
show_help: false, show_help: false,
editor: None, editor: None,
backend: BoardBackend::Projects {
project_id: project.id, project_id: project.id,
},
}) })
} }
@@ -214,19 +147,13 @@ impl App {
return None; return None;
} }
let issue = self.focused_card()?; let issue = self.focused_card()?;
let action = match &self.backend { let action = MoveAction::Project {
BoardBackend::Projects { project_id } => MoveAction::Project {
issue_id: issue.id, issue_id: issue.id,
project_id: *project_id, project_id: self.project_id,
payload: MoveProjectIssuePayload { payload: MoveProjectIssuePayload {
column_id: self.columns[target].spec.id, column_id: self.columns[target].spec.id,
sorting: None, sorting: None,
}, },
},
BoardBackend::Labels { prefix } => MoveAction::Labels {
issue_number: issue.number,
payload: replacement_payload(issue, prefix, self.columns[target].spec.id),
},
}; };
Some(MoveRequest { Some(MoveRequest {
source: self.focused_column, source: self.focused_column,
@@ -237,14 +164,8 @@ impl App {
pub fn apply_move(&mut self, request: MoveRequest) { pub fn apply_move(&mut self, request: MoveRequest) {
let selected = self.focused_cards[request.source]; let selected = self.focused_cards[request.source];
let mut issue = self.columns[request.source].cards.remove(selected); let issue = self.columns[request.source].cards.remove(selected);
let issue_number = issue.number; let issue_number = issue.number;
if let BoardBackend::Labels { prefix } = &self.backend {
issue.labels.retain(|label| !label.name.starts_with(prefix));
if let Some(label) = &self.columns[request.target].spec.label {
issue.labels.push(label.clone());
}
}
self.columns[request.target].cards.push(issue); self.columns[request.target].cards.push(issue);
self.focused_cards[request.source] = self.focused_cards[request.source] =
selected.min(self.columns[request.source].cards.len().saturating_sub(1)); selected.min(self.columns[request.source].cards.len().saturating_sub(1));
@@ -254,16 +175,10 @@ impl App {
} }
pub fn begin_create(&mut self) { 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 { self.editor = Some(EditorState {
mode: EditorMode::Create { project_id }, mode: EditorMode::Create {
project_id: self.project_id,
},
field: EditorField::Title, field: EditorField::Title,
title: String::new(), title: String::new(),
body: String::new(), body: String::new(),
@@ -271,10 +186,6 @@ impl App {
} }
pub fn begin_edit(&mut self) { 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 { let Some(issue) = self.focused_card().cloned() else {
self.status = "No issue selected".to_owned(); self.status = "No issue selected".to_owned();
return; return;
@@ -290,10 +201,6 @@ impl App {
} }
pub fn begin_delete(&mut self) { 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 { let Some(issue) = self.focused_card() else {
self.status = "No issue selected".to_owned(); self.status = "No issue selected".to_owned();
return; return;
@@ -430,34 +337,44 @@ mod tests {
use super::*; use super::*;
fn label(id: u64, name: &str) -> Label {
Label {
id,
name: name.to_owned(),
color: String::new(),
}
}
fn app() -> App { fn app() -> App {
App::new_labels( App::new_project(
vec![label(1, "kanban/01 Todo"), label(2, "kanban/02 Done")], Project {
id: 8,
title: "Kanban".to_owned(),
is_closed: false,
},
vec![
ProjectColumn {
id: 10,
title: "Todo".to_owned(),
color: String::new(),
sorting: 0,
},
ProjectColumn {
id: 20,
title: "Done".to_owned(),
color: String::new(),
sorting: 1,
},
],
vec![
vec![ vec![
Issue { Issue {
id: 1, id: 1,
number: 1, number: 1,
title: "First".to_owned(), title: "First".to_owned(),
body: None, body: None,
labels: vec![label(1, "kanban/01 Todo"), label(7, "bug")],
}, },
Issue { Issue {
id: 2, id: 2,
number: 2, number: 2,
title: "Second".to_owned(), title: "Second".to_owned(),
body: None, body: None,
labels: vec![label(1, "kanban/01 Todo")],
}, },
], ],
"kanban/".to_owned(), Vec::new(),
],
) )
.expect("board builds") .expect("board builds")
} }
@@ -478,26 +395,6 @@ mod tests {
assert_eq!(app.focused_column, 1); assert_eq!(app.focused_column, 1);
} }
#[test]
fn move_preparation_and_commit_preserve_non_column_labels() {
let mut app = app();
let request = app.prepare_move(1).expect("can move right");
assert!(matches!(
request.action,
MoveAction::Labels { ref payload, .. } if payload.labels == vec![7, 2]
));
app.apply_move(request);
assert_eq!(app.focused_column, 1);
assert_eq!(app.columns[0].cards.len(), 1);
assert_eq!(app.columns[1].cards[0].number, 1);
assert!(app.columns[1].cards[0]
.labels
.iter()
.any(|label| label.name == "bug"));
}
#[test] #[test]
fn cannot_move_past_board_edge() { fn cannot_move_past_board_edge() {
let app = app(); let app = app();
@@ -551,7 +448,6 @@ mod tests {
number: 7, number: 7,
title: "Fix".to_owned(), title: "Fix".to_owned(),
body: None, body: None,
labels: Vec::new(),
}], }],
Vec::new(), Vec::new(),
], ],
@@ -603,19 +499,4 @@ mod tests {
)); ));
assert!(app.editor.is_none()); assert!(app.editor.is_none());
} }
#[test]
fn label_board_without_cards_keeps_actionable_error() {
let result = App::new_labels(
vec![label(1, "kanban/Todo"), label(2, "kanban/Done")],
Vec::new(),
"kanban/".to_owned(),
);
let error = match result {
Ok(_) => panic!("empty board should report missing cards"),
Err(error) => error,
};
assert!(error.contains("add a kanban/<column> label"));
}
} }
+17 -68
View File
@@ -5,19 +5,13 @@ 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, ValueEnum}; use clap::Parser;
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum Backend {
Projects,
Labels,
}
#[derive(Parser)] #[derive(Parser)]
#[command( #[command(
name = "gitea-kanban-tui", name = "gitea-kanban-tui",
version, version,
about = "Browse and move Gitea issues using native projects or labels" about = "Browse and move Gitea issues using native projects"
)] )]
pub struct Args { pub struct Args {
/// Gitea base URL; falls back to GITEA_URL /// Gitea base URL; falls back to GITEA_URL
@@ -28,22 +22,14 @@ pub struct Args {
#[arg(long, value_name = "PATH")] #[arg(long, value_name = "PATH")]
pub token_file: Option<PathBuf>, pub token_file: Option<PathBuf>,
/// Board backend: projects or labels /// Exact native project name
#[arg(long, value_enum)]
pub backend: Option<Backend>,
/// Exact native project name; used with --backend projects
#[arg(long)] #[arg(long)]
pub project: Option<String>, pub project: Option<String>,
/// Native project ID; used with --backend projects /// Native project ID
#[arg(long)] #[arg(long)]
pub project_id: Option<u64>, pub project_id: Option<u64>,
/// Label prefix used for columns
#[arg(long)]
pub label_prefix: Option<String>,
/// Repository owner; falls back to GITEA_OWNER /// Repository owner; falls back to GITEA_OWNER
pub owner: Option<String>, pub owner: Option<String>,
@@ -57,10 +43,8 @@ pub struct Config {
pub token: String, pub token: String,
pub owner: String, pub owner: String,
pub repo: String, pub repo: String,
pub backend: Backend,
pub project: Option<String>, pub project: Option<String>,
pub project_id: Option<u64>, pub project_id: Option<u64>,
pub label_prefix: String,
} }
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
@@ -98,23 +82,6 @@ impl Config {
"repository name", "repository name",
"REPO argument or GITEA_REPO", "REPO argument or GITEA_REPO",
)?; )?;
let backend = if let Some(backend) = args.backend {
backend
} else if let Some(value) = env_var("GITEA_KANBAN_BACKEND") {
match value.as_str() {
"projects" => Backend::Projects,
"labels" => Backend::Labels,
_ => {
return Err(ConfigError(
"GITEA_KANBAN_BACKEND must be 'projects' or 'labels'".to_owned(),
));
}
}
} else {
return Err(ConfigError(
"missing board backend; use --backend projects or --backend labels".to_owned(),
));
};
let project = args let project = args
.project .project
.or_else(|| env_var("GITEA_PROJECT")) .or_else(|| env_var("GITEA_PROJECT"))
@@ -123,22 +90,12 @@ impl Config {
let project_id = args let project_id = args
.project_id .project_id
.or_else(|| env_var("GITEA_PROJECT_ID").and_then(|value| value.parse::<u64>().ok())); .or_else(|| env_var("GITEA_PROJECT_ID").and_then(|value| value.parse::<u64>().ok()));
if backend == Backend::Projects && project.is_some() == project_id.is_some() { if project.is_some() == project_id.is_some() {
return Err(ConfigError( return Err(ConfigError(
"projects backend requires exactly one of --project/GITEA_PROJECT or --project-id/GITEA_PROJECT_ID" "projects mode requires exactly one of --project/GITEA_PROJECT or --project-id/GITEA_PROJECT_ID"
.to_owned(), .to_owned(),
)); ));
} }
let label_prefix = args
.label_prefix
.or_else(|| env_var("GITEA_LABEL_PREFIX"))
.unwrap_or_else(|| "kanban/".to_owned());
if label_prefix.trim().is_empty() {
return Err(ConfigError(
"label prefix cannot be empty; set --label-prefix or GITEA_LABEL_PREFIX".to_owned(),
));
}
let token = if let Some(path) = args.token_file { let token = if let Some(path) = args.token_file {
read_token_file(path)? read_token_file(path)?
@@ -158,10 +115,8 @@ impl Config {
token, token,
owner, owner,
repo, repo,
backend,
project, project,
project_id, project_id,
label_prefix,
}) })
} }
} }
@@ -229,8 +184,8 @@ mod tests {
"gitea-kanban-tui", "gitea-kanban-tui",
"--url", "--url",
"https://gitea.example/", "https://gitea.example/",
"--backend", "--project",
"labels", "Kanban",
"owner", "owner",
"repo", "repo",
]) ])
@@ -240,8 +195,7 @@ mod tests {
assert_eq!(config.base_url, "https://gitea.example"); assert_eq!(config.base_url, "https://gitea.example");
assert_eq!(config.owner, "owner"); assert_eq!(config.owner, "owner");
assert_eq!(config.repo, "repo"); assert_eq!(config.repo, "repo");
assert_eq!(config.label_prefix, "kanban/"); assert_eq!(config.project.as_deref(), Some("Kanban"));
assert_eq!(config.backend, Backend::Labels);
assert_eq!(config.token, "secret"); assert_eq!(config.token, "secret");
} }
@@ -251,8 +205,6 @@ mod tests {
"gitea-kanban-tui", "gitea-kanban-tui",
"--url", "--url",
"https://gitea.example", "https://gitea.example",
"--backend",
"projects",
"--project", "--project",
"Kanban", "Kanban",
"owner", "owner",
@@ -261,13 +213,12 @@ mod tests {
.expect("arguments parse"); .expect("arguments parse");
let config = Config::from_args_with(args, token_env).expect("config is valid"); let config = Config::from_args_with(args, token_env).expect("config is valid");
assert_eq!(config.backend, Backend::Projects);
assert_eq!(config.project.as_deref(), Some("Kanban")); assert_eq!(config.project.as_deref(), Some("Kanban"));
assert_eq!(config.project_id, None); assert_eq!(config.project_id, None);
} }
#[test] #[test]
fn requires_explicit_backend() { fn requires_project_selector() {
let args = Args::try_parse_from([ let args = Args::try_parse_from([
"gitea-kanban-tui", "gitea-kanban-tui",
"--url", "--url",
@@ -277,8 +228,9 @@ mod tests {
]) ])
.expect("arguments parse"); .expect("arguments parse");
let error = Config::from_args_with(args, token_env).expect_err("backend is required"); let error =
assert!(error.to_string().contains("--backend projects")); Config::from_args_with(args, token_env).expect_err("project selector is required");
assert!(error.to_string().contains("--project"));
} }
#[test] #[test]
@@ -287,8 +239,6 @@ mod tests {
"gitea-kanban-tui", "gitea-kanban-tui",
"--url", "--url",
"https://gitea.example", "https://gitea.example",
"--backend",
"projects",
"owner", "owner",
"repo", "repo",
]) ])
@@ -305,17 +255,16 @@ mod tests {
match name { match name {
"GITEA_URL" => Some("https://gitea.example"), "GITEA_URL" => Some("https://gitea.example"),
"GITEA_TOKEN" => Some("secret"), "GITEA_TOKEN" => Some("secret"),
"GITEA_KANBAN_BACKEND" => Some("labels"), "GITEA_PROJECT" => Some("Kanban"),
"GITEA_OWNER" => Some("owner"), "GITEA_OWNER" => Some("owner"),
"GITEA_REPO" => Some("repo"), "GITEA_REPO" => Some("repo"),
"GITEA_LABEL_PREFIX" => Some("board/"),
_ => None, _ => None,
} }
.map(str::to_owned) .map(str::to_owned)
}) })
.expect("config is valid"); .expect("config is valid");
assert_eq!(config.label_prefix, "board/"); assert_eq!(config.project.as_deref(), Some("Kanban"));
assert_eq!(config.owner, "owner"); assert_eq!(config.owner, "owner");
} }
@@ -325,8 +274,8 @@ mod tests {
"gitea-kanban-tui", "gitea-kanban-tui",
"--url", "--url",
"https://gitea.example", "https://gitea.example",
"--backend", "--project",
"labels", "Kanban",
"owner", "owner",
"repo", "repo",
]) ])
+1 -15
View File
@@ -5,7 +5,7 @@ use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use gitea_kanban_tui::api::{resolve_project, GiteaApi, GiteaClient}; use gitea_kanban_tui::api::{resolve_project, GiteaApi, GiteaClient};
use gitea_kanban_tui::app::{App, IssueAction, MoveAction}; use gitea_kanban_tui::app::{App, IssueAction, MoveAction};
use gitea_kanban_tui::config::{Backend, Config}; use gitea_kanban_tui::config::Config;
use gitea_kanban_tui::model::CreateIssuePayload; use gitea_kanban_tui::model::CreateIssuePayload;
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;
@@ -108,8 +108,6 @@ 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>> {
match config.backend {
Backend::Projects => {
let projects = client.list_projects()?; let projects = client.list_projects()?;
let project = resolve_project(&projects, config.project.as_deref(), config.project_id)?; let project = resolve_project(&projects, config.project.as_deref(), config.project_id)?;
let columns = client.list_project_columns(project.id)?; let columns = client.list_project_columns(project.id)?;
@@ -119,14 +117,6 @@ fn load_board(client: &impl GiteaApi, config: &Config) -> Result<App, Box<dyn Er
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
App::new_project(project, columns, issues_by_column).map_err(|error| error.into()) App::new_project(project, columns, issues_by_column).map_err(|error| error.into())
} }
Backend::Labels => {
let labels = client.list_labels()?;
let issues = client.list_open_issues()?;
App::new_labels(labels, issues, config.label_prefix.clone())
.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) {
let Some(request) = app.prepare_move(offset) else { let Some(request) = app.prepare_move(offset) else {
@@ -139,10 +129,6 @@ fn move_card(client: &impl GiteaApi, app: &mut App, offset: isize) {
project_id, project_id,
payload, payload,
} => client.move_project_issue(*project_id, *issue_id, payload), } => client.move_project_issue(*project_id, *issue_id, payload),
MoveAction::Labels {
issue_number,
payload,
} => client.replace_issue_labels(*issue_number, payload),
}; };
match result { match result {
Ok(()) => app.apply_move(request), Ok(()) => app.apply_move(request),
-94
View File
@@ -1,13 +1,5 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct Label {
pub id: u64,
pub name: String,
#[serde(default)]
pub color: String,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] #[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct Issue { pub struct Issue {
pub id: u64, pub id: u64,
@@ -15,8 +7,6 @@ pub struct Issue {
pub title: String, pub title: String,
#[serde(default)] #[serde(default)]
pub body: Option<String>, pub body: Option<String>,
#[serde(default)]
pub labels: Vec<Label>,
} }
#[derive(Debug, Serialize, PartialEq, Eq)] #[derive(Debug, Serialize, PartialEq, Eq)]
@@ -35,7 +25,6 @@ pub struct EditIssuePayload {
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct ColumnSpec { pub struct ColumnSpec {
pub id: u64, pub id: u64,
pub label: Option<Label>,
pub title: String, pub title: String,
} }
@@ -64,93 +53,10 @@ pub struct MoveProjectIssuePayload {
pub sorting: Option<u64>, pub sorting: Option<u64>,
} }
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct ReplaceLabelsPayload {
pub labels: Vec<u64>,
}
pub fn extract_columns(labels: &[Label], prefix: &str) -> Vec<ColumnSpec> {
let mut columns: Vec<ColumnSpec> = labels
.iter()
.filter_map(|label| {
let title = label.name.strip_prefix(prefix)?;
if title.is_empty() {
None
} else {
Some(ColumnSpec {
id: label.id,
label: Some(label.clone()),
title: title.to_owned(),
})
}
})
.collect();
columns.sort_by(|left, right| left.title.cmp(&right.title));
columns
}
pub fn replacement_payload(
issue: &Issue,
column_prefix: &str,
target_label_id: u64,
) -> ReplaceLabelsPayload {
let mut labels: Vec<u64> = issue
.labels
.iter()
.filter(|label| !label.name.starts_with(column_prefix))
.map(|label| label.id)
.collect();
labels.push(target_label_id);
ReplaceLabelsPayload { labels }
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
fn label(id: u64, name: &str) -> Label {
Label {
id,
name: name.to_owned(),
color: String::new(),
}
}
#[test]
fn extracts_prefixed_columns_in_lexical_order() {
let labels = vec![
label(1, "bug"),
label(3, "kanban/Doing"),
label(2, "kanban/Todo"),
label(4, "kanban/"),
];
let columns = extract_columns(&labels, "kanban/");
assert_eq!(columns.len(), 2);
assert_eq!(columns[0].title, "Doing");
assert_eq!(columns[1].label.as_ref().expect("label").id, 2);
}
#[test]
fn replacement_removes_columns_and_preserves_other_labels() {
let issue = Issue {
id: 70,
number: 7,
title: "Fix it".to_owned(),
body: None,
labels: vec![label(1, "bug"), label(2, "kanban/Todo"), label(3, "urgent")],
};
let payload = replacement_payload(&issue, "kanban/", 9);
assert_eq!(payload.labels, vec![1, 3, 9]);
assert_eq!(
serde_json::to_value(payload).expect("serialize payload"),
serde_json::json!({"labels": [1, 3, 9]})
);
}
#[test] #[test]
fn deserializes_native_project_data_and_move_payload() { fn deserializes_native_project_data_and_move_payload() {
let project: Project = serde_json::from_value(serde_json::json!({ let project: Project = serde_json::from_value(serde_json::json!({