This commit is contained in:
@@ -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; };
|
||||
|
||||
Generated
+1453
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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
|
||||
```
|
||||
@@ -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";
|
||||
};
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}"),
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user