This commit is contained in:
@@ -149,6 +149,7 @@ in {
|
|||||||
deploy = pkgs.callPackage ./deploy { inherit inputs; };
|
deploy = pkgs.callPackage ./deploy { inherit inputs; };
|
||||||
element-web = pkgs.callPackage ./element-web {};
|
element-web = pkgs.callPackage ./element-web {};
|
||||||
shellplot = pkgs.callPackage ./shellplot {};
|
shellplot = pkgs.callPackage ./shellplot {};
|
||||||
|
gitea-kanban-tui = pkgs.callPackage ./gitea-kanban-tui rust.commonArgs;
|
||||||
which-country-rs = pkgs.callPackage ./which-country-rs {};
|
which-country-rs = pkgs.callPackage ./which-country-rs {};
|
||||||
onlinepubs2man = pkgs.callPackage ./onlinepubs2man {};
|
onlinepubs2man = pkgs.callPackage ./onlinepubs2man {};
|
||||||
migrator = pkgs.callPackage ./migrator { inherit self; };
|
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
|
let
|
||||||
pname = "gitea";
|
pname = "gitea";
|
||||||
version = "1.26.2";
|
version = "1.27.3";
|
||||||
src = ./source;
|
src = ./source;
|
||||||
pnpm = pnpm_10;
|
pnpm = pnpm_10;
|
||||||
|
pnpmPatches = [ ./pnpm-engine.patch ];
|
||||||
|
|
||||||
frontend = stdenv.mkDerivation {
|
frontend = stdenv.mkDerivation {
|
||||||
pname = "gitea-frontend";
|
pname = "gitea-frontend";
|
||||||
inherit src version;
|
inherit src version;
|
||||||
|
patches = pnpmPatches;
|
||||||
|
|
||||||
pnpmDeps = fetchPnpmDeps {
|
pnpmDeps = fetchPnpmDeps {
|
||||||
pname = "gitea-frontend";
|
pname = "gitea-frontend";
|
||||||
inherit version src;
|
inherit version src;
|
||||||
inherit pnpm;
|
inherit pnpm;
|
||||||
|
patches = pnpmPatches;
|
||||||
fetcherVersion = 3;
|
fetcherVersion = 3;
|
||||||
hash = "sha256-Qo0DLuZv+2GVLsBfCv/6CC9E/qhSE4HwV4StQL4HX4Y=";
|
prePnpmInstall = ''
|
||||||
|
pnpm config set engine-strict false
|
||||||
|
'';
|
||||||
|
hash = "sha256-H1sNMKRkoPlkheJFJVaof4bJ4gQHnbhosJaUqj9X8Gg=";
|
||||||
};
|
};
|
||||||
|
|
||||||
nativeBuildInputs = [
|
nativeBuildInputs = [
|
||||||
@@ -40,6 +46,10 @@ let
|
|||||||
pnpm
|
pnpm
|
||||||
];
|
];
|
||||||
|
|
||||||
|
prePnpmInstall = ''
|
||||||
|
pnpm config set engine-strict false
|
||||||
|
'';
|
||||||
|
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
make frontend
|
make frontend
|
||||||
'';
|
'';
|
||||||
@@ -54,7 +64,8 @@ buildGo126Module rec {
|
|||||||
inherit pname version src;
|
inherit pname version src;
|
||||||
|
|
||||||
proxyVendor = true;
|
proxyVendor = true;
|
||||||
vendorHash = "sha256-7+M1n8RSgB3gZ/2na4RF9kYOf90H0bnsJZMDKpgAy64=";
|
deleteVendor = true;
|
||||||
|
vendorHash = "sha256-YRBMGWKIZgMxOXaXG2bIBj1XzkhSwiMyfRy+yQGw+Bo=";
|
||||||
|
|
||||||
outputs = [
|
outputs = [
|
||||||
"out"
|
"out"
|
||||||
@@ -63,17 +74,8 @@ buildGo126Module rec {
|
|||||||
|
|
||||||
patches = [ ./static-root-path.patch ];
|
patches = [ ./static-root-path.patch ];
|
||||||
|
|
||||||
overrideModAttrs = _: {
|
|
||||||
postPatch = ''
|
|
||||||
substituteInPlace go.mod \
|
|
||||||
--replace-fail "go 1.26.3" "go 1.26"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
postPatch = ''
|
postPatch = ''
|
||||||
substituteInPlace modules/setting/server.go --subst-var data
|
substituteInPlace modules/setting/server.go --subst-var data
|
||||||
substituteInPlace go.mod \
|
|
||||||
--replace-fail "go 1.26.3" "go 1.26"
|
|
||||||
'';
|
'';
|
||||||
|
|
||||||
subPackages = [ "." ];
|
subPackages = [ "." ];
|
||||||
@@ -93,6 +95,7 @@ buildGo126Module rec {
|
|||||||
];
|
];
|
||||||
|
|
||||||
postInstall = ''
|
postInstall = ''
|
||||||
|
mv "$out/bin/gitea.dev" "$out/bin/gitea"
|
||||||
mkdir $data
|
mkdir $data
|
||||||
ln -s ${frontend}/public $data/public
|
ln -s ${frontend}/public $data/public
|
||||||
cp -R ./{templates,options} $data
|
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.
|
// IssuesOptions represents options of an issue.
|
||||||
type IssuesOptions struct { //nolint:revive // export stutter
|
type IssuesOptions struct { //nolint:revive // export stutter
|
||||||
Paginator *db.ListOptions
|
Paginator *db.ListOptions
|
||||||
RepoIDs []int64 // overwrites RepoCond if the length is not 0
|
RepoIDs []int64 // overwrites RepoCond if the length is not 0
|
||||||
AllPublic bool // include also all public repositories
|
AllPublic bool // include also all public repositories
|
||||||
RepoCond builder.Cond
|
RepoCond builder.Cond
|
||||||
AssigneeID string // "(none)" or "(any)" or a user ID
|
AssigneeID string // "(none)" or "(any)" or a user ID
|
||||||
PosterID string // "(none)" or "(any)" or a user ID
|
PosterID string // "(none)" or "(any)" or a user ID
|
||||||
MentionedID int64
|
MentionedID int64
|
||||||
ReviewRequestedID int64
|
ReviewRequestedID int64
|
||||||
ReviewedID int64
|
ReviewedID int64
|
||||||
SubscriberID int64
|
SubscriberID int64
|
||||||
MilestoneIDs []int64
|
MilestoneIDs []int64
|
||||||
ProjectIDs []int64
|
ProjectIDs []int64
|
||||||
IsClosed optional.Option[bool]
|
ProjectColumnID int64
|
||||||
IsPull optional.Option[bool]
|
ProjectColumnOrUnassigned bool
|
||||||
LabelIDs []int64
|
IsClosed optional.Option[bool]
|
||||||
IncludedLabelNames []string
|
IsPull optional.Option[bool]
|
||||||
ExcludedLabelNames []string
|
LabelIDs []int64
|
||||||
IncludeMilestones []string
|
IncludedLabelNames []string
|
||||||
SortType string
|
ExcludedLabelNames []string
|
||||||
IssueIDs []int64
|
IncludeMilestones []string
|
||||||
UpdatedAfterUnix int64
|
SortType string
|
||||||
UpdatedBeforeUnix int64
|
IssueIDs []int64
|
||||||
|
UpdatedAfterUnix int64
|
||||||
|
UpdatedBeforeUnix int64
|
||||||
// prioritize issues from this repo
|
// prioritize issues from this repo
|
||||||
PriorityRepoID int64
|
PriorityRepoID int64
|
||||||
IsArchived optional.Option[bool]
|
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
|
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")))
|
sess.And(builder.NotIn("issue.id", builder.Select("issue_id").From("project_issue")))
|
||||||
} else if len(projectIDs) == 1 && projectIDs[0] > 0 { // single specific project
|
} 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
|
} else if len(projectIDs) > 1 { // multiple projects
|
||||||
// FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: this logic is not right, it should use "AND" but not "OR"
|
// 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))))
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"math"
|
||||||
|
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
@@ -46,6 +47,9 @@ func GetColumnIssueNextSorting(ctx context.Context, projectID, columnID int64) (
|
|||||||
Get(&res); err != nil {
|
Get(&res); err != nil {
|
||||||
return 0, err
|
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
|
return util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,3 +31,26 @@ type Project struct {
|
|||||||
// swagger:strfmt date-time
|
// swagger:strfmt date-time
|
||||||
Closed *time.Time `json:"closed_at,omitempty"`
|
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
|
// FIXME: for consistency, maybe most mustNotBeArchived checks should be replaced with mustEnableEditor
|
||||||
func mustNotBeArchived(ctx *context.APIContext) {
|
func mustNotBeArchived(ctx *context.APIContext) {
|
||||||
if ctx.Repo.Repository.IsArchived {
|
if ctx.Repo.Repository.IsArchived {
|
||||||
@@ -1554,6 +1565,12 @@ func Routes() *web.Router {
|
|||||||
m.Get("/issues/search", repo.SearchIssues)
|
m.Get("/issues/search", repo.SearchIssues)
|
||||||
|
|
||||||
m.Group("/{username}/{reponame}", func() {
|
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.Group("/issues", func() {
|
||||||
m.Combo("").Get(repo.ListIssues).
|
m.Combo("").Get(repo.ListIssues).
|
||||||
Post(reqToken(), mustNotBeArchived, bind(api.CreateIssueOption{}), reqRepoReader(unit.TypeIssues), repo.CreateIssue)
|
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
|
// in:body
|
||||||
IssueLabelsOption api.IssueLabelsOption
|
IssueLabelsOption api.IssueLabelsOption
|
||||||
|
// in:body
|
||||||
|
MoveProjectIssueOption api.MoveProjectIssueOption
|
||||||
|
|
||||||
// in:body
|
// in:body
|
||||||
CreateKeyOption api.CreateKeyOption
|
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
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"math"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
issues_model "gitea.dev/models/issues"
|
issues_model "gitea.dev/models/issues"
|
||||||
project_model "gitea.dev/models/project"
|
project_model "gitea.dev/models/project"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/optional"
|
"gitea.dev/modules/optional"
|
||||||
|
"gitea.dev/modules/util"
|
||||||
|
|
||||||
"xorm.io/builder"
|
"xorm.io/builder"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var projectIssueMoveMu sync.Mutex
|
||||||
|
|
||||||
// MoveIssuesOnProjectColumn moves or keeps issues in a column and sorts them inside that column
|
// 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 {
|
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 {
|
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||||
issueIDs := make([]int64, 0, len(sortedIssueIDs))
|
return moveIssuesOnProjectColumn(ctx, doer, column, sortedIssueIDs)
|
||||||
for _, issueID := range sortedIssueIDs {
|
})
|
||||||
issueIDs = append(issueIDs, issueID)
|
}
|
||||||
}
|
|
||||||
count, err := db.GetEngine(ctx).
|
// MoveIssueOnProjectColumn appends or positions one issue atomically.
|
||||||
Where("project_id=?", column.ProjectID).
|
func MoveIssueOnProjectColumn(ctx context.Context, doer *user_model.User, column *project_model.Column, issueID int64, sorting *int64) error {
|
||||||
In("issue_id", issueIDs).
|
// Serialize reorder calculations so concurrent requests cannot allocate the same sorting value.
|
||||||
Count(new(project_model.ProjectIssue))
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if int(count) != len(sortedIssueIDs) {
|
position := nextSorting
|
||||||
return errors.New("all issues have to be added to a project first")
|
if sorting != nil {
|
||||||
|
position = *sorting
|
||||||
}
|
}
|
||||||
|
if position < 0 || position == math.MaxInt64 {
|
||||||
issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := issues.LoadRepositories(ctx); err != nil {
|
if !exists {
|
||||||
return err
|
return util.NewInvalidArgumentErrorf("issue has to be added to project first")
|
||||||
}
|
}
|
||||||
|
if current.ProjectColumnID == column.ID {
|
||||||
project, err := project_model.GetProjectByID(ctx, column.ProjectID)
|
if position >= nextSorting {
|
||||||
if err != nil {
|
position = nextSorting - 1
|
||||||
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.Sorting < position {
|
||||||
projectColumnMap, err := curIssue.ProjectColumnMap(ctx)
|
if _, err := db.GetEngine(ctx).
|
||||||
if err != nil {
|
Where("project_id=? AND project_board_id=? AND sorting>? AND sorting<=?", column.ProjectID, column.ID, current.Sorting, position).
|
||||||
return err
|
SetExpr("sorting", "sorting - 1").Update(new(project_model.ProjectIssue)); err != nil {
|
||||||
}
|
return err
|
||||||
|
}
|
||||||
projectColumnID := projectColumnMap[column.ProjectID]
|
} else if current.Sorting > position {
|
||||||
|
if _, err := db.GetEngine(ctx).
|
||||||
if projectColumnID != column.ID {
|
Where("project_id=? AND project_board_id=? AND sorting>=? AND sorting<?", column.ProjectID, column.ID, position, current.Sorting).
|
||||||
// add timeline to issue
|
SetExpr("sorting", "sorting + 1").Update(new(project_model.ProjectIssue)); err != nil {
|
||||||
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
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if position > nextSorting {
|
||||||
// Update the column and sorting for this specific issue in this specific project.
|
position = nextSorting
|
||||||
// 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
|
if current.ProjectColumnID != column.ID {
|
||||||
// projects when the issue is assigned to multiple projects.
|
if _, err := db.GetEngine(ctx).
|
||||||
_, err = db.GetEngine(ctx).Table("project_issue").
|
Where("project_id=? AND project_board_id=? AND sorting>=?", column.ProjectID, column.ID, position).
|
||||||
Where("issue_id = ? AND project_id = ?", issueID, column.ProjectID).
|
SetExpr("sorting", "sorting + 1").Update(new(project_model.ProjectIssue)); err != nil {
|
||||||
Update(map[string]any{
|
|
||||||
"project_board_id": column.ID,
|
|
||||||
"sorting": sorting,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
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) {
|
func LoadIssuesAssigneesForProject(ctx context.Context, projectID int64) (users []*user_model.User, _ error) {
|
||||||
sub := builder.Select("distinct issue_assignees.assignee_id").
|
sub := builder.Select("distinct issue_assignees.assignee_id").
|
||||||
From("project_issue").Join("INNER", "issue_assignees", "project_issue.issue_id=issue_assignees.issue_id").
|
From("project_issue").Join("INNER", "issue_assignees", "project_issue.issue_id=issue_assignees.issue_id").
|
||||||
|
|||||||
+286
-1
@@ -19,7 +19,7 @@
|
|||||||
},
|
},
|
||||||
"version": "{{.SwaggerAppVer}}"
|
"version": "{{.SwaggerAppVer}}"
|
||||||
},
|
},
|
||||||
"basePath": "{{.SwaggerAppSubUrl}}/api/v1",
|
"basePath": "/{{.SwaggerAppSubUrl}}/api/v1",
|
||||||
"paths": {
|
"paths": {
|
||||||
"/admin/actions/jobs": {
|
"/admin/actions/jobs": {
|
||||||
"get": {
|
"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": {
|
"/repos/{owner}/{repo}/pulls": {
|
||||||
"get": {
|
"get": {
|
||||||
"produces": [
|
"produces": [
|
||||||
@@ -27941,6 +28157,28 @@
|
|||||||
},
|
},
|
||||||
"x-go-package": "gitea.dev/modules/structs"
|
"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": {
|
"NewIssuePinsAllowed": {
|
||||||
"description": "NewIssuePinsAllowed represents an API response that says if new Issue Pins are allowed",
|
"description": "NewIssuePinsAllowed represents an API response that says if new Issue Pins are allowed",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -28682,6 +28920,35 @@
|
|||||||
},
|
},
|
||||||
"x-go-package": "gitea.dev/modules/structs"
|
"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": {
|
"PublicKey": {
|
||||||
"description": "PublicKey publickey is a user key to push code to repository",
|
"description": "PublicKey publickey is a user key to push code to repository",
|
||||||
"type": "object",
|
"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": {
|
"PublicKey": {
|
||||||
"description": "PublicKey",
|
"description": "PublicKey",
|
||||||
"schema": {
|
"schema": {
|
||||||
|
|||||||
@@ -990,6 +990,32 @@
|
|||||||
},
|
},
|
||||||
"description": "PackageList"
|
"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": {
|
"PublicKey": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
@@ -7705,6 +7731,28 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"x-go-package": "gitea.dev/modules/structs"
|
"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": {
|
"NewIssuePinsAllowed": {
|
||||||
"description": "NewIssuePinsAllowed represents an API response that says if new Issue Pins are allowed",
|
"description": "NewIssuePinsAllowed represents an API response that says if new Issue Pins are allowed",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -8459,6 +8507,35 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"x-go-package": "gitea.dev/modules/structs"
|
"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": {
|
"PublicKey": {
|
||||||
"description": "PublicKey publickey is a user key to push code to repository",
|
"description": "PublicKey publickey is a user key to push code to repository",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -10583,6 +10660,10 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"x-go-name": "HideEmail"
|
"x-go-name": "HideEmail"
|
||||||
},
|
},
|
||||||
|
"include_private_contributions": {
|
||||||
|
"type": "boolean",
|
||||||
|
"x-go-name": "IncludePrivateContributions"
|
||||||
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"x-go-name": "Language"
|
"x-go-name": "Language"
|
||||||
@@ -10627,6 +10708,10 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"x-go-name": "HideEmail"
|
"x-go-name": "HideEmail"
|
||||||
},
|
},
|
||||||
|
"include_private_contributions": {
|
||||||
|
"type": "boolean",
|
||||||
|
"x-go-name": "IncludePrivateContributions"
|
||||||
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"x-go-name": "Language"
|
"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": {
|
"/repos/{owner}/{repo}/pulls": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "repoListPullRequests",
|
"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