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"
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]
+6 -25
View File
@@ -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
+5 -51
View File
@@ -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"
);
}
+46 -165
View File
@@ -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"));
}
}
+17 -68
View File
@@ -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",
])
+9 -23
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::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),
-94
View File
@@ -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!({