feat: clear unneccessary backend from kanban
runner nix smoke / nix label and flake smoke (push) Failing after 35s
runner nix smoke / nix label and flake smoke (push) Failing after 35s
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
name = "gitea-kanban-tui"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Native-project and label-backed Gitea Kanban terminal interface"
|
||||
description = "Native-project Gitea Kanban terminal interface"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,33 +1,25 @@
|
||||
# gitea-kanban-tui
|
||||
|
||||
Keyboard-driven Kanban board for the custom Gitea fork. Native repository
|
||||
Projects are supported alongside the legacy repository-label backend. Backend
|
||||
selection is explicit; API failures never trigger a silent fallback.
|
||||
Keyboard-driven Kanban board for native repository Projects in custom Gitea fork.
|
||||
|
||||
## 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:
|
||||
|
||||
| Flag | Environment | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `--url` | `GITEA_URL` | Gitea base URL |
|
||||
| `--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-id` | `GITEA_PROJECT_ID` | Native project ID instead of name |
|
||||
| positional `OWNER` | `GITEA_OWNER` | Repository owner |
|
||||
| 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
|
||||
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
|
||||
admin permission in this fork. 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.
|
||||
@@ -36,7 +28,7 @@ accepted only for loopback development.
|
||||
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 -- \
|
||||
--backend projects --project Kanban yukkop util.nix
|
||||
--project Kanban yukkop util.nix
|
||||
```
|
||||
|
||||
Alternatively:
|
||||
@@ -44,14 +36,14 @@ Alternatively:
|
||||
```sh
|
||||
nix develop .#ratatui
|
||||
cargo run --manifest-path package/gitea-kanban-tui/Cargo.toml -- \
|
||||
--backend projects --project-id 1 owner repo
|
||||
--project-id 1 owner repo
|
||||
```
|
||||
|
||||
## Keys
|
||||
|
||||
- Arrow keys or `h`/`j`/`k`/`l`: focus column/card
|
||||
- `H`/`L`: move focused card left/right
|
||||
- `r`: refresh labels and open issues
|
||||
- `r`: refresh project columns and issues
|
||||
- `?`: toggle help
|
||||
- `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,
|
||||
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
|
||||
|
||||
|
||||
@@ -9,8 +9,7 @@ use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::model::{
|
||||
CreateIssuePayload, EditIssuePayload, Issue, Label, MoveProjectIssuePayload, Project,
|
||||
ProjectColumn, ReplaceLabelsPayload,
|
||||
CreateIssuePayload, EditIssuePayload, Issue, MoveProjectIssuePayload, Project, ProjectColumn,
|
||||
};
|
||||
use crate::text::sanitize_terminal_text;
|
||||
|
||||
@@ -28,13 +27,6 @@ pub trait GiteaApi {
|
||||
issue_id: u64,
|
||||
payload: &MoveProjectIssuePayload,
|
||||
) -> 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 edit_issue(&self, issue_number: u64, payload: &EditIssuePayload) -> Result<Issue, ApiError>;
|
||||
fn delete_issue(&self, issue_number: u64) -> Result<(), ApiError>;
|
||||
@@ -227,42 +219,6 @@ impl GiteaApi for GiteaClient {
|
||||
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> {
|
||||
let response = self
|
||||
.client
|
||||
@@ -331,7 +287,7 @@ fn check_response(response: Response, operation: &str, token: &str) -> Result<Re
|
||||
" Check token validity and repository issue permissions."
|
||||
}
|
||||
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(),
|
||||
owner: "owner name".to_owned(),
|
||||
repo: "repo/name".to_owned(),
|
||||
backend: crate::config::Backend::Labels,
|
||||
project: None,
|
||||
project: Some("Kanban".to_owned()),
|
||||
project_id: None,
|
||||
label_prefix: "kanban/".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,8 +387,8 @@ mod tests {
|
||||
.expect("client should build");
|
||||
|
||||
assert_eq!(
|
||||
client.endpoint("labels").expect("endpoint").as_str(),
|
||||
"https://gitea.example/subpath/api/v1/repos/owner%20name/repo%2Fname/labels"
|
||||
client.endpoint("projects").expect("endpoint").as_str(),
|
||||
"https://gitea.example/subpath/api/v1/repos/owner%20name/repo%2Fname/projects"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use crate::model::{
|
||||
extract_columns, replacement_payload, ColumnSpec, EditIssuePayload, Issue, Label,
|
||||
MoveProjectIssuePayload, Project, ProjectColumn, ReplaceLabelsPayload,
|
||||
ColumnSpec, EditIssuePayload, Issue, MoveProjectIssuePayload, Project, ProjectColumn,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -19,7 +18,7 @@ pub struct App {
|
||||
pub status: String,
|
||||
pub show_help: bool,
|
||||
pub editor: Option<EditorState>,
|
||||
backend: BoardBackend,
|
||||
project_id: u64,
|
||||
}
|
||||
|
||||
#[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)]
|
||||
pub enum MoveAction {
|
||||
Project {
|
||||
@@ -72,10 +65,6 @@ pub enum MoveAction {
|
||||
project_id: u64,
|
||||
payload: MoveProjectIssuePayload,
|
||||
},
|
||||
Labels {
|
||||
issue_number: u64,
|
||||
payload: ReplaceLabelsPayload,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
@@ -86,59 +75,6 @@ pub struct MoveRequest {
|
||||
}
|
||||
|
||||
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(
|
||||
project: Project,
|
||||
project_columns: Vec<ProjectColumn>,
|
||||
@@ -156,7 +92,6 @@ impl App {
|
||||
.map(|(column, cards)| Column {
|
||||
spec: ColumnSpec {
|
||||
id: column.id,
|
||||
label: None,
|
||||
title: column.title,
|
||||
},
|
||||
cards,
|
||||
@@ -175,9 +110,7 @@ impl App {
|
||||
status: "Ready".to_owned(),
|
||||
show_help: false,
|
||||
editor: None,
|
||||
backend: BoardBackend::Projects {
|
||||
project_id: project.id,
|
||||
},
|
||||
project_id: project.id,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -214,18 +147,12 @@ impl App {
|
||||
return None;
|
||||
}
|
||||
let issue = self.focused_card()?;
|
||||
let action = match &self.backend {
|
||||
BoardBackend::Projects { project_id } => MoveAction::Project {
|
||||
issue_id: issue.id,
|
||||
project_id: *project_id,
|
||||
payload: MoveProjectIssuePayload {
|
||||
column_id: self.columns[target].spec.id,
|
||||
sorting: None,
|
||||
},
|
||||
},
|
||||
BoardBackend::Labels { prefix } => MoveAction::Labels {
|
||||
issue_number: issue.number,
|
||||
payload: replacement_payload(issue, prefix, self.columns[target].spec.id),
|
||||
let action = MoveAction::Project {
|
||||
issue_id: issue.id,
|
||||
project_id: self.project_id,
|
||||
payload: MoveProjectIssuePayload {
|
||||
column_id: self.columns[target].spec.id,
|
||||
sorting: None,
|
||||
},
|
||||
};
|
||||
Some(MoveRequest {
|
||||
@@ -237,14 +164,8 @@ impl App {
|
||||
|
||||
pub fn apply_move(&mut self, request: MoveRequest) {
|
||||
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;
|
||||
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.focused_cards[request.source] =
|
||||
selected.min(self.columns[request.source].cards.len().saturating_sub(1));
|
||||
@@ -254,16 +175,10 @@ impl App {
|
||||
}
|
||||
|
||||
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 },
|
||||
mode: EditorMode::Create {
|
||||
project_id: self.project_id,
|
||||
},
|
||||
field: EditorField::Title,
|
||||
title: String::new(),
|
||||
body: String::new(),
|
||||
@@ -271,10 +186,6 @@ impl App {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -290,10 +201,6 @@ impl App {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -430,34 +337,44 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
fn label(id: u64, name: &str) -> Label {
|
||||
Label {
|
||||
id,
|
||||
name: name.to_owned(),
|
||||
color: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn app() -> App {
|
||||
App::new_labels(
|
||||
vec![label(1, "kanban/01 Todo"), label(2, "kanban/02 Done")],
|
||||
App::new_project(
|
||||
Project {
|
||||
id: 8,
|
||||
title: "Kanban".to_owned(),
|
||||
is_closed: false,
|
||||
},
|
||||
vec![
|
||||
Issue {
|
||||
id: 1,
|
||||
number: 1,
|
||||
title: "First".to_owned(),
|
||||
body: None,
|
||||
labels: vec![label(1, "kanban/01 Todo"), label(7, "bug")],
|
||||
ProjectColumn {
|
||||
id: 10,
|
||||
title: "Todo".to_owned(),
|
||||
color: String::new(),
|
||||
sorting: 0,
|
||||
},
|
||||
Issue {
|
||||
id: 2,
|
||||
number: 2,
|
||||
title: "Second".to_owned(),
|
||||
body: None,
|
||||
labels: vec![label(1, "kanban/01 Todo")],
|
||||
ProjectColumn {
|
||||
id: 20,
|
||||
title: "Done".to_owned(),
|
||||
color: String::new(),
|
||||
sorting: 1,
|
||||
},
|
||||
],
|
||||
"kanban/".to_owned(),
|
||||
vec![
|
||||
vec![
|
||||
Issue {
|
||||
id: 1,
|
||||
number: 1,
|
||||
title: "First".to_owned(),
|
||||
body: None,
|
||||
},
|
||||
Issue {
|
||||
id: 2,
|
||||
number: 2,
|
||||
title: "Second".to_owned(),
|
||||
body: None,
|
||||
},
|
||||
],
|
||||
Vec::new(),
|
||||
],
|
||||
)
|
||||
.expect("board builds")
|
||||
}
|
||||
@@ -478,26 +395,6 @@ mod tests {
|
||||
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]
|
||||
fn cannot_move_past_board_edge() {
|
||||
let app = app();
|
||||
@@ -551,7 +448,6 @@ mod tests {
|
||||
number: 7,
|
||||
title: "Fix".to_owned(),
|
||||
body: None,
|
||||
labels: Vec::new(),
|
||||
}],
|
||||
Vec::new(),
|
||||
],
|
||||
@@ -603,19 +499,4 @@ mod tests {
|
||||
));
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,19 +5,13 @@ use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::{Parser, ValueEnum};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
|
||||
pub enum Backend {
|
||||
Projects,
|
||||
Labels,
|
||||
}
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "gitea-kanban-tui",
|
||||
version,
|
||||
about = "Browse and move Gitea issues using native projects or labels"
|
||||
about = "Browse and move Gitea issues using native projects"
|
||||
)]
|
||||
pub struct Args {
|
||||
/// Gitea base URL; falls back to GITEA_URL
|
||||
@@ -28,22 +22,14 @@ pub struct Args {
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub token_file: Option<PathBuf>,
|
||||
|
||||
/// Board backend: projects or labels
|
||||
#[arg(long, value_enum)]
|
||||
pub backend: Option<Backend>,
|
||||
|
||||
/// Exact native project name; used with --backend projects
|
||||
/// Exact native project name
|
||||
#[arg(long)]
|
||||
pub project: Option<String>,
|
||||
|
||||
/// Native project ID; used with --backend projects
|
||||
/// Native project ID
|
||||
#[arg(long)]
|
||||
pub project_id: Option<u64>,
|
||||
|
||||
/// Label prefix used for columns
|
||||
#[arg(long)]
|
||||
pub label_prefix: Option<String>,
|
||||
|
||||
/// Repository owner; falls back to GITEA_OWNER
|
||||
pub owner: Option<String>,
|
||||
|
||||
@@ -57,10 +43,8 @@ pub struct Config {
|
||||
pub token: String,
|
||||
pub owner: String,
|
||||
pub repo: String,
|
||||
pub backend: Backend,
|
||||
pub project: Option<String>,
|
||||
pub project_id: Option<u64>,
|
||||
pub label_prefix: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
@@ -98,23 +82,6 @@ impl Config {
|
||||
"repository name",
|
||||
"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
|
||||
.project
|
||||
.or_else(|| env_var("GITEA_PROJECT"))
|
||||
@@ -123,22 +90,12 @@ impl Config {
|
||||
let project_id = args
|
||||
.project_id
|
||||
.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(
|
||||
"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(),
|
||||
));
|
||||
}
|
||||
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 {
|
||||
read_token_file(path)?
|
||||
@@ -158,10 +115,8 @@ impl Config {
|
||||
token,
|
||||
owner,
|
||||
repo,
|
||||
backend,
|
||||
project,
|
||||
project_id,
|
||||
label_prefix,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -229,8 +184,8 @@ mod tests {
|
||||
"gitea-kanban-tui",
|
||||
"--url",
|
||||
"https://gitea.example/",
|
||||
"--backend",
|
||||
"labels",
|
||||
"--project",
|
||||
"Kanban",
|
||||
"owner",
|
||||
"repo",
|
||||
])
|
||||
@@ -240,8 +195,7 @@ mod tests {
|
||||
assert_eq!(config.base_url, "https://gitea.example");
|
||||
assert_eq!(config.owner, "owner");
|
||||
assert_eq!(config.repo, "repo");
|
||||
assert_eq!(config.label_prefix, "kanban/");
|
||||
assert_eq!(config.backend, Backend::Labels);
|
||||
assert_eq!(config.project.as_deref(), Some("Kanban"));
|
||||
assert_eq!(config.token, "secret");
|
||||
}
|
||||
|
||||
@@ -251,8 +205,6 @@ mod tests {
|
||||
"gitea-kanban-tui",
|
||||
"--url",
|
||||
"https://gitea.example",
|
||||
"--backend",
|
||||
"projects",
|
||||
"--project",
|
||||
"Kanban",
|
||||
"owner",
|
||||
@@ -261,13 +213,12 @@ mod tests {
|
||||
.expect("arguments parse");
|
||||
|
||||
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_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_explicit_backend() {
|
||||
fn requires_project_selector() {
|
||||
let args = Args::try_parse_from([
|
||||
"gitea-kanban-tui",
|
||||
"--url",
|
||||
@@ -277,8 +228,9 @@ mod tests {
|
||||
])
|
||||
.expect("arguments parse");
|
||||
|
||||
let error = Config::from_args_with(args, token_env).expect_err("backend is required");
|
||||
assert!(error.to_string().contains("--backend projects"));
|
||||
let error =
|
||||
Config::from_args_with(args, token_env).expect_err("project selector is required");
|
||||
assert!(error.to_string().contains("--project"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -287,8 +239,6 @@ mod tests {
|
||||
"gitea-kanban-tui",
|
||||
"--url",
|
||||
"https://gitea.example",
|
||||
"--backend",
|
||||
"projects",
|
||||
"owner",
|
||||
"repo",
|
||||
])
|
||||
@@ -305,17 +255,16 @@ mod tests {
|
||||
match name {
|
||||
"GITEA_URL" => Some("https://gitea.example"),
|
||||
"GITEA_TOKEN" => Some("secret"),
|
||||
"GITEA_KANBAN_BACKEND" => Some("labels"),
|
||||
"GITEA_PROJECT" => Some("Kanban"),
|
||||
"GITEA_OWNER" => Some("owner"),
|
||||
"GITEA_REPO" => Some("repo"),
|
||||
"GITEA_LABEL_PREFIX" => Some("board/"),
|
||||
_ => None,
|
||||
}
|
||||
.map(str::to_owned)
|
||||
})
|
||||
.expect("config is valid");
|
||||
|
||||
assert_eq!(config.label_prefix, "board/");
|
||||
assert_eq!(config.project.as_deref(), Some("Kanban"));
|
||||
assert_eq!(config.owner, "owner");
|
||||
}
|
||||
|
||||
@@ -325,8 +274,8 @@ mod tests {
|
||||
"gitea-kanban-tui",
|
||||
"--url",
|
||||
"https://gitea.example",
|
||||
"--backend",
|
||||
"labels",
|
||||
"--project",
|
||||
"Kanban",
|
||||
"owner",
|
||||
"repo",
|
||||
])
|
||||
|
||||
@@ -5,7 +5,7 @@ use crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
||||
|
||||
use gitea_kanban_tui::api::{resolve_project, GiteaApi, GiteaClient};
|
||||
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::terminal::TerminalGuard;
|
||||
use gitea_kanban_tui::text::sanitize_terminal_text;
|
||||
@@ -108,24 +108,14 @@ 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>> {
|
||||
match config.backend {
|
||||
Backend::Projects => {
|
||||
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())
|
||||
}
|
||||
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())
|
||||
}
|
||||
}
|
||||
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())
|
||||
}
|
||||
|
||||
fn move_card(client: &impl GiteaApi, app: &mut App, offset: isize) {
|
||||
@@ -139,10 +129,6 @@ fn move_card(client: &impl GiteaApi, app: &mut App, offset: isize) {
|
||||
project_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 {
|
||||
Ok(()) => app.apply_move(request),
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
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)]
|
||||
pub struct Issue {
|
||||
pub id: u64,
|
||||
@@ -15,8 +7,6 @@ pub struct Issue {
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
#[serde(default)]
|
||||
pub labels: Vec<Label>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq, Eq)]
|
||||
@@ -35,7 +25,6 @@ pub struct EditIssuePayload {
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ColumnSpec {
|
||||
pub id: u64,
|
||||
pub label: Option<Label>,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
@@ -64,93 +53,10 @@ pub struct MoveProjectIssuePayload {
|
||||
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)]
|
||||
mod tests {
|
||||
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]
|
||||
fn deserializes_native_project_data_and_move_payload() {
|
||||
let project: Project = serde_json::from_value(serde_json::json!({
|
||||
|
||||
Reference in New Issue
Block a user