feat: kanban tui
runner nix smoke / nix label and flake smoke (push) Failing after 1m12s

This commit is contained in:
2026-09-27 15:42:51 +00:00
parent 4c9611a2d2
commit 335a74010d
28 changed files with 4636 additions and 100 deletions
+1
View File
@@ -149,6 +149,7 @@ in {
deploy = pkgs.callPackage ./deploy { inherit inputs; };
element-web = pkgs.callPackage ./element-web {};
shellplot = pkgs.callPackage ./shellplot {};
gitea-kanban-tui = pkgs.callPackage ./gitea-kanban-tui rust.commonArgs;
which-country-rs = pkgs.callPackage ./which-country-rs {};
onlinepubs2man = pkgs.callPackage ./onlinepubs2man {};
migrator = pkgs.callPackage ./migrator { inherit self; };
+1453
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "gitea-kanban-tui"
version = "0.1.0"
edition = "2021"
description = "Native-project and label-backed Gitea Kanban terminal interface"
license = "MIT"
[dependencies]
clap = { version = "=4.5.20", features = ["derive"] }
crossterm = "=0.28.1"
encoding_rs = "=0.8.35"
indexmap = "=2.6.0"
instability = "=0.3.2"
ratatui = "=0.29.0"
reqwest = { version = "=0.11.27", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { version = "=1.0.210", features = ["derive"] }
serde_json = "=1.0.128"
unicode-segmentation = "=1.12.0"
url = "=2.5.2"
+83
View File
@@ -0,0 +1,83 @@
# 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.
## 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` |
| `--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. Label
mode requires repository issue read/write permission. 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.
```sh
export GITEA_URL=https://gitea.hectic-lab.com
export GITEA_TOKEN_FILE="$HOME/.config/gitea/token"
cargo run --manifest-path package/gitea-kanban-tui/Cargo.toml -- \
--backend projects --project Kanban yukkop util.nix
```
Alternatively:
```sh
nix develop .#ratatui
cargo run --manifest-path package/gitea-kanban-tui/Cargo.toml -- \
--backend projects --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
- `?`: toggle help
- `q`: quit
Native mode reads `/projects`, project columns, and each column's issues. Moves
use the issue's global API `id` and destination column `id`; optional sorting is
supported by the server API. Project names are exact, case-sensitive matches;
use `--project-id` when duplicate names exist. Empty native boards render
normally. 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
```sh
nix develop .#ratatui
cargo fmt --manifest-path package/gitea-kanban-tui/Cargo.toml -- --check
cargo clippy --manifest-path package/gitea-kanban-tui/Cargo.toml --all-targets -- -D warnings
cargo test --manifest-path package/gitea-kanban-tui/Cargo.toml
nix build .#gitea-kanban-tui
```
+25
View File
@@ -0,0 +1,25 @@
{
cargoToml,
lib,
nativeBuildInputs,
pkgs,
...
}: let
cargo = cargoToml ./Cargo.toml;
in
pkgs.rustPlatform.buildRustPackage {
pname = cargo.package.name;
version = cargo.package.version;
src = ./.;
inherit nativeBuildInputs;
cargoLock.lockFile = ./Cargo.lock;
doCheck = true;
meta = {
description = cargo.package.description;
license = lib.licenses.mit;
mainProgram = "gitea-kanban-tui";
};
}
+473
View File
@@ -0,0 +1,473 @@
use std::fmt;
use std::time::Duration;
use reqwest::blocking::{Client, Response};
use reqwest::redirect::Policy;
use reqwest::{StatusCode, Url};
use serde::de::DeserializeOwned;
use crate::config::Config;
use crate::model::{
Issue, Label, MoveProjectIssuePayload, Project, ProjectColumn, ReplaceLabelsPayload,
};
use crate::text::sanitize_terminal_text;
pub trait GiteaApi {
fn list_projects(&self) -> Result<Vec<Project>, ApiError>;
fn list_project_columns(&self, project_id: u64) -> Result<Vec<ProjectColumn>, ApiError>;
fn list_project_column_issues(
&self,
project_id: u64,
column_id: u64,
) -> Result<Vec<Issue>, ApiError>;
fn move_project_issue(
&self,
project_id: u64,
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>;
}
pub struct GiteaClient {
client: Client,
api_base: Url,
token: String,
}
#[derive(Debug)]
pub struct ApiError(String);
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for ApiError {}
pub fn resolve_project(
projects: &[Project],
project_name: Option<&str>,
project_id: Option<u64>,
) -> Result<Project, ApiError> {
if let Some(id) = project_id {
return projects
.iter()
.find(|project| project.id == id)
.cloned()
.ok_or_else(|| ApiError(format!("project ID {id} was not found in this repository")));
}
let name = project_name.ok_or_else(|| ApiError("project selector is missing".to_owned()))?;
let matches = projects
.iter()
.filter(|project| project.title == name)
.cloned()
.collect::<Vec<_>>();
match matches.as_slice() {
[project] => Ok(project.clone()),
[] => Err(ApiError(format!(
"project named '{name}' was not found; names are matched exactly"
))),
_ => Err(ApiError(format!(
"multiple projects are named '{name}'; use --project-id"
))),
}
}
impl GiteaClient {
pub fn new(config: &Config) -> Result<Self, ApiError> {
let mut api_base = Url::parse(&config.base_url)
.map_err(|error| ApiError(format!("invalid Gitea URL: {error}")))?;
require_secure_transport(&api_base)?;
api_base.set_query(None);
api_base.set_fragment(None);
api_base
.path_segments_mut()
.map_err(|_| ApiError("Gitea URL cannot be used as an API base".to_owned()))?
.pop_if_empty()
.extend(["api", "v1", "repos", &config.owner, &config.repo]);
let client = Client::builder()
.timeout(Duration::from_secs(20))
.redirect(Policy::none())
.user_agent(concat!("gitea-kanban-tui/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|error| ApiError(format!("cannot create HTTP client: {error}")))?;
Ok(Self {
client,
api_base,
token: config.token.clone(),
})
}
fn endpoint(&self, path: &str) -> Result<Url, ApiError> {
let mut url = self.api_base.clone();
url.path_segments_mut()
.map_err(|_| ApiError("Gitea URL cannot contain API paths".to_owned()))?
.extend(path.split('/').filter(|part| !part.is_empty()));
Ok(url)
}
fn decode<T: DeserializeOwned>(
&self,
response: Response,
operation: &str,
) -> Result<T, ApiError> {
let response = check_response(response, operation)?;
response.json().map_err(|error| {
ApiError(format!(
"Gitea returned invalid JSON while {operation}: {error}"
))
})
}
fn get_all<T: DeserializeOwned>(
&self,
path: &str,
operation: &str,
query: &[(&str, &str)],
) -> Result<Vec<T>, ApiError> {
let mut items = Vec::new();
for page in 1..=10_000_u32 {
let page_value = page.to_string();
let mut page_query = query.to_vec();
page_query.extend([("limit", "100"), ("page", page_value.as_str())]);
let response = self
.client
.get(self.endpoint(path)?)
.query(&page_query)
.header("Authorization", format!("token {}", self.token))
.send()
.map_err(|error| {
ApiError(format!("cannot reach Gitea while {operation}: {error}"))
})?;
let page_items: Vec<T> = self.decode(response, operation)?;
if page_items.is_empty() {
return Ok(items);
}
items.extend(page_items);
}
Err(ApiError(format!(
"Gitea returned too many pages while {operation}; narrow repository data or check server pagination"
)))
}
}
impl GiteaApi for GiteaClient {
fn list_projects(&self) -> Result<Vec<Project>, ApiError> {
self.get_all(
"projects",
"listing repository projects",
&[("state", "all")],
)
}
fn list_project_columns(&self, project_id: u64) -> Result<Vec<ProjectColumn>, ApiError> {
let response = self
.client
.get(self.endpoint(&format!("projects/{project_id}/columns"))?)
.header("Authorization", format!("token {}", self.token))
.send()
.map_err(|error| {
ApiError(format!(
"cannot reach Gitea while listing project columns: {error}"
))
})?;
self.decode(response, "listing project columns")
}
fn list_project_column_issues(
&self,
project_id: u64,
column_id: u64,
) -> Result<Vec<Issue>, ApiError> {
self.get_all(
&format!("projects/{project_id}/columns/{column_id}/issues"),
"listing project issues",
&[],
)
}
fn move_project_issue(
&self,
project_id: u64,
issue_id: u64,
payload: &MoveProjectIssuePayload,
) -> Result<(), ApiError> {
let response = self
.client
.post(self.endpoint(&format!("projects/{project_id}/issues/{issue_id}/move"))?)
.header("Authorization", format!("token {}", self.token))
.json(payload)
.send()
.map_err(|error| {
ApiError(format!(
"cannot reach Gitea while moving project issue: {error}"
))
})?;
check_response(response, "moving project issue")?;
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}"),
)?;
Ok(())
}
}
fn check_response(response: Response, operation: &str) -> Result<Response, ApiError> {
let status = response.status();
if status.is_success() {
return Ok(response);
}
let message = response
.text()
.ok()
.and_then(|body| serde_json::from_str::<serde_json::Value>(&body).ok())
.and_then(|value| value.get("message")?.as_str().map(sanitize_terminal_text));
let detail = message
.map(|message| format!(": {message}"))
.unwrap_or_default();
let guidance = match status {
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
" Check token validity and repository issue permissions."
}
StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED => {
" Check repository owner/name and whether this Gitea version supports repository issue-label APIs."
}
_ => "",
};
Err(ApiError(format!(
"Gitea API failed while {operation} ({status}){detail}.{guidance}"
)))
}
fn require_secure_transport(url: &Url) -> Result<(), ApiError> {
if url.scheme() == "https" {
return Ok(());
}
let loopback = matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "::1"));
if url.scheme() == "http" && loopback {
return Ok(());
}
Err(ApiError(
"Gitea URL must use HTTPS to protect the API token; plain HTTP is allowed only for loopback development"
.to_owned(),
))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc::{self, Receiver};
use std::thread;
fn config(base_url: &str) -> Config {
Config {
base_url: base_url.to_owned(),
token: "secret".to_owned(),
owner: "owner name".to_owned(),
repo: "repo/name".to_owned(),
backend: crate::config::Backend::Labels,
project: None,
project_id: None,
label_prefix: "kanban/".to_owned(),
}
}
fn mock_server(responses: Vec<&'static str>) -> (String, Receiver<String>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock server");
let address = listener.local_addr().expect("mock address");
let (sender, receiver) = mpsc::channel();
thread::spawn(move || {
for response in responses {
let (mut stream, _) = listener.accept().expect("accept request");
let mut request = Vec::new();
let mut buffer = [0_u8; 4096];
loop {
let read = stream.read(&mut buffer).expect("read request");
if read == 0 {
break;
}
request.extend_from_slice(&buffer[..read]);
let header_end = request
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|position| position + 4);
if let Some(header_end) = header_end {
let headers = String::from_utf8_lossy(&request[..header_end]);
let content_length = headers
.lines()
.find_map(|line| {
line.to_ascii_lowercase()
.strip_prefix("content-length: ")?
.parse::<usize>()
.ok()
})
.unwrap_or(0);
if request.len() >= header_end + content_length {
break;
}
}
}
let _ = sender.send(String::from_utf8(request).expect("UTF-8 request"));
stream
.write_all(response.as_bytes())
.expect("write response");
}
});
(format!("http://{address}"), receiver)
}
#[test]
fn preserves_base_path_and_encodes_repository_segments() {
let client = GiteaClient::new(&config("https://gitea.example/subpath"))
.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"
);
}
#[test]
fn rejects_remote_plain_http_but_allows_loopback() {
assert!(GiteaClient::new(&config("http://gitea.example")).is_err());
assert!(GiteaClient::new(&config("http://127.0.0.1:3000")).is_ok());
}
#[test]
fn resolves_exact_project_name_and_rejects_ambiguity() {
let projects = vec![
Project {
id: 1,
title: "Kanban".to_owned(),
is_closed: false,
},
Project {
id: 2,
title: "kanban".to_owned(),
is_closed: false,
},
];
assert_eq!(
resolve_project(&projects, Some("Kanban"), None)
.expect("match")
.id,
1
);
assert!(resolve_project(&projects, Some("Missing"), None).is_err());
assert_eq!(
resolve_project(&projects, None, Some(2)).expect("id").title,
"kanban"
);
}
#[test]
fn lists_projects_with_pagination_and_reports_unsupported_api() {
let (base_url, requests) = mock_server(vec![
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n[{\"id\":4,\"title\":\"Kanban\",\"is_closed\":false}]",
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n[]",
]);
let client = GiteaClient::new(&config(&base_url)).expect("client");
let projects = client.list_projects().expect("projects");
assert_eq!(projects[0].title, "Kanban");
let first = requests.recv().expect("first request");
let second = requests.recv().expect("second request");
assert!(first.starts_with("GET /api/v1/repos/owner%20name/repo%2Fname/projects?"));
assert!(first.contains("state=all"));
assert!(first.contains("page=1"));
assert!(second.contains("page=2"));
assert!(first.contains("authorization: token secret"));
let (base_url, _) = mock_server(vec![
"HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{\"message\":\"projects unavailable\"}",
]);
let client = GiteaClient::new(&config(&base_url)).expect("client");
let error = client
.list_project_columns(4)
.expect_err("unsupported API must fail");
assert!(error.to_string().contains("404 Not Found"));
assert!(error.to_string().contains("projects unavailable"));
assert!(!error.to_string().contains("secret"));
}
#[test]
fn sends_global_issue_move_payload() {
let (base_url, requests) = mock_server(vec![
"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
]);
let client = GiteaClient::new(&config(&base_url)).expect("client");
client
.move_project_issue(
4,
99,
&MoveProjectIssuePayload {
column_id: 12,
sorting: Some(3),
},
)
.expect("move");
let request = requests.recv().expect("request");
assert!(request.starts_with(
"POST /api/v1/repos/owner%20name/repo%2Fname/projects/4/issues/99/move HTTP/1.1"
));
assert!(request.ends_with("{\"column_id\":12,\"sorting\":3}"));
}
#[test]
fn paginates_native_column_issues() {
let (base_url, requests) = mock_server(vec![
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n[{\"id\":99,\"number\":7,\"title\":\"Fix\"}]",
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n[]",
]);
let client = GiteaClient::new(&config(&base_url)).expect("client");
let issues = client
.list_project_column_issues(4, 12)
.expect("column issues");
assert_eq!(issues[0].id, 99);
assert!(requests.recv().expect("page one").contains("page=1"));
assert!(requests.recv().expect("page two").contains("page=2"));
}
}
+369
View File
@@ -0,0 +1,369 @@
use crate::model::{
extract_columns, replacement_payload, ColumnSpec, Issue, Label, MoveProjectIssuePayload,
Project, ProjectColumn, ReplaceLabelsPayload,
};
#[derive(Clone, Debug)]
pub struct Column {
pub spec: ColumnSpec,
pub cards: Vec<Issue>,
}
pub struct App {
pub board_title: String,
pub columns: Vec<Column>,
pub focused_column: usize,
pub focused_cards: Vec<usize>,
pub status: String,
pub show_help: bool,
backend: BoardBackend,
}
#[derive(Clone, Debug)]
enum BoardBackend {
Projects { project_id: u64 },
Labels { prefix: String },
}
#[derive(Debug, PartialEq, Eq)]
pub enum MoveAction {
Project {
issue_id: u64,
project_id: u64,
payload: MoveProjectIssuePayload,
},
Labels {
issue_number: u64,
payload: ReplaceLabelsPayload,
},
}
#[derive(Debug, PartialEq, Eq)]
pub struct MoveRequest {
pub source: usize,
pub target: usize,
pub action: MoveAction,
}
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,
backend: BoardBackend::Labels {
prefix: label_prefix,
},
})
}
pub fn new_project(
project: Project,
project_columns: Vec<ProjectColumn>,
issues_by_column: Vec<Vec<Issue>>,
) -> Result<Self, String> {
if project_columns.is_empty() {
return Err(format!("project '{}' has no columns", project.title));
}
if project_columns.len() != issues_by_column.len() {
return Err("project columns and issue lists do not match".to_owned());
}
let columns = project_columns
.into_iter()
.zip(issues_by_column)
.map(|(column, cards)| Column {
spec: ColumnSpec {
id: column.id,
label: None,
title: column.title,
},
cards,
})
.collect::<Vec<_>>();
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!("{} (project {})", project.title, project.id),
columns,
focused_column,
focused_cards,
status: "Ready".to_owned(),
show_help: false,
backend: BoardBackend::Projects {
project_id: project.id,
},
})
}
pub fn focus_left(&mut self) {
self.focused_column = self.focused_column.saturating_sub(1);
}
pub fn focus_right(&mut self) {
self.focused_column = (self.focused_column + 1).min(self.columns.len() - 1);
}
pub fn focus_up(&mut self) {
let selected = &mut self.focused_cards[self.focused_column];
*selected = selected.saturating_sub(1);
}
pub fn focus_down(&mut self) {
let column = &self.columns[self.focused_column];
if !column.cards.is_empty() {
let selected = &mut self.focused_cards[self.focused_column];
*selected = (*selected + 1).min(column.cards.len() - 1);
}
}
pub fn focused_card(&self) -> Option<&Issue> {
self.columns[self.focused_column]
.cards
.get(self.focused_cards[self.focused_column])
}
pub fn prepare_move(&self, offset: isize) -> Option<MoveRequest> {
let target = self.focused_column.checked_add_signed(offset)?;
if target >= self.columns.len() {
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),
},
};
Some(MoveRequest {
source: self.focused_column,
target,
action,
})
}
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_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));
self.focused_cards[request.target] = self.columns[request.target].cards.len() - 1;
self.focused_column = request.target;
self.status = format!("Moved issue #{issue_number}");
}
}
#[cfg(test)]
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")],
vec![
Issue {
id: 1,
number: 1,
title: "First".to_owned(),
body: None,
labels: vec![label(1, "kanban/01 Todo"), label(7, "bug")],
},
Issue {
id: 2,
number: 2,
title: "Second".to_owned(),
body: None,
labels: vec![label(1, "kanban/01 Todo")],
},
],
"kanban/".to_owned(),
)
.expect("board builds")
}
#[test]
fn navigation_stays_within_board() {
let mut app = app();
app.focus_left();
app.focus_up();
assert_eq!(app.focused_column, 0);
assert_eq!(app.focused_cards[0], 0);
app.focus_down();
app.focus_down();
app.focus_right();
app.focus_right();
assert_eq!(app.focused_cards[0], 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]
fn cannot_move_past_board_edge() {
let app = app();
assert!(app.prepare_move(-1).is_none());
}
#[test]
fn empty_board_returns_actionable_error() {
let result = App::new_project(
Project {
id: 1,
title: "Kanban".to_owned(),
is_closed: false,
},
vec![ProjectColumn {
id: 1,
title: "Todo".to_owned(),
color: String::new(),
sorting: 0,
}],
vec![Vec::new()],
);
assert!(result.is_ok());
}
#[test]
fn native_move_uses_global_issue_and_column_ids() {
let mut app = App::new_project(
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![Issue {
id: 99,
number: 7,
title: "Fix".to_owned(),
body: None,
labels: Vec::new(),
}],
Vec::new(),
],
)
.expect("board");
let request = app.prepare_move(1).expect("move");
assert!(matches!(
request.action,
MoveAction::Project { issue_id: 99, ref payload, .. } if payload.column_id == 20
));
app.apply_move(request);
assert_eq!(app.columns[1].cards[0].id, 99);
}
#[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"));
}
}
+341
View File
@@ -0,0 +1,341 @@
use std::env;
use std::fmt;
use std::fs;
#[cfg(unix)]
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,
}
#[derive(Parser)]
#[command(
name = "gitea-kanban-tui",
version,
about = "Browse and move Gitea issues using native projects or labels"
)]
pub struct Args {
/// Gitea base URL; falls back to GITEA_URL
#[arg(long)]
pub url: Option<String>,
/// File containing API token; falls back to GITEA_TOKEN_FILE
#[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
#[arg(long)]
pub project: Option<String>,
/// Native project ID; used with --backend projects
#[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>,
/// Repository name; falls back to GITEA_REPO
pub repo: Option<String>,
}
#[derive(Debug)]
pub struct Config {
pub base_url: String,
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)]
pub struct ConfigError(String);
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for ConfigError {}
impl Config {
pub fn load() -> Result<Self, ConfigError> {
Self::from_args_with(Args::parse(), |name| env::var(name).ok())
}
pub fn from_args_with<F>(args: Args, env_var: F) -> Result<Self, ConfigError>
where
F: Fn(&str) -> Option<String>,
{
let base_url = required(
args.url.or_else(|| env_var("GITEA_URL")),
"Gitea URL",
"--url or GITEA_URL",
)?;
let owner = required(
args.owner.or_else(|| env_var("GITEA_OWNER")),
"repository owner",
"OWNER argument or GITEA_OWNER",
)?;
let repo = required(
args.repo.or_else(|| env_var("GITEA_REPO")),
"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"))
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty());
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() {
return Err(ConfigError(
"projects backend 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)?
} else if let Some(path) = env_var("GITEA_TOKEN_FILE") {
read_token_file(PathBuf::from(path))?
} else if let Some(token) = env_var("GITEA_TOKEN") {
clean_token(token, "GITEA_TOKEN")?
} else {
return Err(ConfigError(
"missing Gitea token; use --token-file, GITEA_TOKEN, or GITEA_TOKEN_FILE"
.to_owned(),
));
};
Ok(Self {
base_url: base_url.trim_end_matches('/').to_owned(),
token,
owner,
repo,
backend,
project,
project_id,
label_prefix,
})
}
}
fn required(value: Option<String>, name: &str, source: &str) -> Result<String, ConfigError> {
match value.map(|value| value.trim().to_owned()) {
Some(value) if !value.is_empty() => Ok(value),
_ => Err(ConfigError(format!("missing {name}; use {source}"))),
}
}
fn clean_token(token: String, source: &str) -> Result<String, ConfigError> {
let token = token.trim().to_owned();
if token.is_empty() {
Err(ConfigError(format!("{source} contains an empty token")))
} else {
Ok(token)
}
}
fn read_token_file(path: PathBuf) -> Result<String, ConfigError> {
let metadata = fs::symlink_metadata(&path).map_err(|error| {
ConfigError(format!(
"cannot inspect token file {}: {error}",
path.display()
))
})?;
if !metadata.is_file() {
return Err(ConfigError(format!(
"token path {} is not a regular file",
path.display()
)));
}
#[cfg(unix)]
if metadata.permissions().mode() & 0o077 != 0 {
return Err(ConfigError(format!(
"token file {} must not be group- or world-readable",
path.display()
)));
}
let token = fs::read_to_string(&path).map_err(|error| {
ConfigError(format!(
"cannot read token file {}: {error}",
path.display()
))
})?;
clean_token(token, &format!("token file {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_env(_: &str) -> Option<String> {
None
}
fn token_env(name: &str) -> Option<String> {
(name == "GITEA_TOKEN").then(|| "secret".to_owned())
}
#[test]
fn parses_explicit_configuration() {
let args = Args::try_parse_from([
"gitea-kanban-tui",
"--url",
"https://gitea.example/",
"--backend",
"labels",
"owner",
"repo",
])
.expect("arguments parse");
let config = Config::from_args_with(args, token_env).expect("config is valid");
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.token, "secret");
}
#[test]
fn parses_native_project_by_name() {
let args = Args::try_parse_from([
"gitea-kanban-tui",
"--url",
"https://gitea.example",
"--backend",
"projects",
"--project",
"Kanban",
"owner",
"repo",
])
.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() {
let args = Args::try_parse_from([
"gitea-kanban-tui",
"--url",
"https://gitea.example",
"owner",
"repo",
])
.expect("arguments parse");
let error = Config::from_args_with(args, token_env).expect_err("backend is required");
assert!(error.to_string().contains("--backend projects"));
}
#[test]
fn native_project_selector_is_unambiguous() {
let args = Args::try_parse_from([
"gitea-kanban-tui",
"--url",
"https://gitea.example",
"--backend",
"projects",
"owner",
"repo",
])
.expect("arguments parse");
let error = Config::from_args_with(args, token_env).expect_err("selector is required");
assert!(error.to_string().contains("exactly one"));
}
#[test]
fn falls_back_to_environment() {
let args = Args::try_parse_from(["gitea-kanban-tui"]).expect("arguments parse");
let config = Config::from_args_with(args, |name| {
match name {
"GITEA_URL" => Some("https://gitea.example"),
"GITEA_TOKEN" => Some("secret"),
"GITEA_KANBAN_BACKEND" => Some("labels"),
"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.owner, "owner");
}
#[test]
fn reports_missing_token_without_exposing_values() {
let args = Args::try_parse_from([
"gitea-kanban-tui",
"--url",
"https://gitea.example",
"--backend",
"labels",
"owner",
"repo",
])
.expect("arguments parse");
let error = match Config::from_args_with(args, empty_env) {
Ok(_) => panic!("token should be required"),
Err(error) => error,
};
assert!(error.to_string().contains("GITEA_TOKEN_FILE"));
}
}
+7
View File
@@ -0,0 +1,7 @@
pub mod api;
pub mod app;
pub mod config;
pub mod model;
pub mod terminal;
pub mod text;
pub mod ui;
+100
View File
@@ -0,0 +1,100 @@
use std::error::Error;
use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use gitea_kanban_tui::api::{resolve_project, GiteaApi, GiteaClient};
use gitea_kanban_tui::app::{App, MoveAction};
use gitea_kanban_tui::config::{Backend, Config};
use gitea_kanban_tui::terminal::TerminalGuard;
use gitea_kanban_tui::ui;
fn main() {
if let Err(error) = run() {
eprintln!("error: {error}");
std::process::exit(1);
}
}
fn run() -> Result<(), Box<dyn Error>> {
let config = Config::load()?;
let client = GiteaClient::new(&config)?;
let mut app = load_board(&client, &config)?;
let repository = format!("{}/{}", config.owner, config.repo);
let mut terminal = TerminalGuard::enter()?;
loop {
terminal
.terminal()
.draw(|frame| ui::draw(frame, &app, &repository))?;
if !event::poll(Duration::from_millis(250))? {
continue;
}
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
match key.code {
KeyCode::Char('q') => break,
KeyCode::Left | KeyCode::Char('h') => app.focus_left(),
KeyCode::Right | KeyCode::Char('l') => app.focus_right(),
KeyCode::Up | KeyCode::Char('k') => app.focus_up(),
KeyCode::Down | KeyCode::Char('j') => app.focus_down(),
KeyCode::Char('H') => move_card(&client, &mut app, -1),
KeyCode::Char('L') => move_card(&client, &mut app, 1),
KeyCode::Char('r') => match load_board(&client, &config) {
Ok(board) => app = board,
Err(error) => app.status = format!("Refresh failed: {error}"),
},
KeyCode::Char('?') => app.show_help = !app.show_help,
_ => {}
}
}
Ok(())
}
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())
}
}
}
fn move_card(client: &impl GiteaApi, app: &mut App, offset: isize) {
let Some(request) = app.prepare_move(offset) else {
app.status = "Cannot move card beyond board edge".to_owned();
return;
};
let result = match &request.action {
MoveAction::Project {
issue_id,
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),
Err(error) => app.status = format!("Move failed: {error}"),
}
}
+173
View File
@@ -0,0 +1,173 @@
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,
pub number: u64,
pub title: String,
#[serde(default)]
pub body: Option<String>,
#[serde(default)]
pub labels: Vec<Label>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ColumnSpec {
pub id: u64,
pub label: Option<Label>,
pub title: String,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct Project {
pub id: u64,
pub title: String,
#[serde(default)]
pub is_closed: bool,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct ProjectColumn {
pub id: u64,
pub title: String,
#[serde(default)]
pub color: String,
#[serde(default)]
pub sorting: i8,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct MoveProjectIssuePayload {
pub column_id: u64,
#[serde(skip_serializing_if = "Option::is_none")]
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!({
"id": 4,
"title": "Kanban",
"is_closed": false
}))
.expect("project JSON");
let column: ProjectColumn = serde_json::from_value(serde_json::json!({
"id": 9,
"title": "Doing",
"sorting": 1
}))
.expect("column JSON");
let issue: Issue = serde_json::from_value(serde_json::json!({
"id": 70,
"number": 7,
"title": "Fix it"
}))
.expect("issue JSON");
let payload = MoveProjectIssuePayload {
column_id: column.id,
sorting: Some(3),
};
assert_eq!(project.title, "Kanban");
assert_eq!(issue.id, 70);
assert_eq!(
serde_json::to_value(payload).expect("move payload"),
serde_json::json!({"column_id": 9, "sorting": 3})
);
}
}
+45
View File
@@ -0,0 +1,45 @@
use std::io::{self, Stdout};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
pub struct TerminalGuard {
terminal: Terminal<CrosstermBackend<Stdout>>,
}
impl TerminalGuard {
pub fn enter() -> io::Result<Self> {
enable_raw_mode()?;
let mut stdout = io::stdout();
if let Err(error) = execute!(stdout, EnterAlternateScreen) {
let _ = disable_raw_mode();
return Err(error);
}
let backend = CrosstermBackend::new(stdout);
match Terminal::new(backend) {
Ok(terminal) => Ok(Self { terminal }),
Err(error) => {
let mut stdout = io::stdout();
let _ = execute!(stdout, LeaveAlternateScreen);
let _ = disable_raw_mode();
Err(error)
}
}
}
pub fn terminal(&mut self) -> &mut Terminal<CrosstermBackend<Stdout>> {
&mut self.terminal
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
let _ = self.terminal.show_cursor();
}
}
+25
View File
@@ -0,0 +1,25 @@
pub fn sanitize_terminal_text(value: &str) -> String {
value
.chars()
.map(|character| {
if character.is_control() {
' '
} else {
character
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_terminal_control_characters() {
assert_eq!(
sanitize_terminal_text("safe\u{1b}[31m\ntext"),
"safe [31m text"
);
}
}
+133
View File
@@ -0,0 +1,133 @@
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};
use ratatui::Frame;
use crate::app::{App, Column};
use crate::text::sanitize_terminal_text;
pub fn draw(frame: &mut Frame<'_>, app: &App, repository: &str) {
let areas = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(6),
Constraint::Length(4),
Constraint::Length(if app.show_help { 3 } else { 1 }),
])
.split(frame.area());
frame.render_widget(
Paragraph::new(format!(
"Gitea Kanban — {} — {}",
sanitize_terminal_text(repository),
sanitize_terminal_text(&app.board_title)
))
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
areas[0],
);
draw_columns(frame, app, areas[1]);
draw_detail(frame, app, areas[2]);
let help = if app.show_help {
format!(
"{}\n←/h →/l: column ↑/k ↓/j: card H/L: move r: refresh ?: help q: quit",
sanitize_terminal_text(&app.status)
)
} else {
format!(
"{} | ?: help q: quit",
sanitize_terminal_text(&app.status)
)
};
frame.render_widget(Paragraph::new(help).wrap(Wrap { trim: true }), areas[3]);
}
fn draw_columns(frame: &mut Frame<'_>, app: &App, area: Rect) {
let widths = vec![Constraint::Ratio(1, app.columns.len() as u32); app.columns.len()];
let areas = Layout::default()
.direction(Direction::Horizontal)
.constraints(widths)
.split(area);
for (index, column) in app.columns.iter().enumerate() {
draw_column(frame, app, column, index, areas[index]);
}
}
fn draw_column(frame: &mut Frame<'_>, app: &App, column: &Column, index: usize, area: Rect) {
let focused = index == app.focused_column;
let border_style = if focused {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
let items: Vec<ListItem<'_>> = column
.cards
.iter()
.map(|issue| {
ListItem::new(Line::from(vec![
Span::styled(
format!("#{} ", issue.number),
Style::default().fg(Color::DarkGray),
),
Span::raw(sanitize_terminal_text(&issue.title)),
]))
})
.collect();
let list = List::new(items)
.block(
Block::default()
.title(format!(
" {} ({}) ",
sanitize_terminal_text(&column.spec.title),
column.cards.len()
))
.borders(Borders::ALL)
.border_style(border_style),
)
.highlight_style(
Style::default()
.bg(Color::Blue)
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▶ ");
let mut state = ListState::default();
if focused && !column.cards.is_empty() {
state.select(Some(app.focused_cards[index]));
}
frame.render_stateful_widget(list, area, &mut state);
}
fn draw_detail(frame: &mut Frame<'_>, app: &App, area: Rect) {
let text = app
.focused_card()
.map(|issue| {
let body = sanitize_terminal_text(
&issue
.body
.as_deref()
.unwrap_or("No description")
.replace('\n', " "),
);
format!(
"#{} {}\n{}",
issue.number,
sanitize_terminal_text(&issue.title),
body
)
})
.unwrap_or_else(|| "No card in focused column".to_owned());
frame.render_widget(
Paragraph::new(text)
.block(Block::default().title(" Detail ").borders(Borders::ALL))
.wrap(Wrap { trim: true }),
area,
);
}
+15 -12
View File
@@ -18,20 +18,26 @@
let
pname = "gitea";
version = "1.26.2";
version = "1.27.3";
src = ./source;
pnpm = pnpm_10;
pnpmPatches = [ ./pnpm-engine.patch ];
frontend = stdenv.mkDerivation {
pname = "gitea-frontend";
inherit src version;
patches = pnpmPatches;
pnpmDeps = fetchPnpmDeps {
pname = "gitea-frontend";
inherit version src;
inherit pnpm;
patches = pnpmPatches;
fetcherVersion = 3;
hash = "sha256-Qo0DLuZv+2GVLsBfCv/6CC9E/qhSE4HwV4StQL4HX4Y=";
prePnpmInstall = ''
pnpm config set engine-strict false
'';
hash = "sha256-H1sNMKRkoPlkheJFJVaof4bJ4gQHnbhosJaUqj9X8Gg=";
};
nativeBuildInputs = [
@@ -40,6 +46,10 @@ let
pnpm
];
prePnpmInstall = ''
pnpm config set engine-strict false
'';
buildPhase = ''
make frontend
'';
@@ -54,7 +64,8 @@ buildGo126Module rec {
inherit pname version src;
proxyVendor = true;
vendorHash = "sha256-7+M1n8RSgB3gZ/2na4RF9kYOf90H0bnsJZMDKpgAy64=";
deleteVendor = true;
vendorHash = "sha256-YRBMGWKIZgMxOXaXG2bIBj1XzkhSwiMyfRy+yQGw+Bo=";
outputs = [
"out"
@@ -63,17 +74,8 @@ buildGo126Module rec {
patches = [ ./static-root-path.patch ];
overrideModAttrs = _: {
postPatch = ''
substituteInPlace go.mod \
--replace-fail "go 1.26.3" "go 1.26"
'';
};
postPatch = ''
substituteInPlace modules/setting/server.go --subst-var data
substituteInPlace go.mod \
--replace-fail "go 1.26.3" "go 1.26"
'';
subPackages = [ "." ];
@@ -93,6 +95,7 @@ buildGo126Module rec {
];
postInstall = ''
mv "$out/bin/gitea.dev" "$out/bin/gitea"
mkdir $data
ln -s ${frontend}/public $data/public
cp -R ./{templates,options} $data
+13
View File
@@ -0,0 +1,13 @@
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"type": "module",
- "packageManager": "pnpm@11.9.0",
+ "packageManager": "pnpm@10.12.4",
"engines": {
"node": ">= 22.18.0",
- "pnpm": ">= 11.0.0"
+ "pnpm": ">= 10.0.0"
},
@@ -25,28 +25,30 @@ const ScopeSortPrefix = "scope-"
// IssuesOptions represents options of an issue.
type IssuesOptions struct { //nolint:revive // export stutter
Paginator *db.ListOptions
RepoIDs []int64 // overwrites RepoCond if the length is not 0
AllPublic bool // include also all public repositories
RepoCond builder.Cond
AssigneeID string // "(none)" or "(any)" or a user ID
PosterID string // "(none)" or "(any)" or a user ID
MentionedID int64
ReviewRequestedID int64
ReviewedID int64
SubscriberID int64
MilestoneIDs []int64
ProjectIDs []int64
IsClosed optional.Option[bool]
IsPull optional.Option[bool]
LabelIDs []int64
IncludedLabelNames []string
ExcludedLabelNames []string
IncludeMilestones []string
SortType string
IssueIDs []int64
UpdatedAfterUnix int64
UpdatedBeforeUnix int64
Paginator *db.ListOptions
RepoIDs []int64 // overwrites RepoCond if the length is not 0
AllPublic bool // include also all public repositories
RepoCond builder.Cond
AssigneeID string // "(none)" or "(any)" or a user ID
PosterID string // "(none)" or "(any)" or a user ID
MentionedID int64
ReviewRequestedID int64
ReviewedID int64
SubscriberID int64
MilestoneIDs []int64
ProjectIDs []int64
ProjectColumnID int64
ProjectColumnOrUnassigned bool
IsClosed optional.Option[bool]
IsPull optional.Option[bool]
LabelIDs []int64
IncludedLabelNames []string
ExcludedLabelNames []string
IncludeMilestones []string
SortType string
IssueIDs []int64
UpdatedAfterUnix int64
UpdatedBeforeUnix int64
// prioritize issues from this repo
PriorityRepoID int64
IsArchived optional.Option[bool]
@@ -201,7 +203,17 @@ func applyProjectCondition(sess db.Session, opts *IssuesOptions) {
if len(projectIDs) == 1 && projectIDs[0] == db.NoConditionID { // show those that are in no project
sess.And(builder.NotIn("issue.id", builder.Select("issue_id").From("project_issue")))
} else if len(projectIDs) == 1 && projectIDs[0] > 0 { // single specific project
sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id AND project_issue.project_id = ?", projectIDs[0])
joinCondition := "issue.id = project_issue.issue_id AND project_issue.project_id = ?"
joinArgs := []any{projectIDs[0]}
if opts.ProjectColumnID > 0 {
if opts.ProjectColumnOrUnassigned {
joinCondition += " AND project_issue.project_board_id IN (?, 0)"
} else {
joinCondition += " AND project_issue.project_board_id = ?"
}
joinArgs = append(joinArgs, opts.ProjectColumnID)
}
sess.Join("INNER", "project_issue", joinCondition, joinArgs...)
} else if len(projectIDs) > 1 { // multiple projects
// FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: this logic is not right, it should use "AND" but not "OR"
sess.And(builder.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.In("project_id", projectIDs))))
@@ -6,6 +6,7 @@ package project
import (
"context"
"errors"
"math"
"gitea.dev/models/db"
"gitea.dev/modules/util"
@@ -46,6 +47,9 @@ func GetColumnIssueNextSorting(ctx context.Context, projectID, columnID int64) (
Get(&res); err != nil {
return 0, err
}
if res.IssueCount > 0 && res.MaxSorting == math.MaxInt64 {
return 0, util.NewInvalidArgumentErrorf("project column sorting is exhausted")
}
return util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0), nil
}
@@ -31,3 +31,26 @@ type Project struct {
// swagger:strfmt date-time
Closed *time.Time `json:"closed_at,omitempty"`
}
// ProjectColumn represents a column in a project
// swagger:model
type ProjectColumn struct {
// ID is the unique identifier for the column
ID int64 `json:"id"`
// Title is the title of the column
Title string `json:"title"`
// Color is the optional column color
Color string `json:"color"`
// Sorting is the column position
Sorting int8 `json:"sorting"`
}
// MoveProjectIssueOption options for moving an issue inside a project
// swagger:model
type MoveProjectIssueOption struct {
// ColumnID is the destination project column
// required: true
ColumnID int64 `json:"column_id" binding:"Required"`
// Sorting is the optional position inside the destination column
Sorting *int64 `json:"sorting,omitempty"`
}
@@ -799,6 +799,17 @@ func mustEnableWiki(ctx *context.APIContext) {
}
}
func mustEnableRepoProjects(ctx *context.APIContext) {
if unit.TypeProjects.UnitGlobalDisabled() || !ctx.Repo.Permission.CanRead(unit.TypeProjects) {
ctx.APIErrorNotFound()
return
}
projectsUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeProjects)
if !projectsUnit.ProjectsConfig().IsProjectsAllowed(repo_model.ProjectsModeRepo) {
ctx.APIErrorNotFound()
}
}
// FIXME: for consistency, maybe most mustNotBeArchived checks should be replaced with mustEnableEditor
func mustNotBeArchived(ctx *context.APIContext) {
if ctx.Repo.Repository.IsArchived {
@@ -1554,6 +1565,12 @@ func Routes() *web.Router {
m.Get("/issues/search", repo.SearchIssues)
m.Group("/{username}/{reponame}", func() {
m.Group("/projects", func() {
m.Get("", repo.ListProjects)
m.Get("/{id}/columns", repo.ListProjectColumns)
m.Get("/{id}/columns/{column_id}/issues", repo.ListProjectColumnIssues)
m.Post("/{id}/issues/{issue_id}/move", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.MoveProjectIssueOption{}), repo.MoveProjectIssue)
}, reqRepoReader(unit.TypeProjects), mustEnableRepoProjects)
m.Group("/issues", func() {
m.Combo("").Get(repo.ListIssues).
Post(reqToken(), mustNotBeArchived, bind(api.CreateIssueOption{}), reqRepoReader(unit.TypeIssues), repo.CreateIssue)
@@ -0,0 +1,314 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"errors"
"net/http"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
project_model "gitea.dev/models/project"
"gitea.dev/modules/optional"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
"gitea.dev/routers/api/v1/utils"
"gitea.dev/services/context"
"gitea.dev/services/convert"
project_service "gitea.dev/services/projects"
)
func getRepoProject(ctx *context.APIContext) *project_model.Project {
project, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"))
if err != nil {
if project_model.IsErrProjectNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return nil
}
return project
}
func getProjectColumn(ctx *context.APIContext, projectID int64) *project_model.Column {
column, err := project_model.GetColumnByIDAndProjectID(ctx, ctx.PathParamInt64("column_id"), projectID)
if err != nil {
if project_model.IsErrProjectColumnNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return nil
}
return column
}
// ListProjects list a repository's projects
func ListProjects(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/projects project repoListProjects
// ---
// summary: List a repository's projects
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// required: true
// type: string
// - name: repo
// in: path
// required: true
// type: string
// - name: state
// in: query
// type: string
// enum: [open, closed, all]
// default: open
// - name: page
// in: query
// type: integer
// - name: limit
// in: query
// type: integer
// responses:
// "200":
// "$ref": "#/responses/ProjectList"
// "422":
// "$ref": "#/responses/validationError"
// "404":
// "$ref": "#/responses/notFound"
// "500":
// "$ref": "#/responses/internalServerError"
state := ctx.FormString("state")
isClosed := optional.Some(false)
switch state {
case "", "open":
case "closed":
isClosed = optional.Some(true)
case "all":
isClosed = optional.None[bool]()
default:
ctx.APIError(http.StatusUnprocessableEntity, "state must be one of open, closed, or all")
return
}
listOptions := utils.GetListOptions(ctx)
projects, count, err := db.FindAndCount[project_model.Project](ctx, project_model.SearchOptions{
ListOptions: listOptions,
RepoID: ctx.Repo.Repository.ID,
IsClosed: isClosed,
OrderBy: db.SearchOrderByNewest,
Type: project_model.TypeRepository,
})
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.SetLinkHeader(count, listOptions.PageSize)
ctx.SetTotalCountHeader(count)
ctx.JSON(http.StatusOK, convert.ToAPIProjectList(projects))
}
// ListProjectColumns list a repository project's columns
func ListProjectColumns(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/projects/{id}/columns project repoListProjectColumns
// ---
// summary: List a repository project's columns
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// required: true
// type: string
// - name: repo
// in: path
// required: true
// type: string
// - name: id
// in: path
// required: true
// type: integer
// format: int64
// responses:
// "200":
// "$ref": "#/responses/ProjectColumnList"
// "404":
// "$ref": "#/responses/notFound"
// "500":
// "$ref": "#/responses/internalServerError"
project := getRepoProject(ctx)
if project == nil {
return
}
columns, err := project.GetColumns(ctx)
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.SetTotalCountHeader(int64(len(columns)))
ctx.JSON(http.StatusOK, convert.ToAPIProjectColumnList(columns))
}
// ListProjectColumnIssues list issues in a repository project column
func ListProjectColumnIssues(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues project repoListProjectColumnIssues
// ---
// summary: List issues in a repository project column
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// required: true
// type: string
// - name: repo
// in: path
// required: true
// type: string
// - name: id
// in: path
// required: true
// type: integer
// format: int64
// - name: column_id
// in: path
// required: true
// type: integer
// format: int64
// - name: page
// in: query
// type: integer
// - name: limit
// in: query
// type: integer
// responses:
// "200":
// "$ref": "#/responses/IssueList"
// "404":
// "$ref": "#/responses/notFound"
// "500":
// "$ref": "#/responses/internalServerError"
project := getRepoProject(ctx)
if project == nil {
return
}
column := getProjectColumn(ctx, project.ID)
if column == nil {
return
}
listOptions := utils.GetListOptions(ctx)
issueOptions := &issues_model.IssuesOptions{
Paginator: &listOptions,
RepoIDs: []int64{ctx.Repo.Repository.ID},
ProjectIDs: []int64{project.ID},
ProjectColumnID: column.ID,
ProjectColumnOrUnassigned: column.Default,
SortType: "project-column-sorting",
Doer: ctx.Doer,
}
total, err := issues_model.CountIssues(ctx, issueOptions)
if err != nil {
ctx.APIErrorInternal(err)
return
}
issues, err := issues_model.Issues(ctx, issueOptions)
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.SetLinkHeader(total, listOptions.PageSize)
ctx.SetTotalCountHeader(total)
ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, ctx.Doer, issues))
}
// MoveProjectIssue moves an issue to another repository project column
func MoveProjectIssue(ctx *context.APIContext) {
// swagger:operation POST /repos/{owner}/{repo}/projects/{id}/issues/{issue_id}/move project repoMoveProjectIssue
// ---
// summary: Move an issue to another repository project column
// consumes:
// - application/json
// parameters:
// - name: owner
// in: path
// required: true
// type: string
// - name: repo
// in: path
// required: true
// type: string
// - name: id
// in: path
// required: true
// type: integer
// format: int64
// - name: issue_id
// in: path
// required: true
// type: integer
// format: int64
// - name: body
// in: body
// required: true
// schema:
// "$ref": "#/definitions/MoveProjectIssueOption"
// responses:
// "204":
// description: issue moved
// "403":
// "$ref": "#/responses/forbidden"
// "423":
// "$ref": "#/responses/locked"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
// "500":
// "$ref": "#/responses/internalServerError"
project := getRepoProject(ctx)
if project == nil {
return
}
if project.IsClosed {
ctx.APIError(http.StatusForbidden, "project is closed")
return
}
form := web.GetForm(ctx).(*api.MoveProjectIssueOption)
column, err := project_model.GetColumnByIDAndProjectID(ctx, form.ColumnID, project.ID)
if err != nil {
if project_model.IsErrProjectColumnNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return
}
issueID := ctx.PathParamInt64("issue_id")
if _, err := issues_model.GetIssueByRepoID(ctx, ctx.Repo.Repository.ID, issueID); err != nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return
}
if err := project_service.MoveIssueOnProjectColumn(ctx, ctx.Doer, column, issueID, form.Sorting); err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.APIErrorNotFound()
} else if errors.Is(err, util.ErrInvalidArgument) {
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
} else {
ctx.APIErrorInternal(err)
}
return
}
ctx.Status(http.StatusNoContent)
}
@@ -48,6 +48,8 @@ type swaggerParameterBodies struct {
// in:body
IssueLabelsOption api.IssueLabelsOption
// in:body
MoveProjectIssueOption api.MoveProjectIssueOption
// in:body
CreateKeyOption api.CreateKeyOption
@@ -0,0 +1,20 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package swagger
import api "gitea.dev/modules/structs"
// ProjectList
// swagger:response ProjectList
type swaggerResponseProjectList struct {
// in:body
Body []api.Project `json:"body"`
}
// ProjectColumnList
// swagger:response ProjectColumnList
type swaggerResponseProjectColumnList struct {
// in:body
Body []api.ProjectColumn `json:"body"`
}
@@ -35,3 +35,22 @@ func ToAPIProjectList(projects []*project_model.Project) []*api.Project {
}
return result
}
// ToAPIProjectColumn converts a project column to API format
func ToAPIProjectColumn(column *project_model.Column) *api.ProjectColumn {
return &api.ProjectColumn{
ID: column.ID,
Title: column.Title,
Color: column.Color,
Sorting: column.Sorting,
}
}
// ToAPIProjectColumnList converts project columns to API format
func ToAPIProjectColumnList(columns project_model.ColumnList) []*api.ProjectColumn {
result := make([]*api.ProjectColumn, len(columns))
for i := range columns {
result[i] = ToAPIProjectColumn(columns[i])
}
return result
}
+135 -63
View File
@@ -5,102 +5,174 @@ package project
import (
"context"
"errors"
"math"
"slices"
"strings"
"sync"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
project_model "gitea.dev/models/project"
user_model "gitea.dev/models/user"
"gitea.dev/modules/optional"
"gitea.dev/modules/util"
"xorm.io/builder"
)
var projectIssueMoveMu sync.Mutex
// MoveIssuesOnProjectColumn moves or keeps issues in a column and sorts them inside that column
func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, column *project_model.Column, sortedIssueIDs map[int64]int64) error {
return db.WithTx(ctx, func(ctx context.Context) error {
issueIDs := make([]int64, 0, len(sortedIssueIDs))
for _, issueID := range sortedIssueIDs {
issueIDs = append(issueIDs, issueID)
}
count, err := db.GetEngine(ctx).
Where("project_id=?", column.ProjectID).
In("issue_id", issueIDs).
Count(new(project_model.ProjectIssue))
return moveIssuesOnProjectColumn(ctx, doer, column, sortedIssueIDs)
})
}
// MoveIssueOnProjectColumn appends or positions one issue atomically.
func MoveIssueOnProjectColumn(ctx context.Context, doer *user_model.User, column *project_model.Column, issueID int64, sorting *int64) error {
// Serialize reorder calculations so concurrent requests cannot allocate the same sorting value.
projectIssueMoveMu.Lock()
defer projectIssueMoveMu.Unlock()
return db.WithTx(ctx, func(ctx context.Context) error {
nextSorting, err := project_model.GetColumnIssueNextSorting(ctx, column.ProjectID, column.ID)
if err != nil {
return err
}
if int(count) != len(sortedIssueIDs) {
return errors.New("all issues have to be added to a project first")
position := nextSorting
if sorting != nil {
position = *sorting
}
issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs)
if position < 0 || position == math.MaxInt64 {
return util.NewInvalidArgumentErrorf("sorting must be between 0 and %d", math.MaxInt64-1)
}
current := &project_model.ProjectIssue{}
exists, err := db.GetEngine(ctx).
Where("project_id=? AND issue_id=?", column.ProjectID, issueID).
Get(current)
if err != nil {
return err
}
if _, err := issues.LoadRepositories(ctx); err != nil {
return err
if !exists {
return util.NewInvalidArgumentErrorf("issue has to be added to project first")
}
project, err := project_model.GetProjectByID(ctx, column.ProjectID)
if err != nil {
return err
}
issuesMap := make(map[int64]*issues_model.Issue, len(issues))
for _, issue := range issues {
issuesMap[issue.ID] = issue
}
for sorting, issueID := range sortedIssueIDs {
curIssue := issuesMap[issueID]
if curIssue == nil {
continue
if current.ProjectColumnID == column.ID {
if position >= nextSorting {
position = nextSorting - 1
}
projectColumnMap, err := curIssue.ProjectColumnMap(ctx)
if err != nil {
return err
}
projectColumnID := projectColumnMap[column.ProjectID]
if projectColumnID != column.ID {
// add timeline to issue
if _, err := issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
Type: issues_model.CommentTypeProjectColumn,
Doer: doer,
Repo: curIssue.Repo,
Issue: curIssue,
ProjectID: column.ProjectID,
ProjectTitle: project.Title,
ProjectColumnID: column.ID,
ProjectColumnTitle: column.Title,
}); err != nil {
if current.Sorting < position {
if _, err := db.GetEngine(ctx).
Where("project_id=? AND project_board_id=? AND sorting>? AND sorting<=?", column.ProjectID, column.ID, current.Sorting, position).
SetExpr("sorting", "sorting - 1").Update(new(project_model.ProjectIssue)); err != nil {
return err
}
} else if current.Sorting > position {
if _, err := db.GetEngine(ctx).
Where("project_id=? AND project_board_id=? AND sorting>=? AND sorting<?", column.ProjectID, column.ID, position, current.Sorting).
SetExpr("sorting", "sorting + 1").Update(new(project_model.ProjectIssue)); err != nil {
return err
}
}
// Update the column and sorting for this specific issue in this specific project.
// IMPORTANT: The WHERE clause must include both issue_id AND project_id to ensure
// that moving an issue's column in one project doesn't affect its column in other
// projects when the issue is assigned to multiple projects.
_, err = db.GetEngine(ctx).Table("project_issue").
Where("issue_id = ? AND project_id = ?", issueID, column.ProjectID).
Update(map[string]any{
"project_board_id": column.ID,
"sorting": sorting,
})
if err != nil {
} else if position > nextSorting {
position = nextSorting
}
if current.ProjectColumnID != column.ID {
if _, err := db.GetEngine(ctx).
Where("project_id=? AND project_board_id=? AND sorting>=?", column.ProjectID, column.ID, position).
SetExpr("sorting", "sorting + 1").Update(new(project_model.ProjectIssue)); err != nil {
return err
}
}
return nil
return moveIssuesOnProjectColumn(ctx, doer, column, map[int64]int64{position: issueID})
})
}
func moveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, column *project_model.Column, sortedIssueIDs map[int64]int64) error {
for sorting := range sortedIssueIDs {
if sorting < 0 || sorting == math.MaxInt64 {
return util.NewInvalidArgumentErrorf("sorting must be between 0 and %d", math.MaxInt64-1)
}
}
issueIDs := make([]int64, 0, len(sortedIssueIDs))
for _, issueID := range sortedIssueIDs {
issueIDs = append(issueIDs, issueID)
}
count, err := db.GetEngine(ctx).
Where("project_id=?", column.ProjectID).
In("issue_id", issueIDs).
Count(new(project_model.ProjectIssue))
if err != nil {
return err
}
if int(count) != len(sortedIssueIDs) {
return util.NewInvalidArgumentErrorf("all issues have to be added to a project first")
}
issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs)
if err != nil {
return err
}
if _, err := issues.LoadRepositories(ctx); err != nil {
return err
}
project, err := project_model.GetProjectByID(ctx, column.ProjectID)
if err != nil {
return err
}
issuesMap := make(map[int64]*issues_model.Issue, len(issues))
for _, issue := range issues {
issuesMap[issue.ID] = issue
}
for sorting, issueID := range sortedIssueIDs {
curIssue := issuesMap[issueID]
if curIssue == nil {
continue
}
projectColumnMap, err := curIssue.ProjectColumnMap(ctx)
if err != nil {
return err
}
projectColumnID := projectColumnMap[column.ProjectID]
if projectColumnID != column.ID {
// add timeline to issue
if _, err := issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
Type: issues_model.CommentTypeProjectColumn,
Doer: doer,
Repo: curIssue.Repo,
Issue: curIssue,
ProjectID: column.ProjectID,
ProjectTitle: project.Title,
ProjectColumnID: column.ID,
ProjectColumnTitle: column.Title,
}); err != nil {
return err
}
}
// Update the column and sorting for this specific issue in this specific project.
// IMPORTANT: The WHERE clause must include both issue_id AND project_id to ensure
// that moving an issue's column in one project doesn't affect its column in other
// projects when the issue is assigned to multiple projects.
_, err = db.GetEngine(ctx).Table("project_issue").
Where("issue_id = ? AND project_id = ?", issueID, column.ProjectID).
Update(map[string]any{
"project_board_id": column.ID,
"sorting": sorting,
})
if err != nil {
return err
}
}
return nil
}
func LoadIssuesAssigneesForProject(ctx context.Context, projectID int64) (users []*user_model.User, _ error) {
sub := builder.Select("distinct issue_assignees.assignee_id").
From("project_issue").Join("INNER", "issue_assignees", "project_issue.issue_id=issue_assignees.issue_id").
+287 -2
View File
@@ -19,7 +19,7 @@
},
"version": "{{.SwaggerAppVer}}"
},
"basePath": "{{.SwaggerAppSubUrl}}/api/v1",
"basePath": "/{{.SwaggerAppSubUrl}}/api/v1",
"paths": {
"/admin/actions/jobs": {
"get": {
@@ -14354,6 +14354,222 @@
}
}
},
"/repos/{owner}/{repo}/projects": {
"get": {
"produces": [
"application/json"
],
"tags": [
"project"
],
"summary": "List a repository's projects",
"operationId": "repoListProjects",
"parameters": [
{
"type": "string",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"name": "repo",
"in": "path",
"required": true
},
{
"enum": [
"open",
"closed",
"all"
],
"type": "string",
"default": "open",
"name": "state",
"in": "query"
},
{
"type": "integer",
"name": "page",
"in": "query"
},
{
"type": "integer",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"$ref": "#/responses/ProjectList"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/repos/{owner}/{repo}/projects/{id}/columns": {
"get": {
"produces": [
"application/json"
],
"tags": [
"project"
],
"summary": "List a repository project's columns",
"operationId": "repoListProjectColumns",
"parameters": [
{
"type": "string",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"name": "repo",
"in": "path",
"required": true
},
{
"type": "integer",
"format": "int64",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/ProjectColumnList"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues": {
"get": {
"produces": [
"application/json"
],
"tags": [
"project"
],
"summary": "List issues in a repository project column",
"operationId": "repoListProjectColumnIssues",
"parameters": [
{
"type": "string",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"name": "repo",
"in": "path",
"required": true
},
{
"type": "integer",
"format": "int64",
"name": "id",
"in": "path",
"required": true
},
{
"type": "integer",
"format": "int64",
"name": "column_id",
"in": "path",
"required": true
},
{
"type": "integer",
"name": "page",
"in": "query"
},
{
"type": "integer",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"$ref": "#/responses/IssueList"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/repos/{owner}/{repo}/projects/{id}/issues/{issue_id}/move": {
"post": {
"consumes": [
"application/json"
],
"tags": [
"project"
],
"summary": "Move an issue to another repository project column",
"operationId": "repoMoveProjectIssue",
"parameters": [
{
"type": "string",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"name": "repo",
"in": "path",
"required": true
},
{
"type": "integer",
"format": "int64",
"name": "id",
"in": "path",
"required": true
},
{
"type": "integer",
"format": "int64",
"name": "issue_id",
"in": "path",
"required": true
},
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/MoveProjectIssueOption"
}
}
],
"responses": {
"204": {
"description": "issue moved"
},
"403": {
"$ref": "#/responses/forbidden"
},
"404": {
"$ref": "#/responses/notFound"
},
"422": {
"$ref": "#/responses/validationError"
}
}
}
},
"/repos/{owner}/{repo}/pulls": {
"get": {
"produces": [
@@ -27941,6 +28157,28 @@
},
"x-go-package": "gitea.dev/modules/structs"
},
"MoveProjectIssueOption": {
"description": "MoveProjectIssueOption options for moving an issue inside a project",
"type": "object",
"required": [
"column_id"
],
"properties": {
"column_id": {
"description": "ColumnID is the destination project column",
"type": "integer",
"format": "int64",
"x-go-name": "ColumnID"
},
"sorting": {
"description": "Sorting is the optional position inside the destination column",
"type": "integer",
"format": "int64",
"x-go-name": "Sorting"
}
},
"x-go-package": "gitea.dev/modules/structs"
},
"NewIssuePinsAllowed": {
"description": "NewIssuePinsAllowed represents an API response that says if new Issue Pins are allowed",
"type": "object",
@@ -28682,6 +28920,35 @@
},
"x-go-package": "gitea.dev/modules/structs"
},
"ProjectColumn": {
"description": "ProjectColumn represents a column in a project",
"type": "object",
"properties": {
"color": {
"description": "Color is the optional column color",
"type": "string",
"x-go-name": "Color"
},
"id": {
"description": "ID is the unique identifier for the column",
"type": "integer",
"format": "int64",
"x-go-name": "ID"
},
"sorting": {
"description": "Sorting is the column position",
"type": "integer",
"format": "int8",
"x-go-name": "Sorting"
},
"title": {
"description": "Title is the title of the column",
"type": "string",
"x-go-name": "Title"
}
},
"x-go-package": "gitea.dev/modules/structs"
},
"PublicKey": {
"description": "PublicKey publickey is a user key to push code to repository",
"type": "object",
@@ -31645,6 +31912,24 @@
}
}
},
"ProjectColumnList": {
"description": "ProjectColumnList",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/ProjectColumn"
}
}
},
"ProjectList": {
"description": "ProjectList",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/Project"
}
}
},
"PublicKey": {
"description": "PublicKey",
"schema": {
@@ -32205,4 +32490,4 @@
"TOTPHeader": []
}
]
}
}
@@ -990,6 +990,32 @@
},
"description": "PackageList"
},
"ProjectColumnList": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/ProjectColumn"
},
"type": "array"
}
}
},
"description": "ProjectColumnList"
},
"ProjectList": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/Project"
},
"type": "array"
}
}
},
"description": "ProjectList"
},
"PublicKey": {
"content": {
"application/json": {
@@ -7705,6 +7731,28 @@
"type": "object",
"x-go-package": "gitea.dev/modules/structs"
},
"MoveProjectIssueOption": {
"description": "MoveProjectIssueOption options for moving an issue inside a project",
"properties": {
"column_id": {
"description": "ColumnID is the destination project column",
"format": "int64",
"type": "integer",
"x-go-name": "ColumnID"
},
"sorting": {
"description": "Sorting is the optional position inside the destination column",
"format": "int64",
"type": "integer",
"x-go-name": "Sorting"
}
},
"required": [
"column_id"
],
"type": "object",
"x-go-package": "gitea.dev/modules/structs"
},
"NewIssuePinsAllowed": {
"description": "NewIssuePinsAllowed represents an API response that says if new Issue Pins are allowed",
"properties": {
@@ -8459,6 +8507,35 @@
"type": "object",
"x-go-package": "gitea.dev/modules/structs"
},
"ProjectColumn": {
"description": "ProjectColumn represents a column in a project",
"properties": {
"color": {
"description": "Color is the optional column color",
"type": "string",
"x-go-name": "Color"
},
"id": {
"description": "ID is the unique identifier for the column",
"format": "int64",
"type": "integer",
"x-go-name": "ID"
},
"sorting": {
"description": "Sorting is the column position",
"format": "int8",
"type": "integer",
"x-go-name": "Sorting"
},
"title": {
"description": "Title is the title of the column",
"type": "string",
"x-go-name": "Title"
}
},
"type": "object",
"x-go-package": "gitea.dev/modules/structs"
},
"PublicKey": {
"description": "PublicKey publickey is a user key to push code to repository",
"properties": {
@@ -10583,6 +10660,10 @@
"type": "boolean",
"x-go-name": "HideEmail"
},
"include_private_contributions": {
"type": "boolean",
"x-go-name": "IncludePrivateContributions"
},
"language": {
"type": "string",
"x-go-name": "Language"
@@ -10627,6 +10708,10 @@
"type": "boolean",
"x-go-name": "HideEmail"
},
"include_private_contributions": {
"type": "boolean",
"x-go-name": "IncludePrivateContributions"
},
"language": {
"type": "string",
"x-go-name": "Language"
@@ -26239,6 +26324,249 @@
]
}
},
"/repos/{owner}/{repo}/projects": {
"get": {
"operationId": "repoListProjects",
"parameters": [
{
"in": "path",
"name": "owner",
"required": true,
"schema": {
"type": "string"
}
},
{
"in": "path",
"name": "repo",
"required": true,
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "state",
"schema": {
"default": "open",
"enum": [
"open",
"closed",
"all"
],
"type": "string"
}
},
{
"in": "query",
"name": "page",
"schema": {
"type": "integer"
}
},
{
"in": "query",
"name": "limit",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"$ref": "#/components/responses/ProjectList"
},
"404": {
"$ref": "#/components/responses/notFound"
}
},
"summary": "List a repository's projects",
"tags": [
"project"
]
}
},
"/repos/{owner}/{repo}/projects/{id}/columns": {
"get": {
"operationId": "repoListProjectColumns",
"parameters": [
{
"in": "path",
"name": "owner",
"required": true,
"schema": {
"type": "string"
}
},
{
"in": "path",
"name": "repo",
"required": true,
"schema": {
"type": "string"
}
},
{
"in": "path",
"name": "id",
"required": true,
"schema": {
"format": "int64",
"type": "integer"
}
}
],
"responses": {
"200": {
"$ref": "#/components/responses/ProjectColumnList"
},
"404": {
"$ref": "#/components/responses/notFound"
}
},
"summary": "List a repository project's columns",
"tags": [
"project"
]
}
},
"/repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues": {
"get": {
"operationId": "repoListProjectColumnIssues",
"parameters": [
{
"in": "path",
"name": "owner",
"required": true,
"schema": {
"type": "string"
}
},
{
"in": "path",
"name": "repo",
"required": true,
"schema": {
"type": "string"
}
},
{
"in": "path",
"name": "id",
"required": true,
"schema": {
"format": "int64",
"type": "integer"
}
},
{
"in": "path",
"name": "column_id",
"required": true,
"schema": {
"format": "int64",
"type": "integer"
}
},
{
"in": "query",
"name": "page",
"schema": {
"type": "integer"
}
},
{
"in": "query",
"name": "limit",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"$ref": "#/components/responses/IssueList"
},
"404": {
"$ref": "#/components/responses/notFound"
}
},
"summary": "List issues in a repository project column",
"tags": [
"project"
]
}
},
"/repos/{owner}/{repo}/projects/{id}/issues/{issue_id}/move": {
"post": {
"operationId": "repoMoveProjectIssue",
"parameters": [
{
"in": "path",
"name": "owner",
"required": true,
"schema": {
"type": "string"
}
},
{
"in": "path",
"name": "repo",
"required": true,
"schema": {
"type": "string"
}
},
{
"in": "path",
"name": "id",
"required": true,
"schema": {
"format": "int64",
"type": "integer"
}
},
{
"in": "path",
"name": "issue_id",
"required": true,
"schema": {
"format": "int64",
"type": "integer"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MoveProjectIssueOption"
}
}
},
"required": true,
"x-originalParamName": "body"
},
"responses": {
"204": {
"description": "issue moved"
},
"403": {
"$ref": "#/components/responses/forbidden"
},
"404": {
"$ref": "#/components/responses/notFound"
},
"422": {
"$ref": "#/components/responses/validationError"
}
},
"summary": "Move an issue to another repository project column",
"tags": [
"project"
]
}
},
"/repos/{owner}/{repo}/pulls": {
"get": {
"operationId": "repoListPullRequests",
@@ -0,0 +1,177 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"math"
"net/http"
"testing"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/db"
project_model "gitea.dev/models/project"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
api "gitea.dev/modules/structs"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAPIRepositoryProjects(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
ownerSession := loginUser(t, repo.OwnerName)
readToken := getTokenForLoggedInUser(t, ownerSession, auth_model.AccessTokenScopeReadIssue)
writeToken := getTokenForLoggedInUser(t, ownerSession, auth_model.AccessTokenScopeWriteIssue)
baseURL := fmt.Sprintf("/api/v1/repos/%s/projects", repo.FullName())
t.Run("List", func(t *testing.T) {
req := NewRequest(t, "GET", baseURL).AddTokenAuth(readToken)
resp := MakeRequest(t, req, http.StatusOK)
projects := DecodeJSON(t, resp, &[]api.Project{})
require.Len(t, *projects, 1)
assert.Equal(t, int64(1), (*projects)[0].ID)
assert.Equal(t, "First project", (*projects)[0].Title)
})
t.Run("Columns", func(t *testing.T) {
req := NewRequest(t, "GET", baseURL+"/1/columns").AddTokenAuth(readToken)
resp := MakeRequest(t, req, http.StatusOK)
columns := DecodeJSON(t, resp, &[]api.ProjectColumn{})
require.Len(t, *columns, 3)
assert.Equal(t, int64(1), (*columns)[0].ID)
assert.Equal(t, "To Do", (*columns)[0].Title)
})
t.Run("ColumnIssues", func(t *testing.T) {
req := NewRequest(t, "GET", baseURL+"/1/columns/2/issues").AddTokenAuth(readToken)
resp := MakeRequest(t, req, http.StatusOK)
issues := DecodeJSON(t, resp, &[]api.Issue{})
require.Len(t, *issues, 1)
assert.Equal(t, int64(3), (*issues)[0].ID)
})
t.Run("ColumnIssuesPaginationAndDefault", func(t *testing.T) {
req := NewRequest(t, "GET", baseURL+"/1/columns/1/issues?limit=1&page=1").AddTokenAuth(readToken)
resp := MakeRequest(t, req, http.StatusOK)
issues := DecodeJSON(t, resp, &[]api.Issue{})
require.Len(t, *issues, 1)
assert.Equal(t, "2", resp.Header().Get("X-Total-Count"))
req = NewRequest(t, "GET", baseURL+"/1/columns/1/issues?limit=1&page=2").AddTokenAuth(readToken)
resp = MakeRequest(t, req, http.StatusOK)
issues = DecodeJSON(t, resp, &[]api.Issue{})
require.Len(t, *issues, 1)
assert.NotEqual(t, (*issues)[0].ID, int64(0))
})
t.Run("CrossRepositoryProject", func(t *testing.T) {
req := NewRequest(t, "GET", baseURL+"/2/columns").AddTokenAuth(readToken)
MakeRequest(t, req, http.StatusNotFound)
})
t.Run("CrossProjectColumn", func(t *testing.T) {
req := NewRequest(t, "GET", baseURL+"/1/columns/5/issues").AddTokenAuth(readToken)
MakeRequest(t, req, http.StatusNotFound)
})
t.Run("RejectsSortingOverflow", func(t *testing.T) {
projectIssue := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: 1, IssueID: 3})
sorting := int64(math.MaxInt64)
req := NewRequestWithJSON(t, "POST", baseURL+"/1/issues/3/move", api.MoveProjectIssueOption{
ColumnID: 3,
Sorting: &sorting,
}).AddTokenAuth(writeToken)
MakeRequest(t, req, http.StatusUnprocessableEntity)
unchanged := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: 1, IssueID: 3})
assert.Equal(t, projectIssue.ProjectColumnID, unchanged.ProjectColumnID)
assert.Equal(t, projectIssue.Sorting, unchanged.Sorting)
})
t.Run("ReordersWithinColumn", func(t *testing.T) {
sorting := int64(0)
req := NewRequestWithJSON(t, "POST", baseURL+"/1/issues/3/move", api.MoveProjectIssueOption{
ColumnID: 1,
Sorting: &sorting,
}).AddTokenAuth(writeToken)
MakeRequest(t, req, http.StatusNoContent)
sorting = 2
req = NewRequestWithJSON(t, "POST", baseURL+"/1/issues/3/move", api.MoveProjectIssueOption{
ColumnID: 1,
Sorting: &sorting,
}).AddTokenAuth(writeToken)
MakeRequest(t, req, http.StatusNoContent)
var projectIssues []project_model.ProjectIssue
err := db.GetEngine(t.Context()).
Where("project_id=? AND project_board_id=?", 1, 1).
OrderBy("sorting").Find(&projectIssues)
require.NoError(t, err)
require.Len(t, projectIssues, 2)
assert.Equal(t, int64(3), projectIssues[1].IssueID)
assert.Equal(t, []int64{0, 1}, []int64{projectIssues[0].Sorting, projectIssues[1].Sorting})
})
t.Run("Move", func(t *testing.T) {
req := NewRequestWithJSON(t, "POST", baseURL+"/1/issues/3/move", api.MoveProjectIssueOption{
ColumnID: 3,
}).AddTokenAuth(writeToken)
MakeRequest(t, req, http.StatusNoContent)
projectIssue := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: 1, IssueID: 3})
assert.Equal(t, int64(3), projectIssue.ProjectColumnID)
})
t.Run("UnassignedIssue", func(t *testing.T) {
req := NewRequestWithJSON(t, "POST", baseURL+"/1/issues/11/move", api.MoveProjectIssueOption{
ColumnID: 3,
}).AddTokenAuth(writeToken)
MakeRequest(t, req, http.StatusUnprocessableEntity)
})
t.Run("Permission", func(t *testing.T) {
outsiderSession := loginUser(t, "user5")
outsiderToken := getTokenForLoggedInUser(t, outsiderSession, auth_model.AccessTokenScopeWriteIssue)
req := NewRequestWithJSON(t, "POST", baseURL+"/1/issues/3/move", api.MoveProjectIssueOption{
ColumnID: 2,
}).AddTokenAuth(outsiderToken)
MakeRequest(t, req, http.StatusForbidden)
})
t.Run("ClosedProject", func(t *testing.T) {
project := unittest.AssertExistsAndLoadBean(t, &project_model.Project{ID: 1})
require.NoError(t, project_model.ChangeProjectStatus(t.Context(), project, true))
defer func() {
require.NoError(t, project_model.ChangeProjectStatus(t.Context(), project, false))
}()
req := NewRequestWithJSON(t, "POST", baseURL+"/1/issues/3/move", api.MoveProjectIssueOption{
ColumnID: 2,
}).AddTokenAuth(writeToken)
MakeRequest(t, req, http.StatusForbidden)
})
t.Run("ArchivedRepository", func(t *testing.T) {
_, err := db.GetEngine(t.Context()).ID(repo.ID).Cols("is_archived").Update(&repo_model.Repository{IsArchived: true})
require.NoError(t, err)
defer func() {
_, err := db.GetEngine(t.Context()).ID(repo.ID).Cols("is_archived").Update(&repo_model.Repository{IsArchived: false})
require.NoError(t, err)
}()
req := NewRequestWithJSON(t, "POST", baseURL+"/1/issues/3/move", api.MoveProjectIssueOption{
ColumnID: 2,
}).AddTokenAuth(writeToken)
MakeRequest(t, req, http.StatusLocked)
})
t.Run("DisabledProjects", func(t *testing.T) {
disabledSession := loginUser(t, "user5")
disabledToken := getTokenForLoggedInUser(t, disabledSession, auth_model.AccessTokenScopeReadIssue)
req := NewRequest(t, "GET", "/api/v1/repos/user5/repo4/projects").AddTokenAuth(disabledToken)
MakeRequest(t, req, http.StatusNotFound)
})
}