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"
|
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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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!({
|
||||||
|
|||||||
Reference in New Issue
Block a user