This commit is contained in:
@@ -24,8 +24,10 @@ Required configuration can come from flags or environment variables:
|
|||||||
| `--label-prefix` | `GITEA_LABEL_PREFIX` | Column prefix; default `kanban/` |
|
| `--label-prefix` | `GITEA_LABEL_PREFIX` | Column prefix; default `kanban/` |
|
||||||
|
|
||||||
Native mode requires an enabled repository Projects unit, project read/write
|
Native mode requires an enabled repository Projects unit, project read/write
|
||||||
repository permission, and a token with `read:issue`/`write:issue` scope. Label
|
repository permission, and a token with `read:issue`/`write:issue` scope. Issue
|
||||||
mode requires repository issue read/write permission. Token values are sent
|
creation/editing follows Gitea issue permissions; deletion requires repository
|
||||||
|
admin permission in this fork. Label mode requires repository issue read/write
|
||||||
|
permission. Token values are sent
|
||||||
only through Gitea's `Authorization` header and are never printed. Token-file
|
only through Gitea's `Authorization` header and are never printed. Token-file
|
||||||
input takes precedence over environment variables. Remote URLs must use HTTPS; plain HTTP is
|
input takes precedence over environment variables. Remote URLs must use HTTPS; plain HTTP is
|
||||||
accepted only for loopback development.
|
accepted only for loopback development.
|
||||||
@@ -57,7 +59,10 @@ 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
|
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;
|
supported by the server API. Project names are exact, case-sensitive matches;
|
||||||
use `--project-id` when duplicate names exist. Empty native boards render
|
use `--project-id` when duplicate names exist. Empty native boards render
|
||||||
normally. Closed projects, archived repositories, disabled Projects units,
|
normally. `n` creates an issue assigned to the selected project, `e` edits the
|
||||||
|
focused issue title/body, and `d` deletes it after confirmation. In the editor,
|
||||||
|
`Enter` switches from title to body, `Tab` switches fields, `Ctrl-S` saves, and
|
||||||
|
`Esc` cancels. Closed projects, archived repositories, disabled Projects units,
|
||||||
unassigned issues, and cross-repository IDs are rejected by the server.
|
unassigned issues, and cross-repository IDs are rejected by the server.
|
||||||
|
|
||||||
Explicit fallback command:
|
Explicit fallback command:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
use std::net::IpAddr;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use reqwest::blocking::{Client, Response};
|
use reqwest::blocking::{Client, Response};
|
||||||
@@ -8,7 +9,8 @@ use serde::de::DeserializeOwned;
|
|||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
Issue, Label, MoveProjectIssuePayload, Project, ProjectColumn, ReplaceLabelsPayload,
|
CreateIssuePayload, EditIssuePayload, Issue, Label, MoveProjectIssuePayload, Project,
|
||||||
|
ProjectColumn, ReplaceLabelsPayload,
|
||||||
};
|
};
|
||||||
use crate::text::sanitize_terminal_text;
|
use crate::text::sanitize_terminal_text;
|
||||||
|
|
||||||
@@ -33,6 +35,9 @@ pub trait GiteaApi {
|
|||||||
issue_number: u64,
|
issue_number: u64,
|
||||||
payload: &ReplaceLabelsPayload,
|
payload: &ReplaceLabelsPayload,
|
||||||
) -> Result<(), ApiError>;
|
) -> Result<(), ApiError>;
|
||||||
|
fn create_issue(&self, payload: &CreateIssuePayload) -> Result<Issue, ApiError>;
|
||||||
|
fn edit_issue(&self, issue_number: u64, payload: &EditIssuePayload) -> Result<Issue, ApiError>;
|
||||||
|
fn delete_issue(&self, issue_number: u64) -> Result<(), ApiError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct GiteaClient {
|
pub struct GiteaClient {
|
||||||
@@ -85,6 +90,11 @@ impl GiteaClient {
|
|||||||
pub fn new(config: &Config) -> Result<Self, ApiError> {
|
pub fn new(config: &Config) -> Result<Self, ApiError> {
|
||||||
let mut api_base = Url::parse(&config.base_url)
|
let mut api_base = Url::parse(&config.base_url)
|
||||||
.map_err(|error| ApiError(format!("invalid Gitea URL: {error}")))?;
|
.map_err(|error| ApiError(format!("invalid Gitea URL: {error}")))?;
|
||||||
|
if !api_base.username().is_empty() || api_base.password().is_some() {
|
||||||
|
return Err(ApiError(
|
||||||
|
"Gitea URL must not contain embedded username or password".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
require_secure_transport(&api_base)?;
|
require_secure_transport(&api_base)?;
|
||||||
api_base.set_query(None);
|
api_base.set_query(None);
|
||||||
api_base.set_fragment(None);
|
api_base.set_fragment(None);
|
||||||
@@ -121,7 +131,7 @@ impl GiteaClient {
|
|||||||
response: Response,
|
response: Response,
|
||||||
operation: &str,
|
operation: &str,
|
||||||
) -> Result<T, ApiError> {
|
) -> Result<T, ApiError> {
|
||||||
let response = check_response(response, operation)?;
|
let response = check_response(response, operation, &self.token)?;
|
||||||
response.json().map_err(|error| {
|
response.json().map_err(|error| {
|
||||||
ApiError(format!(
|
ApiError(format!(
|
||||||
"Gitea returned invalid JSON while {operation}: {error}"
|
"Gitea returned invalid JSON while {operation}: {error}"
|
||||||
@@ -213,7 +223,7 @@ impl GiteaApi for GiteaClient {
|
|||||||
"cannot reach Gitea while moving project issue: {error}"
|
"cannot reach Gitea while moving project issue: {error}"
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
check_response(response, "moving project issue")?;
|
check_response(response, "moving project issue", &self.token)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,12 +258,60 @@ impl GiteaApi for GiteaClient {
|
|||||||
check_response(
|
check_response(
|
||||||
response,
|
response,
|
||||||
&format!("replacing labels on issue #{issue_number}"),
|
&format!("replacing labels on issue #{issue_number}"),
|
||||||
|
&self.token,
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_issue(&self, payload: &CreateIssuePayload) -> Result<Issue, ApiError> {
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.post(self.endpoint("issues")?)
|
||||||
|
.header("Authorization", format!("token {}", self.token))
|
||||||
|
.json(payload)
|
||||||
|
.send()
|
||||||
|
.map_err(|error| {
|
||||||
|
ApiError(format!("cannot reach Gitea while creating issue: {error}"))
|
||||||
|
})?;
|
||||||
|
self.decode(response, "creating issue")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_issue(&self, issue_number: u64, payload: &EditIssuePayload) -> Result<Issue, ApiError> {
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.patch(self.endpoint(&format!("issues/{issue_number}"))?)
|
||||||
|
.header("Authorization", format!("token {}", self.token))
|
||||||
|
.json(payload)
|
||||||
|
.send()
|
||||||
|
.map_err(|error| {
|
||||||
|
ApiError(format!(
|
||||||
|
"cannot reach Gitea while editing issue #{issue_number}: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
self.decode(response, &format!("editing issue #{issue_number}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_issue(&self, issue_number: u64) -> Result<(), ApiError> {
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.delete(self.endpoint(&format!("issues/{issue_number}"))?)
|
||||||
|
.header("Authorization", format!("token {}", self.token))
|
||||||
|
.send()
|
||||||
|
.map_err(|error| {
|
||||||
|
ApiError(format!(
|
||||||
|
"cannot reach Gitea while deleting issue #{issue_number}: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
check_response(
|
||||||
|
response,
|
||||||
|
&format!("deleting issue #{issue_number}"),
|
||||||
|
&self.token,
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check_response(response: Response, operation: &str) -> Result<Response, ApiError> {
|
fn check_response(response: Response, operation: &str, token: &str) -> Result<Response, ApiError> {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
if status.is_success() {
|
if status.is_success() {
|
||||||
return Ok(response);
|
return Ok(response);
|
||||||
@@ -263,7 +321,8 @@ fn check_response(response: Response, operation: &str) -> Result<Response, ApiEr
|
|||||||
.text()
|
.text()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|body| serde_json::from_str::<serde_json::Value>(&body).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));
|
.and_then(|value| value.get("message")?.as_str().map(sanitize_terminal_text))
|
||||||
|
.map(|message| message.replace(token, "[redacted token]"));
|
||||||
let detail = message
|
let detail = message
|
||||||
.map(|message| format!(": {message}"))
|
.map(|message| format!(": {message}"))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
@@ -285,7 +344,15 @@ fn require_secure_transport(url: &Url) -> Result<(), ApiError> {
|
|||||||
if url.scheme() == "https" {
|
if url.scheme() == "https" {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let loopback = matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "::1"));
|
let loopback = url
|
||||||
|
.host_str()
|
||||||
|
.and_then(|host| {
|
||||||
|
host.trim_start_matches('[')
|
||||||
|
.trim_end_matches(']')
|
||||||
|
.parse::<IpAddr>()
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.is_some_and(|address| address.is_loopback());
|
||||||
if url.scheme() == "http" && loopback {
|
if url.scheme() == "http" && loopback {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -374,7 +441,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn rejects_remote_plain_http_but_allows_loopback() {
|
fn rejects_remote_plain_http_but_allows_loopback() {
|
||||||
assert!(GiteaClient::new(&config("http://gitea.example")).is_err());
|
assert!(GiteaClient::new(&config("http://gitea.example")).is_err());
|
||||||
|
assert!(GiteaClient::new(&config("http://localhost:3000")).is_err());
|
||||||
assert!(GiteaClient::new(&config("http://127.0.0.1:3000")).is_ok());
|
assert!(GiteaClient::new(&config("http://127.0.0.1:3000")).is_ok());
|
||||||
|
assert!(GiteaClient::new(&config("http://[::1]:3000")).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -422,7 +491,7 @@ mod tests {
|
|||||||
assert!(first.contains("authorization: token secret"));
|
assert!(first.contains("authorization: token secret"));
|
||||||
|
|
||||||
let (base_url, _) = mock_server(vec![
|
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\"}",
|
"HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{\"message\":\"projects unavailable secret\"}",
|
||||||
]);
|
]);
|
||||||
let client = GiteaClient::new(&config(&base_url)).expect("client");
|
let client = GiteaClient::new(&config(&base_url)).expect("client");
|
||||||
let error = client
|
let error = client
|
||||||
@@ -470,4 +539,46 @@ mod tests {
|
|||||||
assert!(requests.recv().expect("page one").contains("page=1"));
|
assert!(requests.recv().expect("page one").contains("page=1"));
|
||||||
assert!(requests.recv().expect("page two").contains("page=2"));
|
assert!(requests.recv().expect("page two").contains("page=2"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn creates_edits_and_deletes_issues() {
|
||||||
|
let (base_url, requests) = mock_server(vec![
|
||||||
|
"HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{\"id\":99,\"number\":7,\"title\":\"New\"}",
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{\"id\":99,\"number\":7,\"title\":\"Updated\"}",
|
||||||
|
"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");
|
||||||
|
let created = client
|
||||||
|
.create_issue(&CreateIssuePayload {
|
||||||
|
title: "New".to_owned(),
|
||||||
|
body: "Details".to_owned(),
|
||||||
|
projects: vec![4],
|
||||||
|
})
|
||||||
|
.expect("create");
|
||||||
|
assert_eq!(created.number, 7);
|
||||||
|
let edited = client
|
||||||
|
.edit_issue(
|
||||||
|
7,
|
||||||
|
&EditIssuePayload {
|
||||||
|
title: "Updated".to_owned(),
|
||||||
|
body: "Changed".to_owned(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("edit");
|
||||||
|
assert_eq!(edited.title, "Updated");
|
||||||
|
client.delete_issue(7).expect("delete");
|
||||||
|
|
||||||
|
assert!(requests
|
||||||
|
.recv()
|
||||||
|
.expect("create request")
|
||||||
|
.ends_with("{\"title\":\"New\",\"body\":\"Details\",\"projects\":[4]}"));
|
||||||
|
assert!(requests
|
||||||
|
.recv()
|
||||||
|
.expect("edit request")
|
||||||
|
.starts_with("PATCH /api/v1/repos/owner%20name/repo%2Fname/issues/7 HTTP/1.1"));
|
||||||
|
assert!(requests
|
||||||
|
.recv()
|
||||||
|
.expect("delete request")
|
||||||
|
.starts_with("DELETE /api/v1/repos/owner%20name/repo%2Fname/issues/7 HTTP/1.1"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
extract_columns, replacement_payload, ColumnSpec, Issue, Label, MoveProjectIssuePayload,
|
extract_columns, replacement_payload, ColumnSpec, EditIssuePayload, Issue, Label,
|
||||||
Project, ProjectColumn, ReplaceLabelsPayload,
|
MoveProjectIssuePayload, Project, ProjectColumn, ReplaceLabelsPayload,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -16,9 +18,47 @@ pub struct App {
|
|||||||
pub focused_cards: Vec<usize>,
|
pub focused_cards: Vec<usize>,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub show_help: bool,
|
pub show_help: bool,
|
||||||
|
pub editor: Option<EditorState>,
|
||||||
backend: BoardBackend,
|
backend: BoardBackend,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum EditorMode {
|
||||||
|
Create { project_id: u64 },
|
||||||
|
Edit { issue_number: u64 },
|
||||||
|
Delete { issue_number: u64, title: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum EditorField {
|
||||||
|
Title,
|
||||||
|
Body,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct EditorState {
|
||||||
|
pub mode: EditorMode,
|
||||||
|
pub field: EditorField,
|
||||||
|
pub title: String,
|
||||||
|
pub body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum IssueAction {
|
||||||
|
Create {
|
||||||
|
project_id: u64,
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
},
|
||||||
|
Edit {
|
||||||
|
issue_number: u64,
|
||||||
|
payload: EditIssuePayload,
|
||||||
|
},
|
||||||
|
Delete {
|
||||||
|
issue_number: u64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
enum BoardBackend {
|
enum BoardBackend {
|
||||||
Projects { project_id: u64 },
|
Projects { project_id: u64 },
|
||||||
@@ -92,6 +132,7 @@ impl App {
|
|||||||
focused_cards,
|
focused_cards,
|
||||||
status: "Ready".to_owned(),
|
status: "Ready".to_owned(),
|
||||||
show_help: false,
|
show_help: false,
|
||||||
|
editor: None,
|
||||||
backend: BoardBackend::Labels {
|
backend: BoardBackend::Labels {
|
||||||
prefix: label_prefix,
|
prefix: label_prefix,
|
||||||
},
|
},
|
||||||
@@ -133,6 +174,7 @@ impl App {
|
|||||||
focused_cards,
|
focused_cards,
|
||||||
status: "Ready".to_owned(),
|
status: "Ready".to_owned(),
|
||||||
show_help: false,
|
show_help: false,
|
||||||
|
editor: None,
|
||||||
backend: BoardBackend::Projects {
|
backend: BoardBackend::Projects {
|
||||||
project_id: project.id,
|
project_id: project.id,
|
||||||
},
|
},
|
||||||
@@ -210,10 +252,182 @@ impl App {
|
|||||||
self.focused_column = request.target;
|
self.focused_column = request.target;
|
||||||
self.status = format!("Moved issue #{issue_number}");
|
self.status = format!("Moved issue #{issue_number}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn begin_create(&mut self) {
|
||||||
|
if !matches!(&self.backend, BoardBackend::Projects { .. }) {
|
||||||
|
self.status = "Issue editing is available only with --backend projects".to_owned();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let project_id = match &self.backend {
|
||||||
|
BoardBackend::Projects { project_id } => *project_id,
|
||||||
|
BoardBackend::Labels { .. } => unreachable!("backend checked above"),
|
||||||
|
};
|
||||||
|
self.editor = Some(EditorState {
|
||||||
|
mode: EditorMode::Create { project_id },
|
||||||
|
field: EditorField::Title,
|
||||||
|
title: String::new(),
|
||||||
|
body: String::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn begin_edit(&mut self) {
|
||||||
|
if !matches!(&self.backend, BoardBackend::Projects { .. }) {
|
||||||
|
self.status = "Issue editing is available only with --backend projects".to_owned();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(issue) = self.focused_card().cloned() else {
|
||||||
|
self.status = "No issue selected".to_owned();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.editor = Some(EditorState {
|
||||||
|
mode: EditorMode::Edit {
|
||||||
|
issue_number: issue.number,
|
||||||
|
},
|
||||||
|
field: EditorField::Title,
|
||||||
|
title: issue.title,
|
||||||
|
body: issue.body.unwrap_or_default(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn begin_delete(&mut self) {
|
||||||
|
if !matches!(&self.backend, BoardBackend::Projects { .. }) {
|
||||||
|
self.status = "Issue editing is available only with --backend projects".to_owned();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(issue) = self.focused_card() else {
|
||||||
|
self.status = "No issue selected".to_owned();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.editor = Some(EditorState {
|
||||||
|
mode: EditorMode::Delete {
|
||||||
|
issue_number: issue.number,
|
||||||
|
title: issue.title.clone(),
|
||||||
|
},
|
||||||
|
field: EditorField::Title,
|
||||||
|
title: String::new(),
|
||||||
|
body: String::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_editor_key(&mut self, key: KeyEvent) -> Option<IssueAction> {
|
||||||
|
let mut editor = self.editor.take()?;
|
||||||
|
if matches!(editor.mode, EditorMode::Delete { .. }) {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('y') | KeyCode::Char('Y') => {
|
||||||
|
if let EditorMode::Delete { issue_number, .. } = editor.mode {
|
||||||
|
return Some(IssueAction::Delete { issue_number });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
|
||||||
|
self.status = "Delete cancelled".to_owned();
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
self.editor = Some(editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if key.code == KeyCode::Esc {
|
||||||
|
self.status = "Edit cancelled".to_owned();
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') {
|
||||||
|
return self.submit_editor(editor);
|
||||||
|
}
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Tab => {
|
||||||
|
editor.field = match editor.field {
|
||||||
|
EditorField::Title => EditorField::Body,
|
||||||
|
EditorField::Body => EditorField::Title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
KeyCode::Enter if editor.field == EditorField::Title => {
|
||||||
|
editor.field = EditorField::Body;
|
||||||
|
}
|
||||||
|
KeyCode::Enter => editor.body.push('\n'),
|
||||||
|
KeyCode::Backspace => match editor.field {
|
||||||
|
EditorField::Title => {
|
||||||
|
editor.title.pop();
|
||||||
|
}
|
||||||
|
EditorField::Body => {
|
||||||
|
editor.body.pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
KeyCode::Char(character) if !character.is_control() => match editor.field {
|
||||||
|
EditorField::Title => editor.title.push(character),
|
||||||
|
EditorField::Body => editor.body.push(character),
|
||||||
|
},
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
self.editor = Some(editor);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn restore_issue_action(&mut self, action: IssueAction) {
|
||||||
|
self.editor = Some(match action {
|
||||||
|
IssueAction::Create {
|
||||||
|
project_id,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
} => EditorState {
|
||||||
|
mode: EditorMode::Create { project_id },
|
||||||
|
field: EditorField::Body,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
},
|
||||||
|
IssueAction::Edit {
|
||||||
|
issue_number,
|
||||||
|
payload,
|
||||||
|
} => EditorState {
|
||||||
|
mode: EditorMode::Edit { issue_number },
|
||||||
|
field: EditorField::Body,
|
||||||
|
title: payload.title,
|
||||||
|
body: payload.body,
|
||||||
|
},
|
||||||
|
IssueAction::Delete { issue_number } => EditorState {
|
||||||
|
mode: EditorMode::Delete {
|
||||||
|
issue_number,
|
||||||
|
title: self
|
||||||
|
.focused_card()
|
||||||
|
.map(|issue| issue.title.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
},
|
||||||
|
field: EditorField::Title,
|
||||||
|
title: String::new(),
|
||||||
|
body: String::new(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn submit_editor(&mut self, editor: EditorState) -> Option<IssueAction> {
|
||||||
|
if editor.title.trim().is_empty() {
|
||||||
|
self.status = "Title cannot be empty".to_owned();
|
||||||
|
self.editor = Some(editor);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match editor.mode {
|
||||||
|
EditorMode::Create { project_id } => Some(IssueAction::Create {
|
||||||
|
project_id,
|
||||||
|
title: editor.title,
|
||||||
|
body: editor.body,
|
||||||
|
}),
|
||||||
|
EditorMode::Edit { issue_number } => Some(IssueAction::Edit {
|
||||||
|
issue_number,
|
||||||
|
payload: EditIssuePayload {
|
||||||
|
title: editor.title,
|
||||||
|
body: editor.body,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
EditorMode::Delete { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn label(id: u64, name: &str) -> Label {
|
fn label(id: u64, name: &str) -> Label {
|
||||||
@@ -352,6 +566,44 @@ mod tests {
|
|||||||
assert_eq!(app.columns[1].cards[0].id, 99);
|
assert_eq!(app.columns[1].cards[0].id, 99);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_editor_creates_project_issue_action() {
|
||||||
|
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,
|
||||||
|
}],
|
||||||
|
vec![Vec::new()],
|
||||||
|
)
|
||||||
|
.expect("board");
|
||||||
|
app.begin_create();
|
||||||
|
for character in "New issue".chars() {
|
||||||
|
app.handle_editor_key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE));
|
||||||
|
}
|
||||||
|
app.handle_editor_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||||
|
for character in "Details".chars() {
|
||||||
|
app.handle_editor_key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE));
|
||||||
|
}
|
||||||
|
let action =
|
||||||
|
app.handle_editor_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
|
||||||
|
assert!(matches!(
|
||||||
|
action,
|
||||||
|
Some(IssueAction::Create {
|
||||||
|
project_id: 8,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
}) if title == "New issue" && body == "Details"
|
||||||
|
));
|
||||||
|
assert!(app.editor.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn label_board_without_cards_keeps_actionable_error() {
|
fn label_board_without_cards_keeps_actionable_error() {
|
||||||
let result = App::new_labels(
|
let result = App::new_labels(
|
||||||
|
|||||||
@@ -4,14 +4,16 @@ use std::time::Duration;
|
|||||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
||||||
|
|
||||||
use gitea_kanban_tui::api::{resolve_project, GiteaApi, GiteaClient};
|
use gitea_kanban_tui::api::{resolve_project, GiteaApi, GiteaClient};
|
||||||
use gitea_kanban_tui::app::{App, MoveAction};
|
use gitea_kanban_tui::app::{App, IssueAction, MoveAction};
|
||||||
use gitea_kanban_tui::config::{Backend, Config};
|
use gitea_kanban_tui::config::{Backend, Config};
|
||||||
|
use gitea_kanban_tui::model::CreateIssuePayload;
|
||||||
use gitea_kanban_tui::terminal::TerminalGuard;
|
use gitea_kanban_tui::terminal::TerminalGuard;
|
||||||
|
use gitea_kanban_tui::text::sanitize_terminal_text;
|
||||||
use gitea_kanban_tui::ui;
|
use gitea_kanban_tui::ui;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
if let Err(error) = run() {
|
if let Err(error) = run() {
|
||||||
eprintln!("error: {error}");
|
eprintln!("error: {}", sanitize_terminal_text(&error.to_string()));
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,6 +39,13 @@ fn run() -> Result<(), Box<dyn Error>> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if app.editor.is_some() {
|
||||||
|
if let Some(action) = app.handle_editor_key(key) {
|
||||||
|
issue_action(&client, &config, &mut app, action);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char('q') => break,
|
KeyCode::Char('q') => break,
|
||||||
KeyCode::Left | KeyCode::Char('h') => app.focus_left(),
|
KeyCode::Left | KeyCode::Char('h') => app.focus_left(),
|
||||||
@@ -45,6 +54,9 @@ fn run() -> Result<(), Box<dyn Error>> {
|
|||||||
KeyCode::Down | KeyCode::Char('j') => app.focus_down(),
|
KeyCode::Down | KeyCode::Char('j') => app.focus_down(),
|
||||||
KeyCode::Char('H') => move_card(&client, &mut app, -1),
|
KeyCode::Char('H') => move_card(&client, &mut app, -1),
|
||||||
KeyCode::Char('L') => move_card(&client, &mut app, 1),
|
KeyCode::Char('L') => move_card(&client, &mut app, 1),
|
||||||
|
KeyCode::Char('n') => app.begin_create(),
|
||||||
|
KeyCode::Char('e') => app.begin_edit(),
|
||||||
|
KeyCode::Char('d') => app.begin_delete(),
|
||||||
KeyCode::Char('r') => match load_board(&client, &config) {
|
KeyCode::Char('r') => match load_board(&client, &config) {
|
||||||
Ok(board) => app = board,
|
Ok(board) => app = board,
|
||||||
Err(error) => app.status = format!("Refresh failed: {error}"),
|
Err(error) => app.status = format!("Refresh failed: {error}"),
|
||||||
@@ -56,6 +68,45 @@ fn run() -> Result<(), Box<dyn Error>> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn issue_action(client: &impl GiteaApi, config: &Config, app: &mut App, action: IssueAction) {
|
||||||
|
let retry_action = action.clone();
|
||||||
|
let message = match action {
|
||||||
|
IssueAction::Create {
|
||||||
|
project_id,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
} => client
|
||||||
|
.create_issue(&CreateIssuePayload {
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
projects: vec![project_id],
|
||||||
|
})
|
||||||
|
.map(|issue| format!("Created issue #{}", issue.number)),
|
||||||
|
IssueAction::Edit {
|
||||||
|
issue_number,
|
||||||
|
payload,
|
||||||
|
} => client
|
||||||
|
.edit_issue(issue_number, &payload)
|
||||||
|
.map(|_| format!("Updated issue #{issue_number}")),
|
||||||
|
IssueAction::Delete { issue_number } => client
|
||||||
|
.delete_issue(issue_number)
|
||||||
|
.map(|_| format!("Deleted issue #{issue_number}")),
|
||||||
|
};
|
||||||
|
match message {
|
||||||
|
Ok(message) => match load_board(client, config) {
|
||||||
|
Ok(mut board) => {
|
||||||
|
board.status = message;
|
||||||
|
*app = board;
|
||||||
|
}
|
||||||
|
Err(error) => app.status = format!("Saved, refresh failed: {error}"),
|
||||||
|
},
|
||||||
|
Err(error) => {
|
||||||
|
app.restore_issue_action(retry_action);
|
||||||
|
app.status = format!("Issue operation failed: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn load_board(client: &impl GiteaApi, config: &Config) -> Result<App, Box<dyn Error>> {
|
fn load_board(client: &impl GiteaApi, config: &Config) -> Result<App, Box<dyn Error>> {
|
||||||
match config.backend {
|
match config.backend {
|
||||||
Backend::Projects => {
|
Backend::Projects => {
|
||||||
|
|||||||
@@ -19,6 +19,19 @@ pub struct Issue {
|
|||||||
pub labels: Vec<Label>,
|
pub labels: Vec<Label>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, PartialEq, Eq)]
|
||||||
|
pub struct CreateIssuePayload {
|
||||||
|
pub title: String,
|
||||||
|
pub body: String,
|
||||||
|
pub projects: Vec<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||||
|
pub struct EditIssuePayload {
|
||||||
|
pub title: String,
|
||||||
|
pub body: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct ColumnSpec {
|
pub struct ColumnSpec {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
@@ -170,4 +183,25 @@ mod tests {
|
|||||||
serde_json::json!({"column_id": 9, "sorting": 3})
|
serde_json::json!({"column_id": 9, "sorting": 3})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serializes_issue_create_and_edit_payloads() {
|
||||||
|
let create = CreateIssuePayload {
|
||||||
|
title: "New issue".to_owned(),
|
||||||
|
body: "Details".to_owned(),
|
||||||
|
projects: vec![4],
|
||||||
|
};
|
||||||
|
let edit = EditIssuePayload {
|
||||||
|
title: "Updated".to_owned(),
|
||||||
|
body: "Changed".to_owned(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(create).expect("create payload"),
|
||||||
|
serde_json::json!({"title": "New issue", "body": "Details", "projects": [4]})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(edit).expect("edit payload"),
|
||||||
|
serde_json::json!({"title": "Updated", "body": "Changed"})
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,19 @@ pub fn sanitize_terminal_text(value: &str) -> String {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn sanitize_editor_text(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.chars()
|
||||||
|
.map(|character| {
|
||||||
|
if character == '\n' || !character.is_control() {
|
||||||
|
character
|
||||||
|
} else {
|
||||||
|
' '
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -22,4 +35,12 @@ mod tests {
|
|||||||
"safe [31m text"
|
"safe [31m text"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editor_sanitizer_preserves_newlines() {
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_editor_text("first\nsecond\u{1b}[31m"),
|
||||||
|
"first\nsecond [31m"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};
|
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
use crate::app::{App, Column};
|
use crate::app::{App, Column, EditorField, EditorMode, EditorState};
|
||||||
use crate::text::sanitize_terminal_text;
|
use crate::text::{sanitize_editor_text, sanitize_terminal_text};
|
||||||
|
|
||||||
pub fn draw(frame: &mut Frame<'_>, app: &App, repository: &str) {
|
pub fn draw(frame: &mut Frame<'_>, app: &App, repository: &str) {
|
||||||
let areas = Layout::default()
|
let areas = Layout::default()
|
||||||
@@ -36,7 +36,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, repository: &str) {
|
|||||||
|
|
||||||
let help = if app.show_help {
|
let help = if app.show_help {
|
||||||
format!(
|
format!(
|
||||||
"{}\n←/h →/l: column ↑/k ↓/j: card H/L: move r: refresh ?: help q: quit",
|
"{}\n←/h →/l: column ↑/k ↓/j: card H/L: move n: new e: edit d: delete r: refresh ?: help q: quit",
|
||||||
sanitize_terminal_text(&app.status)
|
sanitize_terminal_text(&app.status)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -46,6 +46,81 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, repository: &str) {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
frame.render_widget(Paragraph::new(help).wrap(Wrap { trim: true }), areas[3]);
|
frame.render_widget(Paragraph::new(help).wrap(Wrap { trim: true }), areas[3]);
|
||||||
|
if let Some(editor) = &app.editor {
|
||||||
|
draw_editor(frame, editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_editor(frame: &mut Frame<'_>, editor: &EditorState) {
|
||||||
|
let area = centered_rect(frame.area(), 80, 45);
|
||||||
|
frame.render_widget(Clear, area);
|
||||||
|
let (title, content) = match &editor.mode {
|
||||||
|
EditorMode::Delete {
|
||||||
|
issue_number,
|
||||||
|
title,
|
||||||
|
} => (
|
||||||
|
"Delete issue".to_owned(),
|
||||||
|
format!(
|
||||||
|
"Delete issue #{issue_number} — {}?\n\n[y] confirm [n/Esc] cancel",
|
||||||
|
sanitize_terminal_text(title)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
EditorMode::Create { .. } => (
|
||||||
|
"New issue".to_owned(),
|
||||||
|
editor_content(editor, "Create issue"),
|
||||||
|
),
|
||||||
|
EditorMode::Edit { issue_number } => (
|
||||||
|
format!("Edit issue #{issue_number}"),
|
||||||
|
editor_content(editor, "Edit issue"),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(content)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.title(format!(" {title} "))
|
||||||
|
.borders(Borders::ALL),
|
||||||
|
)
|
||||||
|
.wrap(Wrap { trim: false }),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn editor_content(editor: &EditorState, action: &str) -> String {
|
||||||
|
let title_marker = if editor.field == EditorField::Title {
|
||||||
|
"▶ "
|
||||||
|
} else {
|
||||||
|
" "
|
||||||
|
};
|
||||||
|
let body_marker = if editor.field == EditorField::Body {
|
||||||
|
"▶ "
|
||||||
|
} else {
|
||||||
|
" "
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"{title_marker}Title: {}\n{body_marker}Body: {}\n\nTab: switch field Enter: title → body Ctrl-S: save Esc: cancel\n{action}",
|
||||||
|
sanitize_terminal_text(&editor.title),
|
||||||
|
sanitize_editor_text(&editor.body)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn centered_rect(area: Rect, width_percent: u16, height_percent: u16) -> Rect {
|
||||||
|
let vertical = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage((100 - height_percent) / 2),
|
||||||
|
Constraint::Percentage(height_percent),
|
||||||
|
Constraint::Percentage((100 - height_percent) / 2),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage((100 - width_percent) / 2),
|
||||||
|
Constraint::Percentage(width_percent),
|
||||||
|
Constraint::Percentage((100 - width_percent) / 2),
|
||||||
|
])
|
||||||
|
.split(vertical[1])[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_columns(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
fn draw_columns(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||||
|
|||||||
@@ -1599,8 +1599,8 @@ func Routes() *web.Router {
|
|||||||
})
|
})
|
||||||
m.Group("/{index}", func() {
|
m.Group("/{index}", func() {
|
||||||
m.Combo("").Get(repo.GetIssue).
|
m.Combo("").Get(repo.GetIssue).
|
||||||
Patch(reqToken(), bind(api.EditIssueOption{}), repo.EditIssue).
|
Patch(reqToken(), mustNotBeArchived, bind(api.EditIssueOption{}), repo.EditIssue).
|
||||||
Delete(reqToken(), reqAdmin(), context.ReferencesGitRepo(), repo.DeleteIssue)
|
Delete(reqToken(), mustNotBeArchived, reqAdmin(), context.ReferencesGitRepo(), repo.DeleteIssue)
|
||||||
m.Combo("/assignees").
|
m.Combo("/assignees").
|
||||||
Post(reqToken(), mustNotBeArchived, bind(api.IssueAssigneesOption{}), repo.AddIssueAssignees).
|
Post(reqToken(), mustNotBeArchived, bind(api.IssueAssigneesOption{}), repo.AddIssueAssignees).
|
||||||
Delete(reqToken(), mustNotBeArchived, bind(api.IssueAssigneesOption{}), repo.DeleteIssueAssignees)
|
Delete(reqToken(), mustNotBeArchived, bind(api.IssueAssigneesOption{}), repo.DeleteIssueAssignees)
|
||||||
@@ -1614,10 +1614,10 @@ func Routes() *web.Router {
|
|||||||
m.Get("/timeline", repo.ListIssueCommentsAndTimeline)
|
m.Get("/timeline", repo.ListIssueCommentsAndTimeline)
|
||||||
m.Group("/labels", func() {
|
m.Group("/labels", func() {
|
||||||
m.Combo("").Get(repo.ListIssueLabels).
|
m.Combo("").Get(repo.ListIssueLabels).
|
||||||
Post(reqToken(), bind(api.IssueLabelsOption{}), repo.AddIssueLabels).
|
Post(reqToken(), mustNotBeArchived, bind(api.IssueLabelsOption{}), repo.AddIssueLabels).
|
||||||
Put(reqToken(), bind(api.IssueLabelsOption{}), repo.ReplaceIssueLabels).
|
Put(reqToken(), mustNotBeArchived, bind(api.IssueLabelsOption{}), repo.ReplaceIssueLabels).
|
||||||
Delete(reqToken(), repo.ClearIssueLabels)
|
Delete(reqToken(), mustNotBeArchived, repo.ClearIssueLabels)
|
||||||
m.Delete("/{id}", reqToken(), repo.DeleteIssueLabel)
|
m.Delete("/{id}", reqToken(), mustNotBeArchived, repo.DeleteIssueLabel)
|
||||||
})
|
})
|
||||||
m.Group("/times", func() {
|
m.Group("/times", func() {
|
||||||
m.Combo("").
|
m.Combo("").
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
issues_model "gitea.dev/models/issues"
|
issues_model "gitea.dev/models/issues"
|
||||||
access_model "gitea.dev/models/perm/access"
|
access_model "gitea.dev/models/perm/access"
|
||||||
|
project_model "gitea.dev/models/project"
|
||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
"gitea.dev/models/unit"
|
"gitea.dev/models/unit"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
@@ -529,6 +530,38 @@ func GetIssue(ctx *context.APIContext) {
|
|||||||
ctx.JSON(http.StatusOK, convert.ToAPIIssue(ctx, ctx.Doer, issue))
|
ctx.JSON(http.StatusOK, convert.ToAPIIssue(ctx, ctx.Doer, issue))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateProjectAssignments(ctx *context.APIContext, projectIDs []int64) (int, string) {
|
||||||
|
if unit.TypeProjects.UnitGlobalDisabled() || !ctx.Repo.Permission.CanWrite(unit.TypeProjects) {
|
||||||
|
return http.StatusForbidden, "project write permission is required"
|
||||||
|
}
|
||||||
|
projectsUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeProjects)
|
||||||
|
if !projectsUnit.ProjectsConfig().IsProjectsAllowed(repo_model.ProjectsModeRepo) {
|
||||||
|
return http.StatusNotFound, ""
|
||||||
|
}
|
||||||
|
if len(projectIDs) == 0 {
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
projects, err := project_model.GetProjectsMapByIDs(ctx, projectIDs)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, err.Error()
|
||||||
|
}
|
||||||
|
seen := make(map[int64]struct{}, len(projectIDs))
|
||||||
|
for _, projectID := range projectIDs {
|
||||||
|
if _, duplicate := seen[projectID]; duplicate {
|
||||||
|
return http.StatusBadRequest, fmt.Sprintf("project %d is assigned more than once", projectID)
|
||||||
|
}
|
||||||
|
seen[projectID] = struct{}{}
|
||||||
|
project, ok := projects[projectID]
|
||||||
|
if !ok || !project.CanBeAccessedByOwnerRepo(ctx.Repo.Repository.OwnerID, ctx.Repo.Repository) {
|
||||||
|
return http.StatusBadRequest, fmt.Sprintf("project %d is not accessible from this repository", projectID)
|
||||||
|
}
|
||||||
|
if project.IsClosed {
|
||||||
|
return http.StatusForbidden, "project is closed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
|
||||||
// CreateIssue create an issue of a repository
|
// CreateIssue create an issue of a repository
|
||||||
func CreateIssue(ctx *context.APIContext) {
|
func CreateIssue(ctx *context.APIContext) {
|
||||||
// swagger:operation POST /repos/{owner}/{repo}/issues issue issueCreateIssue
|
// swagger:operation POST /repos/{owner}/{repo}/issues issue issueCreateIssue
|
||||||
@@ -568,6 +601,16 @@ func CreateIssue(ctx *context.APIContext) {
|
|||||||
// "$ref": "#/responses/repoArchivedError"
|
// "$ref": "#/responses/repoArchivedError"
|
||||||
|
|
||||||
form := web.GetForm(ctx).(*api.CreateIssueOption)
|
form := web.GetForm(ctx).(*api.CreateIssueOption)
|
||||||
|
if len(form.Projects) > 0 {
|
||||||
|
if status, message := validateProjectAssignments(ctx, form.Projects); status != 0 {
|
||||||
|
if status == http.StatusInternalServerError {
|
||||||
|
ctx.APIErrorInternal(errors.New(message))
|
||||||
|
} else {
|
||||||
|
ctx.APIError(status, message)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
var deadlineUnix timeutil.TimeStamp
|
var deadlineUnix timeutil.TimeStamp
|
||||||
if form.Deadline != nil && ctx.Repo.Permission.CanWrite(unit.TypeIssues) {
|
if form.Deadline != nil && ctx.Repo.Permission.CanWrite(unit.TypeIssues) {
|
||||||
deadlineUnix = timeutil.TimeStamp(form.Deadline.Unix())
|
deadlineUnix = timeutil.TimeStamp(form.Deadline.Unix())
|
||||||
@@ -727,6 +770,16 @@ func EditIssue(ctx *context.APIContext) {
|
|||||||
ctx.APIError(http.StatusConflict, issues_model.ErrIssueAlreadyChanged.Error())
|
ctx.APIError(http.StatusConflict, issues_model.ErrIssueAlreadyChanged.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if form.Projects != nil {
|
||||||
|
if status, message := validateProjectAssignments(ctx, *form.Projects); status != 0 {
|
||||||
|
if status == http.StatusInternalServerError {
|
||||||
|
ctx.APIErrorInternal(errors.New(message))
|
||||||
|
} else {
|
||||||
|
ctx.APIError(status, message)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if len(form.Title) > 0 {
|
if len(form.Title) > 0 {
|
||||||
err = issue_service.ChangeTitle(ctx, issue, ctx.Doer, form.Title)
|
err = issue_service.ChangeTitle(ctx, issue, ctx.Doer, form.Title)
|
||||||
|
|||||||
@@ -155,6 +155,19 @@ func TestAPIRepositoryProjects(t *testing.T) {
|
|||||||
MakeRequest(t, req, http.StatusForbidden)
|
MakeRequest(t, req, http.StatusForbidden)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("ClosedProjectCreate", 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", "/api/v1/repos/user2/repo1/issues", api.CreateIssueOption{
|
||||||
|
Title: "should be rejected",
|
||||||
|
Projects: []int64{1},
|
||||||
|
}).AddTokenAuth(writeToken)
|
||||||
|
MakeRequest(t, req, http.StatusForbidden)
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("ArchivedRepository", func(t *testing.T) {
|
t.Run("ArchivedRepository", func(t *testing.T) {
|
||||||
_, err := db.GetEngine(t.Context()).ID(repo.ID).Cols("is_archived").Update(&repo_model.Repository{IsArchived: true})
|
_, err := db.GetEngine(t.Context()).ID(repo.ID).Cols("is_archived").Update(&repo_model.Repository{IsArchived: true})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
Reference in New Issue
Block a user