diff --git a/package-lock.json b/package-lock.json index 995a6a5..a3bcfd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,8 @@ "@tauri-apps/api": "^2.5.0", "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-updater": "^2.10.1", + "dompurify": "^3.4.15", + "marked": "^18.0.12", "simple-icons": "^16.24.1", "svelte": "^5.0.0", "tailwindcss": "^4.3.1" @@ -1557,6 +1559,15 @@ "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", "license": "MIT" }, + "node_modules/dompurify": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", + "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/enhanced-resolve": { "version": "5.21.6", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", @@ -1975,6 +1986,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "18.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.12.tgz", + "integrity": "sha512-LEm4ga2YeI2T3GVHj9b0BaDPPk93LLTHMFeMyQbNIzPxc8vCI0y/scy0ZA6z6lXKyT9j9Nhl/OC6ZYKYGuFScA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", diff --git a/package.json b/package.json index dc26834..41319ff 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "@tauri-apps/api": "^2.5.0", "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-updater": "^2.10.1", + "dompurify": "^3.4.15", + "marked": "^18.0.12", "simple-icons": "^16.24.1", "svelte": "^5.0.0", "tailwindcss": "^4.3.1" diff --git a/src-tauri/src/integrations.rs b/src-tauri/src/integrations.rs index 5443c00..118fcb8 100644 --- a/src-tauri/src/integrations.rs +++ b/src-tauri/src/integrations.rs @@ -1,3 +1,12 @@ +mod issue_actions; +pub use issue_actions::*; +mod issue_comments; +pub use issue_comments::*; +mod boards; +pub use boards::*; +mod issues; +pub use issues::*; + use crate::git::load_stored_credential; use reqwest::blocking::{Client, Response}; use reqwest::header::{ACCEPT, USER_AGENT}; diff --git a/src-tauri/src/integrations/board_discovery.rs b/src-tauri/src/integrations/board_discovery.rs new file mode 100644 index 0000000..79d359c --- /dev/null +++ b/src-tauri/src/integrations/board_discovery.rs @@ -0,0 +1,177 @@ +//! Discover boards without changing provider data. Return partial results with explicit warnings. +use super::*; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BoardReference { title: String, web_url: String, scope: String } +#[derive(Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BoardDirectory { boards: Vec, warnings: Vec } + +impl BoardDirectory { + fn add(&mut self, api: &BoardApi, title: String, web_url: String, scope: String) { + if board_path(api.base, &web_url).is_ok() && !self.boards.iter().any(|b| b.web_url == web_url) { + self.boards.push(BoardReference { title, web_url, scope }); + } + } + fn warn(&mut self, scope: &str, error: String) { self.warnings.push(format!("{scope}: {error}")); } +} + +fn rest_directory(api: &BoardApi, out: &mut BoardDirectory) -> Result<(), String> { + if api.provider == "gitea" { + // The deployed server schema is more useful than guessing from its version. + if let Ok(schema) = api.get(endpoint(api.base, &["swagger.v1.json"])?) { + if schema["paths"].as_object().is_some_and(|paths| !paths.contains_key("/repos/{owner}/{repo}/projects")) { + return Err("This Gitea server publishes no Projects/Columns API. Automatic board discovery and import are unavailable; use the original web board.".into()); + } + } + let repos = api.pages(endpoint(api.base, &["api", "v1", "user", "repos"])?, &[])?; + for repo in repos { + if repo["has_projects"] == false { continue; } + let full = value_string(&repo, &["full_name"]); + let Some((owner, name)) = full.split_once('/') else { continue; }; + let result = api.pages(endpoint(api.base, &["api", "v1", "repos", owner, name, "projects"])?, &[("state".into(), "open".into())]); + match result { + Ok(boards) => for board in boards { + let id = json_id(&board); + out.add(api, value_string(&board, &["title"]), endpoint(api.base, &[owner, name, "projects", &id])?, full.clone()); + }, + Err(e) => out.warn(&full, e), + } + } + match api.pages(endpoint(api.base, &["api", "v1", "user", "orgs"])?, &[]) { + Ok(orgs) => for org in orgs { + let name = value_string(&org, &["username"]); + let name = if name.is_empty() { value_string(&org, &["name"]) } else { name }; + match api.pages(endpoint(api.base, &["api", "v1", "orgs", &name, "projects"])?, &[("state".into(), "open".into())]) { + Ok(boards) => for board in boards { out.add(api, value_string(&board, &["title"]), endpoint(api.base, &["org", &name, "projects", &json_id(&board)])?, name.clone()); }, + Err(e) => out.warn(&name, e), + } + }, + Err(e) => out.warn("Organizations", e), + } + } else { + for (scope, query) in [("projects", vec![("membership".into(), "true".into()), ("archived".into(), "false".into())]), ("groups", vec![("all_available".into(), "false".into())])] { + let entities = match api.pages(endpoint(api.base, &["api", "v4", scope])?, &query) { Ok(v) => v, Err(e) => { out.warn(scope, e); continue; } }; + for entity in entities { + let id = json_id(&entity); + let name = value_string(&entity, &[if scope == "groups" { "full_path" } else { "path_with_namespace" }]); + let base = value_string(&entity, &["web_url"]); + match api.pages(endpoint(api.base, &["api", "v4", scope, &id, "boards"])?, &[]) { + Ok(boards) => for board in boards { + out.add(api, value_string(&board, &["name"]), format!("{}/-/boards/{}", base.trim_end_matches('/'), json_id(&board)), name.clone()); + }, + Err(e) => out.warn(&name, e), + } + } + } + } + Ok(()) +} + +fn github_directory(api: &BoardApi, out: &mut BoardDirectory) -> Result<(), String> { + let graphql = "https://api.github.com/graphql".to_string(); + let mut owners = vec![]; + let mut cursor = Value::Null; + for page in 0..20 { + let data = api.graphql(graphql.clone(), "query($cursor:String){viewer{login organizations(first:100,after:$cursor){nodes{login} pageInfo{hasNextPage endCursor}}}}", json!({"cursor":cursor}))?; + let viewer = &data["data"]["viewer"]; + if page == 0 { owners.push(("user", value_string(viewer, &["login"]))); } + let orgs = &viewer["organizations"]; + for org in orgs["nodes"].as_array().ok_or("Could not read GitHub organizations.")? { owners.push(("organization", value_string(org, &["login"]))); } + if orgs["pageInfo"]["hasNextPage"] != true { break; } + cursor = orgs["pageInfo"]["endCursor"].clone(); + if page == 19 { out.warn("GitHub", "Organization page limit reached.".into()); } + } + for (kind, owner) in owners { + let query = format!("query($owner:String!,$cursor:String){{{kind}(login:$owner){{projectsV2(first:100,after:$cursor){{nodes{{title url closed}} pageInfo{{hasNextPage endCursor}}}}}}}}"); + let mut cursor = Value::Null; + for page in 0..20 { + let data = match api.graphql(graphql.clone(), &query, json!({"owner":owner,"cursor":cursor})) { Ok(v) => v, Err(e) => { out.warn(&owner, e); break; } }; + let projects = &data["data"][kind]["projectsV2"]; + let Some(nodes) = projects["nodes"].as_array() else { out.warn(&owner, "Project list unavailable; check read:project access.".into()); break; }; + for board in nodes { if board["closed"] != true { out.add(api, value_string(board, &["title"]), value_string(board, &["url"]), owner.clone()); } } + if projects["pageInfo"]["hasNextPage"] != true { break; } + cursor = projects["pageInfo"]["endCursor"].clone(); + if page == 19 { out.warn(&owner, "Project page limit reached.".into()); } + } + } + Ok(()) +} + +fn azure_directory(api: &BoardApi, out: &mut BoardDirectory) -> Result<(), String> { + for page in 0..20 { + let teams = api.read(api.request(reqwest::Method::GET, endpoint(api.base, &["_apis", "teams"])?) + .query(&[("api-version", "7.1-preview.3".to_string()), ("$top", "100".into()), ("$skip", (page * 100).to_string())]))?; + let teams = teams["value"].as_array().ok_or("Could not read Azure teams.")?; + if teams.is_empty() { return Ok(()); } + for team in teams { + let project = value_string(team, &["projectName"]); + let name = value_string(team, &["name"]); + match api.read(api.request(reqwest::Method::GET, endpoint(api.base, &[&project, &name, "_apis", "work", "boards"])?) + .query(&[("api-version", "7.1")])) { + Ok(data) => { + let Some(boards) = data["value"].as_array() else { out.warn(&name, "Invalid Azure board list.".into()); continue; }; + for board in boards { + let title = value_string(board, &["name"]); + out.add(api, title.clone(), endpoint(api.base, &[&project, "_boards", "board", "t", &name, &title])?, format!("{project} / {name}")); + } + }, + Err(e) => out.warn(&name, e), + } + } + } + out.warn("Azure", "Team page limit reached.".into()); + Ok(()) +} + +#[tauri::command] +pub async fn list_integration_boards(provider: String, base_url: String, username: String, token: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + if token.trim().is_empty() { return Err("No token stored for this integration.".into()); } + let base = normalized_base_url(&base_url)?; + let api = BoardApi { client: Client::builder().connect_timeout(Duration::from_secs(7)).timeout(Duration::from_secs(12)).redirect(reqwest::redirect::Policy::none()).build().map_err(|e| e.to_string())?, provider: &provider, base: &base, username: &username, token: &token, started: Instant::now() }; + let mut out = BoardDirectory::default(); + let result = match provider.as_str() { + "gitea" | "gitlab" | "gitlab-self-hosted" => rest_directory(&api, &mut out), + "github" => github_directory(&api, &mut out), + "azure-devops" => azure_directory(&api, &mut out), + _ => Err("Unsupported board provider.".into()), + }; + if let Err(e) = result { out.warn("Board discovery", e); } + out.boards.sort_by(|a,b| (&a.scope, &a.title).cmp(&(&b.scope, &b.title))); + Ok(out) + }).await.map_err(|e| e.to_string())? +} + +#[cfg(test)] +mod tests { + use super::*; + use super::super::tests::{mock_api, test_api}; + #[test] + fn missing_gitea_routes_are_reported_without_querying_repositories() { + let (base, worker) = mock_api(vec![("/swagger.v1.json ", json!({"paths":{"/repos/{owner}/{repo}":{}}}))]); + let mut out = BoardDirectory::default(); + let error = rest_directory(&test_api(&base,"gitea"), &mut out).unwrap_err(); + assert!(error.contains("publishes no Projects/Columns API")); + assert!(out.boards.is_empty()); + worker.join().unwrap(); + } + #[test] + fn gitea_discovers_multiple_repository_boards_and_paginates() { + let (base, worker) = mock_api(vec![ + ("/swagger.v1.json ",json!({"paths":{"/repos/{owner}/{repo}/projects":{}}})), + ("/user/repos?page=1",json!([{"full_name":"team/repo"}])), + ("/user/repos?page=2",json!([])), + ("/repos/team/repo/projects?state=open&page=1",json!([{"id":1,"title":"Delivery"},{"id":2,"title":"Roadmap"}])), + ("/repos/team/repo/projects?state=open&page=2",json!([])), + ("/user/orgs?page=1",json!([])), + ]); + let mut out = BoardDirectory::default(); + rest_directory(&test_api(&base,"gitea"), &mut out).unwrap(); + assert_eq!(out.boards.len(),2); + assert_eq!(out.boards[0].web_url,format!("{base}/team/repo/projects/1")); + assert!(out.warnings.is_empty()); + worker.join().unwrap(); + } +} diff --git a/src-tauri/src/integrations/board_moves.rs b/src-tauri/src/integrations/board_moves.rs new file mode 100644 index 0000000..bc44878 --- /dev/null +++ b/src-tauri/src/integrations/board_moves.rs @@ -0,0 +1,115 @@ +//! Persist one user-requested column move. Resolve all mutation metadata from a fresh board. +use super::*; + +fn gitlab_move_body(board: &IntegrationBoard, source: &BoardColumn, target: &BoardColumn) -> Result { + let metadata = target.move_target.as_ref().ok_or("This board grouping does not support moving cards.")?; + let label = value_string(metadata, &["label"]); + // Only remove this board's grouping labels. Unrelated issue labels stay intact. + let remove = board.columns.iter().filter(|c| target.id == "open" || c.id == source.id).filter_map(|c| c.move_target.as_ref()) + .map(|m| value_string(m, &["label"])) + .filter(|name| !name.is_empty() && *name != label).collect::>(); + if target.id != "closed" && remove.iter().any(|label| strings(metadata, "protectedLabels", "name").contains(label)) { + return Err("This move would remove a label required by the board filter. Open the original board to change its scope.".into()); + } + let mut body = json!({"state_event": if target.id == "closed" { "close" } else { "reopen" }}); + if target.id != "closed" { + body["remove_labels"] = json!(remove.join(",")); + if !label.is_empty() { body["add_labels"] = json!(label); } + } + Ok(body) +} + +fn azure_patch(item: &Value, metadata: &Value) -> Result { + let kind = value_string(item, &["fields", "System.WorkItemType"]); + let state = value_string(&metadata["states"], &[&kind]); + if state.is_empty() { return Err("The target column has no state mapping for this work item type.".into()); } + let rev = item["rev"].as_u64().ok_or("Azure returned no work item revision.")?; + let field = value_string(metadata, &["columnField"]); + wiql_field(&field)?; + let mut patch = vec![json!({"op":"test","path":"/rev","value":rev}), + json!({"op":"add","path":"/fields/System.State","value":state}), + json!({"op":"add","path":format!("/fields/{field}"),"value":metadata["name"]})]; + let done_field = value_string(metadata, &["doneField"]); + if !done_field.is_empty() { + wiql_field(&done_field)?; + patch.push(json!({"op":"add","path":format!("/fields/{done_field}"),"value":metadata["done"]})); + } + Ok(json!(patch)) +} + +#[tauri::command] +pub async fn move_integration_board_card(provider: String, base_url: String, username: String, token: String, board_url: String, card_id: String, source_column_id: String, target_column_id: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + if token.trim().is_empty() { return Err("No token stored for this integration.".into()); } + let base = normalized_base_url(&base_url)?; + let path = board_path(&base, &board_url)?; + let api = BoardApi { client: Client::builder().connect_timeout(Duration::from_secs(7)).timeout(Duration::from_secs(12)).redirect(reqwest::redirect::Policy::none()).build().map_err(|e| e.to_string())?, provider: &provider, base: &base, username: &username, token: &token, started: Instant::now() }; + let board = match provider.as_str() { + "github" => github_board(&api, &path, &board_url)?, + "gitlab" | "gitlab-self-hosted" => gitlab_board(&api, &path, &board_url)?, + "azure-devops" => azure_board(&api, &path, &board_url)?, + _ => return Err("Moving cards is not supported for this integration.".into()), + }; + let card = board.columns.iter().find(|c| c.id == source_column_id).and_then(|c| c.cards.iter().find(|c| c.id == card_id)) + .ok_or("The card has moved or is no longer in this board. Refresh before trying again.")?; + let target = board.columns.iter().find(|c| c.id == target_column_id).ok_or("The target column no longer exists.")?; + let metadata = target.move_target.as_ref().ok_or("This column does not support moving cards.")?; + if source_column_id == target_column_id { return Ok(()); } + match provider.as_str() { + "github" => { + let mut input = json!({"projectId":metadata["project"],"fieldId":metadata["field"],"itemId":card.id}); + let query = if target.id == "unassigned" { + "mutation($input:ClearProjectV2ItemFieldValueInput!){clearProjectV2ItemFieldValue(input:$input){projectV2Item{id}}}" + } else { + input["value"] = json!({"singleSelectOptionId":target.id}); + "mutation($input:UpdateProjectV2ItemFieldValueInput!){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id}}}" + }; + api.graphql("https://api.github.com/graphql".into(), query, json!({"input":input}))?; + }, + "gitlab" | "gitlab-self-hosted" => { + if card.repository.is_empty() { return Err("The issue's repository could not be resolved.".into()); } + let source = board.columns.iter().find(|c| c.id == source_column_id).ok_or("Source column no longer exists.")?; + let body = gitlab_move_body(&board, source, target)?; + api.read(api.request(reqwest::Method::PUT, endpoint(&base, &["api","v4","projects",&card.repository,"issues",&card.number.to_string()])?).json(&body))?; + }, + "azure-devops" => { + let url = endpoint(&base, &["_apis","wit","workitems",&card.number.to_string()])?; + let item = api.read(api.request(reqwest::Method::GET,url.clone()).query(&[("api-version","7.1")]))?; + let source = board.columns.iter().find(|c| c.id == source_column_id).and_then(|c| c.move_target.as_ref()).ok_or("Source column no longer exists.")?; + let field = value_string(source, &["columnField"]); + let done = value_string(source, &["doneField"]); + if item["fields"][&field] != source["name"] || (!done.is_empty() && item["fields"][&done].as_bool().unwrap_or(false) != source["done"].as_bool().unwrap_or(false)) { + return Err("The card has moved since the board was read. Refresh before trying again.".into()); + } + let patch = azure_patch(&item,metadata)?; + api.read(api.request(reqwest::Method::PATCH,url).query(&[("api-version","7.1")]).header("Content-Type","application/json-patch+json").json(&patch))?; + }, + _ => unreachable!(), + } + Ok(()) + }).await.map_err(|e| e.to_string())? +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn label_move_preserves_unrelated_labels_and_close_keeps_assignments() { + let mut a = column("1".into(),"Todo".into()); a.move_target=Some(json!({"label":"Todo"})); + let mut b = column("2".into(),"Build".into()); b.move_target=Some(json!({"label":"Build"})); + let mut c = column("closed".into(),"Closed".into()); c.move_target=Some(json!({"state":"close"})); + let board=IntegrationBoard{title:String::new(),web_url:String::new(),notice:String::new(),columns:vec![a,b,c]}; + assert_eq!(gitlab_move_body(&board,&board.columns[0],&board.columns[1]).unwrap(),json!({"add_labels":"Build","remove_labels":"Todo","state_event":"reopen"})); + assert_eq!(gitlab_move_body(&board,&board.columns[0],&board.columns[2]).unwrap(),json!({"state_event":"close"})); + } + #[test] + fn azure_move_checks_revision_and_uses_team_specific_fields() { + let item=json!({"rev":7,"fields":{"System.WorkItemType":"Bug"}}); + let metadata=json!({"columnField":"WEF_Test.Column","doneField":"WEF_Test.Done","name":"Review","done":true,"states":{"Bug":"Active"}}); + let patch=azure_patch(&item,&metadata).unwrap(); + assert_eq!(patch[0],json!({"op":"test","path":"/rev","value":7})); + assert_eq!(patch[2]["path"],"/fields/WEF_Test.Column"); + assert_eq!(patch[3]["value"],true); + assert!(azure_patch(&json!({"rev":1,"fields":{"System.WorkItemType":"Task"}}),&metadata).is_err()); + } +} diff --git a/src-tauri/src/integrations/boards.rs b/src-tauri/src/integrations/boards.rs new file mode 100644 index 0000000..c058b03 --- /dev/null +++ b/src-tauri/src/integrations/boards.rs @@ -0,0 +1,1165 @@ +//! Read provider-owned board columns and memberships; explicit moves live in board_moves. +use super::*; +use serde_json::{Value, json}; +use std::time::Instant; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BoardCard { + id: String, + title: String, + number: u64, + web_url: String, + repository: String, + description: String, + labels: Vec, + assignees: Vec, + lane: String, +} +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BoardColumn { + id: String, + title: String, + limit: u64, + cards: Vec, + move_target: Option, +} +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct IntegrationBoard { + title: String, + web_url: String, + columns: Vec, + notice: String, +} + +pub(super) struct BoardApi<'a> { + client: Client, + provider: &'a str, + base: &'a str, + username: &'a str, + token: &'a str, + started: Instant, +} +impl BoardApi<'_> { + fn request(&self, method: reqwest::Method, url: String) -> reqwest::blocking::RequestBuilder { + let request = self + .client + .request(method, url) + .header(USER_AGENT, "Gitty") + .header(ACCEPT, "application/json"); + match self.provider { + "github" => request.bearer_auth(self.token), + "gitlab" | "gitlab-self-hosted" => request.header("PRIVATE-TOKEN", self.token), + "gitea" => request.header("Authorization", format!("token {}", self.token)), + _ => request.basic_auth( + if self.username.is_empty() { + "gitty" + } else { + self.username + }, + Some(self.token), + ), + } + } + fn read(&self, request: reqwest::blocking::RequestBuilder) -> Result { + if self.started.elapsed() > Duration::from_secs(50) { + return Err("Board is too large to load within the time limit.".into()); + } + let response = request + .send() + .map_err(|e| format!("Could not load board: {e}"))?; + if !response.status().is_success() { + if self.provider == "gitea" && response.status() == reqwest::StatusCode::NOT_FOUND { + let schema = self.request(reqwest::Method::GET, endpoint(self.base, &["swagger.v1.json"])?).send().ok().filter(|r| r.status().is_success()).and_then(|r| r.json::().ok()); + if schema.as_ref().and_then(|s| s["paths"].as_object()).is_some_and(|paths| !paths.contains_key("/repos/{owner}/{repo}/projects/{id}/columns")) { + return Err("This Gitea server publishes no Projects/Columns API. Boards can exist in the web interface but cannot be imported with an API token. Open the original board.".into()); + } + return Err("Gitea returned 404 for this board. Check the board URL and token access; the server may not support the Projects/Columns API.".into()); + } + return Err(response_error(response, self.provider)); + } + let value: Value = response + .json() + .map_err(|e| format!("Invalid board response: {e}"))?; + if let Some(errors) = value.get("errors").and_then(Value::as_array) { + if !errors.is_empty() { + return Err(errors + .iter() + .map(|e| value_string(e, &["message"])) + .collect::>() + .join("; ")); + } + } + Ok(value) + } + fn get(&self, url: String) -> Result { + self.read(self.request(reqwest::Method::GET, url)) + } + fn graphql(&self, url: String, query: &str, variables: Value) -> Result { + self.read( + self.request(reqwest::Method::POST, url) + .json(&json!({"query":query,"variables":variables})), + ) + } + fn pages(&self, url: String, query: &[(String, String)]) -> Result, String> { + let mut items = vec![]; + let mut seen = BTreeSet::new(); + for page in 1..=20 { + let value = self.read( + self.request(reqwest::Method::GET, url.clone()) + .query(query) + .query(&[("page", page), ("per_page", 100), ("limit", 100)]), + )?; + let values = value + .as_array() + .ok_or("Expected a board list from the provider.")?; + if values.is_empty() { + return Ok(items); + } + items.extend( + values + .iter() + .filter(|value| { + let id = json_id(value); + id.is_empty() || seen.insert(id) + }) + .cloned(), + ); + // Ask for the following page even when the server clamps its page size. + } + Err("Board exceeds the supported page limit; open the original board for the complete view.".into()) + } +} + +fn endpoint(base: &str, segments: &[&str]) -> Result { + let mut url = reqwest::Url::parse(base).map_err(|_| "Invalid integration URL.")?; + url.path_segments_mut() + .map_err(|_| "Invalid integration URL.")? + .pop_if_empty() + .extend(segments); + Ok(url.to_string()) +} +fn decode_segment(value: &str) -> Result { + let bytes = value.as_bytes(); + let mut result = vec![]; + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + if i + 2 >= bytes.len() { + return Err("Invalid board URL encoding.".into()); + } + let hex = std::str::from_utf8(&bytes[i + 1..i + 3]) + .map_err(|_| "Invalid board URL encoding.")?; + result.push(u8::from_str_radix(hex, 16).map_err(|_| "Invalid board URL encoding.")?); + i += 3; + } else { + result.push(bytes[i]); + i += 1; + } + } + String::from_utf8(result).map_err(|_| "Invalid board URL encoding.".into()) +} +fn board_path(base: &str, board_url: &str) -> Result, String> { + let base = reqwest::Url::parse(base).map_err(|_| "Invalid integration URL.")?; + let url = + reqwest::Url::parse(board_url).map_err(|_| "Paste the full URL of the original board.")?; + if !["https", "http"].contains(&url.scheme()) + || base.origin() != url.origin() + || !url.username().is_empty() + || url.password().is_some() + { + return Err("The board URL must belong to the selected integration.".into()); + } + let prefix = format!("{}/", base.path().trim_end_matches('/')); + let path = url + .path() + .strip_prefix(&prefix) + .ok_or("The board URL must belong to the configured organization or server path.")?; + path.trim_matches('/') + .split('/') + .map(decode_segment) + .collect() +} +fn column(id: String, title: String) -> BoardColumn { + BoardColumn { + id, + title, + limit: 0, + cards: vec![], + move_target: None, + } +} +fn strings(value: &Value, key: &str, name: &str) -> Vec { + value + .get(key) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|v| { + v.as_str() + .map(str::to_string) + .or_else(|| v.get(name).and_then(Value::as_str).map(str::to_string)) + }) + .collect() +} +fn rest_card(v: &Value, provider: &str) -> BoardCard { + let gitlab = provider.starts_with("gitlab"); + let reference = value_string(v, &["references", "full"]); + BoardCard { + id: json_id(v), + title: value_string(v, &["title"]), + number: value_u64(v, if gitlab { "iid" } else { "number" }), + web_url: value_string(v, &[if gitlab { "web_url" } else { "html_url" }]), + repository: if gitlab { + reference + .rsplit_once('#') + .map_or(reference.clone(), |(repo, _)| repo.to_string()) + } else { + value_string(v, &["repository", "full_name"]) + }, + description: value_string(v, &[if gitlab { "description" } else { "body" }]), + labels: strings(v, "labels", "name"), + assignees: strings(v, "assignees", if gitlab { "username" } else { "login" }), + lane: String::new(), + } +} + +fn gitea_board(api: &BoardApi, path: &[String], web_url: &str) -> Result { + let (scope, id) = match path { + [owner, repo, kind, id] if kind == "projects" => { + (vec!["repos", owner, repo, "projects"], id) + } + [kind, owner, projects, id] if kind == "org" && projects == "projects" => { + (vec!["orgs", owner, "projects"], id) + } + _ => { + return Err( + "Use a Gitea repository or organization project URL ending in /projects/123." + .into(), + ); + } + }; + let mut segments = vec!["api", "v1"]; + segments.extend(scope); + segments.push(id); + let project = api.get(endpoint(api.base, &segments)?)?; + segments.push("columns"); + let mut columns = api + .get(endpoint(api.base, &segments)?)? + .as_array() + .ok_or("Gitea returned no project columns.")? + .clone(); + columns.sort_by_key(|v| value_u64(v, "sorting")); + let mut result = vec![]; + for c in columns { + let id = json_id(&c); + let mut col = column(id.clone(), value_string(&c, &["title"])); + let mut parts = segments.clone(); + parts.extend([id.as_str(), "issues"]); + col.cards = api + .pages(endpoint(api.base, &parts)?, &[])? + .iter() + .map(|v| rest_card(v, "gitea")) + .collect(); + result.push(col); + } + Ok(IntegrationBoard { + title: value_string(&project, &["title"]), + web_url: web_url.into(), + columns: result, + notice: String::new(), + }) +} + +fn gitlab_board( + api: &BoardApi, + path: &[String], + web_url: &str, +) -> Result { + let marker = path + .iter() + .position(|p| p == "-") + .ok_or("Use a GitLab board URL ending in /-/boards/123.")?; + if path.get(marker + 1).map(String::as_str) != Some("boards") { + return Err("Use a GitLab issue board URL.".into()); + } + let id = path + .get(marker + 2) + .ok_or("Include the board number in the URL.")?; + let group = path.first().map(String::as_str) == Some("groups"); + let project = path[if group { 1 } else { 0 }..marker].join("/"); + let scope = if group { "groups" } else { "projects" }; + let board = api.get(endpoint( + api.base, + &["api", "v4", scope, &project, "boards", id], + )?)?; + let mut lists = api.pages( + endpoint( + api.base, + &["api", "v4", scope, &project, "boards", id, "lists"], + )?, + &[], + )?; + lists.sort_by_key(|l| l.get("position").and_then(Value::as_i64).unwrap_or(0)); + let mut filters = vec![ + ("scope".into(), "all".into()), + ("order_by".into(), "relative_position".into()), + ("sort".into(), "asc".into()), + ]; + let labels = strings(&board, "labels", "name"); + if !labels.is_empty() { + filters.push(("labels".into(), labels.join(","))); + } + for (key, param) in [("assignee", "assignee_id"), ("iteration", "iteration_id")] { + if let Some(id) = board + .get(key) + .and_then(|v| v.get("id")) + .and_then(Value::as_u64) + { + filters.push((param.into(), id.to_string())); + } + } + if let Some(milestone) = board.get("milestone").filter(|v| !v.is_null()) { + let title = value_string(milestone, &["title"]); + if title.is_empty() { + return Err("This board uses a milestone filter that cannot be read by the Issues API. Open the original board.".into()); + } + filters.push(("milestone".into(), title)); + } + if let Some(weight) = board + .get("weight") + .and_then(Value::as_i64) + .filter(|n| *n >= 0) + { + filters.push(("weight".into(), weight.to_string())); + } + if board + .get("iteration_cadence_id") + .is_some_and(|v| !v.is_null()) + { + return Err( + "Iteration-cadence board filters are not supported yet. Open the original board." + .into(), + ); + } + for key in ["assignee", "milestone", "iteration"] { + if let Some(entity) = board.get(key).filter(|v| !v.is_null()) { + if entity.get("id").and_then(Value::as_u64).unwrap_or(0) == 0 { + return Err(format!( + "The board uses a special {key} filter that cannot be imported reliably. Open the original board." + )); + } + } + } + if labels.iter().any(|label| label.contains(',')) { + return Err("This board uses a label filter containing a comma that the Issues API cannot represent reliably.".into()); + } + let values = api.pages( + endpoint(api.base, &["api", "v4", scope, &project, "issues"])?, + &filters, + )?; + let mut columns = vec![]; + let mut matched = BTreeSet::new(); + let label_board = lists.iter().all(|list| list["label"].is_object() && !value_string(&list["label"], &["name"]).contains(',')); + for list in lists { + let (kind, entity) = ["label", "assignee", "milestone", "iteration"] + .into_iter() + .find_map(|kind| list.get(kind).filter(|v| !v.is_null()).map(|v| (kind, v))) + .ok_or( + "This GitLab board contains an unsupported list type. Open the original board.", + )?; + let title = ["name", "title", "username"] + .iter() + .map(|key| value_string(entity, &[key])) + .find(|s| !s.is_empty()) + .unwrap_or_default(); + if kind != "label" && entity.get("id").and_then(Value::as_u64).unwrap_or(0) == 0 { + return Err("This board list has no supported assignment identifier.".into()); + } + let mut col = column(json_id(&list), title.clone()); + col.limit = value_u64(&list, "max_issue_count"); + if label_board { col.move_target = Some(json!({"label":title,"protectedLabels":labels})); } + for v in &values { + if value_string(v, &["state"]) != "opened" { + continue; + } + let matches = match kind { + "label" => strings(v, "labels", "name").contains(&title), + "assignee" => v + .get("assignees") + .and_then(Value::as_array) + .is_some_and(|items| { + items.iter().any(|item| item.get("id") == entity.get("id")) + }), + key => v.get(key).and_then(|v| v.get("id")) == entity.get("id"), + }; + if matches { + matched.insert(json_id(v)); + col.cards.push(rest_card(v, api.provider)); + } + } + columns.push(col); + } + if board.get("hide_backlog_list").and_then(Value::as_bool) != Some(true) { + let mut open = column("open".into(), "Open".into()); + if label_board { open.move_target = Some(json!({"state":"reopen","protectedLabels":labels})); } + open.cards = values + .iter() + .filter(|v| value_string(v, &["state"]) == "opened" && !matched.contains(&json_id(v))) + .map(|v| rest_card(v, api.provider)) + .collect(); + columns.insert(0, open); + } + if board.get("hide_closed_list").and_then(Value::as_bool) != Some(true) { + let mut closed = column("closed".into(), "Closed".into()); + if label_board { closed.move_target = Some(json!({"state":"close","protectedLabels":labels})); } + closed.cards = values + .iter() + .filter(|v| value_string(v, &["state"]) == "closed") + .map(|v| rest_card(v, api.provider)) + .collect(); + columns.push(closed); + } + Ok(IntegrationBoard { + title: value_string(&board, &["name"]), + web_url: web_url.into(), + columns, + notice: "Saved board scope is applied. Temporary browser search filters are not imported." + .into(), + }) +} + +fn github_board( + api: &BoardApi, + path: &[String], + web_url: &str, +) -> Result { + github_api_base_url(api.base)?; + github_board_from_api(api, path, web_url, "https://api.github.com/graphql") +} + +fn github_board_from_api( + api: &BoardApi, + path: &[String], + web_url: &str, + graphql_url: &str, +) -> Result { + let (owner_type, owner, number) = + match path { + [kind, owner, projects, number, ..] + if (kind == "orgs" || kind == "users") && projects == "projects" => + { + ( + if kind == "orgs" { + "organization" + } else { + "user" + }, + owner, + number + .parse::() + .map_err(|_| "Invalid project number.")?, + ) + } + _ => return Err( + "Use a GitHub Projects URL: /orgs/OWNER/projects/123 or /users/OWNER/projects/123." + .into(), + ), + }; + let graphql = graphql_url.to_string(); + let query = format!( + r#"query($owner:String!,$number:Int!) {{ {owner_type}(login:$owner) {{ projectV2(number:$number) {{ + id title fields(first:100) {{ pageInfo {{ hasNextPage }} nodes {{ ... on ProjectV2SingleSelectField {{ id name options {{ id name }} }} }} }} + views(first:100) {{ pageInfo {{ hasNextPage }} nodes {{ number name layout filter groupByFields(first:10) {{ nodes {{ ... on ProjectV2SingleSelectField {{ id name options {{ id name }} }} }} }} }} }} + }} }} }}"# + ); + let payload = api.graphql( + graphql.clone(), + &query, + json!({"owner":owner,"number":number}), + )?; + let project = &payload["data"][owner_type]["projectV2"]; + if project.is_null() { + return Err( + "Project not found. The token needs read access to this GitHub Project.".into(), + ); + } + if project["fields"]["pageInfo"]["hasNextPage"] == true + || project["views"]["pageInfo"]["hasNextPage"] == true + { + return Err("This project has more fields or views than can be imported. Open the original project.".into()); + } + let view_number = path + .iter() + .position(|p| p == "views") + .and_then(|i| path.get(i + 1)) + .map(|n| n.parse::().map_err(|_| "Invalid view number.")) + .transpose()?; + let views = project["views"]["nodes"] + .as_array() + .ok_or("GitHub returned no project views.")?; + let view = views + .iter() + .find(|v| view_number.map_or(v["layout"] == "BOARD_LAYOUT", |n| v["number"] == n)) + .ok_or("No matching board view found in this GitHub Project.")?; + if view["layout"] != "BOARD_LAYOUT" { + return Err("The selected GitHub view is not a board view.".into()); + } + let fields = view["groupByFields"]["nodes"] + .as_array() + .ok_or("The board has no grouping field.")?; + let field = fields.iter().find(|f| f.get("options").is_some()).ok_or( + "Only GitHub board views grouped by a single-select field are currently supported.", + )?; + if fields.len() != 1 { + return Err("This GitHub view uses a grouping that cannot be imported yet.".into()); + } + let field_id = value_string(field, &["id"]); + let mut columns: Vec<_> = field["options"] + .as_array() + .ok_or("No board columns returned.")? + .iter() + .map(|v| column(value_string(v, &["id"]), value_string(v, &["name"]))) + .collect(); + let mut unassigned = column( + "unassigned".into(), + format!("No {}", value_string(field, &["name"])), + ); + let query = r#"query($id:ID!,$after:String) { node(id:$id) { ... on ProjectV2 { items(first:100,after:$after) { + pageInfo { hasNextPage endCursor } nodes { id isArchived fieldValues(first:100) { pageInfo { hasNextPage } nodes { + ... on ProjectV2ItemFieldSingleSelectValue { optionId field { ... on ProjectV2SingleSelectField { id } } } + } } content { + ... on Issue { title number body url repository { nameWithOwner } labels(first:50) { nodes { name } } assignees(first:50) { nodes { login } } } + ... on PullRequest { title number body url repository { nameWithOwner } labels(first:50) { nodes { name } } assignees(first:50) { nodes { login } } } + ... on DraftIssue { title body assignees(first:50) { nodes { login } } } + } } + } } } }"#; + let mut after = Value::Null; + let mut finished = false; + for _ in 0..20 { + let page = api.graphql( + graphql.clone(), + query, + json!({"id":project["id"],"after":after}), + )?; + let connection = &page["data"]["node"]["items"]; + for item in connection["nodes"] + .as_array() + .ok_or("No project items returned.")? + { + if item["isArchived"] == true { + continue; + } + if item["fieldValues"]["pageInfo"]["hasNextPage"] == true { + return Err( + "Project item has too many fields to assign its board column reliably.".into(), + ); + } + let option = item["fieldValues"]["nodes"] + .as_array() + .into_iter() + .flatten() + .find(|v| v["field"]["id"] == field_id) + .map(|v| value_string(v, &["optionId"])) + .unwrap_or_default(); + let content = &item["content"]; + let card = BoardCard { + id: value_string(item, &["id"]), + title: if content.is_null() { + "Restricted item".into() + } else { + value_string(content, &["title"]) + }, + number: value_u64(content, "number"), + web_url: value_string(content, &["url"]), + repository: value_string(content, &["repository", "nameWithOwner"]), + description: value_string(content, &["body"]), + labels: strings(&content["labels"], "nodes", "name"), + assignees: strings(&content["assignees"], "nodes", "login"), + lane: String::new(), + }; + if let Some(col) = columns.iter_mut().find(|c| c.id == option) { + col.cards.push(card); + } else { + unassigned.cards.push(card); + } + } + if connection["pageInfo"]["hasNextPage"] != true { + finished = true; + break; + } + let next = connection["pageInfo"]["endCursor"].clone(); + if next.is_null() || next == after { + return Err("GitHub returned an invalid pagination cursor.".into()); + } + after = next; + } + if !finished { + return Err( + "Project exceeds the supported 2,000-item limit. Open the original project.".into(), + ); + } + columns.insert(0, unassigned); + for col in &mut columns { col.move_target = Some(json!({"project":project["id"],"field":field_id})); } + let filter = value_string(view, &["filter"]); + Ok(IntegrationBoard { + title: format!( + "{} · {}", + value_string(project, &["title"]), + value_string(view, &["name"]) + ), + web_url: web_url.into(), + columns, + notice: if filter.trim().is_empty() { + "Archived cards are omitted. Columns use the board's single-select options.".into() + } else { + format!( + "The saved GitHub view filter is not applied: {filter}. All non-archived project cards are shown in their original columns." + ) + }, + }) +} + +fn wiql_literal(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} +fn wiql_field(value: &str) -> Result { + if value.is_empty() + || !value + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_') + { + return Err("Invalid Azure board field reference.".into()); + } + Ok(format!("[{value}]")) +} +fn azure_board(api: &BoardApi, path: &[String], web_url: &str) -> Result { + let marker = path + .iter() + .position(|p| p == "_boards") + .ok_or("Use an Azure Boards URL containing /_boards/board/t/TEAM/Stories.")?; + let project = path + .first() + .ok_or("The Azure board URL must include the project.")?; + let team_index = path + .iter() + .position(|p| p == "t") + .ok_or("The Azure board URL must include /t/TEAM/.")?; + let team = path + .get(team_index + 1) + .ok_or("The Azure board URL must include the team.")?; + let name = path + .get(team_index + 2) + .ok_or("The Azure board URL must include the board name, for example Stories.")?; + if marker == 0 { + return Err("The Azure board URL must include the project.".into()); + } + let board_url = endpoint(api.base, &[project, team, "_apis", "work", "boards", name])?; + let board = api.read( + api.request(reqwest::Method::GET, board_url) + .query(&[("api-version", "7.1")]), + )?; + let column_field = value_string(&board, &["fields", "columnField", "referenceName"]); + let done_field = value_string(&board, &["fields", "doneField", "referenceName"]); + let row_field = value_string(&board, &["fields", "rowField", "referenceName"]); + let field = wiql_field(&column_field)?; + let mut conditions = vec![ + format!("[System.TeamProject] = {}", wiql_literal(project)), + format!("{field} <> ''"), + ]; + let settings = api.read( + api.request( + reqwest::Method::GET, + endpoint( + api.base, + &[ + project, + team, + "_apis", + "work", + "teamsettings", + "teamfieldvalues", + ], + )?, + ) + .query(&[("api-version", "7.1")]), + )?; + let team_field = wiql_field(&value_string(&settings, &["field", "referenceName"]))?; + let areas = settings["values"] + .as_array() + .ok_or("Could not read the team's board scope.")?; + if areas.is_empty() { + return Err("The Azure team has no configured board area paths.".into()); + } + conditions.push(format!( + "({})", + areas + .iter() + .map(|area| format!( + "{team_field} {} {}", + if area["includeChildren"] == true { + "UNDER" + } else { + "=" + }, + wiql_literal(&value_string(area, &["value"])) + )) + .collect::>() + .join(" OR ") + )); + let raw_columns = board["columns"] + .as_array() + .ok_or("Azure returned no board columns.")?; + let work_types: BTreeSet<_> = raw_columns + .iter() + .flat_map(|col| { + col["stateMappings"] + .as_object() + .into_iter() + .flat_map(|map| map.keys().cloned()) + }) + .collect(); + if !work_types.is_empty() { + conditions.push(format!( + "[System.WorkItemType] IN ({})", + work_types + .iter() + .map(|t| wiql_literal(t)) + .collect::>() + .join(",") + )); + } + let query = format!( + "SELECT [System.Id] FROM WorkItems WHERE {} ORDER BY [System.Id]", + conditions.join(" AND ") + ); + let ids = api.read( + api.request( + reqwest::Method::POST, + endpoint(api.base, &[project, "_apis", "wit", "wiql"])?, + ) + .query(&[("api-version", "7.1"), ("$top", "2001")]) + .json(&json!({"query":query})), + )?; + let ids: Vec = ids["workItems"] + .as_array() + .ok_or("Azure returned no board work items.")? + .iter() + .filter_map(|v| v["id"].as_u64()) + .collect(); + if ids.len() > 2000 { + return Err( + "Board exceeds the supported 2,000-card limit. Open the original board.".into(), + ); + } + let mut values = vec![]; + for chunk in ids.chunks(200) { + let ids = chunk + .iter() + .map(u64::to_string) + .collect::>() + .join(","); + let page = api.read( + api.request( + reqwest::Method::GET, + endpoint(api.base, &["_apis", "wit", "workitems"])?, + ) + .query(&[("api-version", "7.1"), ("ids", ids.as_str())]), + )?; + values.extend( + page["value"] + .as_array() + .ok_or("Azure returned no board card details.")? + .clone(), + ); + } + // The provider's backlog rank orders cards within columns. + values.sort_by(|a, b| { + let rank = |v: &Value| { + v["fields"]["Microsoft.VSTS.Common.StackRank"] + .as_f64() + .or_else(|| v["fields"]["Microsoft.VSTS.Common.BacklogPriority"].as_f64()) + .unwrap_or(f64::MAX) + }; + rank(a) + .total_cmp(&rank(b)) + .then_with(|| value_u64(a, "id").cmp(&value_u64(b, "id"))) + }); + if raw_columns.iter().any(|c| c["isSplit"] == true) && done_field.is_empty() { + return Err("Azure did not return the field required to map split board columns.".into()); + } + let mut columns = vec![]; + for c in raw_columns { + for done in if c["isSplit"] == true { + vec![false, true] + } else { + vec![false] + } { + let title = value_string(c, &["name"]); + let split = c["isSplit"] == true; + let mut col = column( + format!("{}:{done}", value_string(c, &["id"])), + if split { + format!("{title} · {}", if done { "Done" } else { "Doing" }) + } else { + title.clone() + }, + ); + col.limit = value_u64(c, "itemLimit"); + col.move_target = Some(json!({"columnField":column_field,"doneField":done_field,"name":title,"done":done,"states":c["stateMappings"]})); + for v in &values { + if value_string(v, &["fields", &column_field]) != title { + continue; + } + if split && v["fields"][&done_field].as_bool().unwrap_or(false) != done { + continue; + } + let fields = &v["fields"]; + let number = value_u64(v, "id"); + col.cards.push(BoardCard { + id: number.to_string(), + number, + title: value_string(fields, &["System.Title"]), + web_url: format!("{}/_workitems/edit/{number}", api.base), + repository: project.clone(), + description: String::new(), + labels: value_string(fields, &["System.Tags"]) + .split(';') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(), + assignees: vec![value_string(fields, &["System.AssignedTo", "displayName"])] + .into_iter() + .filter(|s| !s.is_empty()) + .collect(), + lane: value_string(fields, &[&row_field]), + }); + } + columns.push(col); + } + } + Ok(IntegrationBoard { title: format!("{project} · {team} · {}",value_string(&board,&["name"])),web_url:web_url.into(),columns, + notice:"Team scope, board columns, split columns and swimlane names are imported. Temporary browser filters and the completed-card age limit are not applied.".into() }) +} + +#[tauri::command] +pub async fn get_integration_board( + provider: String, + base_url: String, + username: String, + token: String, + board_url: String, +) -> Result { + tokio::time::timeout( + Duration::from_secs(65), + tauri::async_runtime::spawn_blocking(move || { + if token.trim().is_empty() { + return Err("No token stored for this integration.".into()); + } + let base = normalized_base_url(&base_url)?; + let path = board_path(&base, &board_url)?; + let client = Client::builder() + .connect_timeout(Duration::from_secs(7)) + .timeout(Duration::from_secs(12)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|e| e.to_string())?; + let api = BoardApi { + client, + provider: &provider, + base: &base, + username: &username, + token: &token, + started: Instant::now(), + }; + match provider.as_str() { + "github" => github_board(&api, &path, &board_url), + "gitlab" | "gitlab-self-hosted" => gitlab_board(&api, &path, &board_url), + "gitea" => gitea_board(&api, &path, &board_url), + "azure-devops" => azure_board(&api, &path, &board_url), + _ => Err("Unsupported board provider.".into()), + } + }), + ) + .await + .map_err(|_| "Board loading timed out.".to_string())? + .map_err(|e| e.to_string())? +} + +#[path = "board_moves.rs"] +mod moves; +pub use moves::*; + +#[path = "board_discovery.rs"] +mod discovery; +pub use discovery::*; + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn board_urls_cannot_send_tokens_to_another_origin_or_organization() { + for url in [ + "https://evil.test/org/p/_boards/board/t/team/Stories", + "https://dev.azure.com/other/p/_boards/board/t/team/Stories", + "https://user:pass@dev.azure.com/org/p/_boards/board/t/team/Stories", + ] { + assert!(board_path("https://dev.azure.com/org", url).is_err()); + } + assert_eq!( + board_path( + "https://dev.azure.com/org", + "https://dev.azure.com/org/My%20Project/_boards/board/t/My%20Team/Stories" + ) + .unwrap()[0], + "My Project" + ); + } + #[test] + fn api_paths_and_query_literals_are_encoded() { + assert_eq!( + endpoint( + "https://gitlab.test/root", + &["api", "v4", "projects", "group/repo"] + ) + .unwrap(), + "https://gitlab.test/root/api/v4/projects/group%2Frepo" + ); + assert_eq!(wiql_literal("Team's Project"), "'Team''s Project'"); + assert!(wiql_field("Bad] OR [System.Id").is_err()); + } + #[test] + fn board_cards_keep_provider_identity_and_labels() { + let card = rest_card( + &json!({"id":5,"iid":7,"title":"Issue","references":{"full":"group/repo#7"},"labels":["Review"],"assignees":[{"username":"alex"}]}), + "gitlab", + ); + assert_eq!(card.number, 7); + assert_eq!(card.repository, "group/repo"); + assert_eq!(card.labels, ["Review"]); + assert_eq!(card.assignees, ["alex"]); + } + pub(super) fn mock_api(responses: Vec<(&'static str, Value)>) -> (String, std::thread::JoinHandle<()>) { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let worker = std::thread::spawn(move || { + for (expected_path, payload) in responses { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + let mut request = Vec::new(); + let mut buffer = [0; 4096]; + loop { + let count = stream.read(&mut buffer).unwrap(); + assert!(count > 0); + request.extend_from_slice(&buffer[..count]); + if let Some(end) = request.windows(4).position(|v| v == b"\r\n\r\n") { + let header = String::from_utf8_lossy(&request[..end]); + let length = header + .lines() + .find_map(|line| { + line.to_lowercase() + .strip_prefix("content-length:") + .map(|v| v.trim().parse::().unwrap()) + }) + .unwrap_or(0); + if request.len() >= end + 4 + length { + break; + } + } + } + let request = String::from_utf8_lossy(&request); + assert!( + request.lines().next().unwrap().contains(expected_path), + "Unexpected endpoint: {}", + request.lines().next().unwrap() + ); + let body = payload.to_string(); + write!(stream,"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",body.len(),body).unwrap(); + } + }); + (base, worker) + } + pub(super) fn test_api<'a>(base: &'a str, provider: &'a str) -> BoardApi<'a> { + BoardApi { + client: client().unwrap(), + provider, + base, + username: "test", + token: "test-token", + started: Instant::now(), + } + } + #[test] + fn gitlab_import_uses_real_labels_and_keeps_closed_cards_out_of_label_columns() { + let (base, worker) = mock_api(vec![ + ( + "/api/v4/projects/team%2Frepo/boards/1 ", + json!({"name":"Delivery"}), + ), + ( + "/lists?page=1", + json!([{"id":8,"label":{"name":"QA"},"position":2},{"id":7,"label":{"name":"Doing"},"position":1}]), + ), + ("/lists?page=2", json!([])), + ( + "/issues?", + json!([ + {"id":1,"iid":1,"title":"Unassigned","state":"opened","labels":[]}, + {"id":2,"iid":2,"title":"Development","state":"opened","labels":["Doing"]}, + {"id":3,"iid":3,"title":"Finished","state":"closed","labels":["Doing"]} + ]), + ), + ("/issues?", json!([])), + ]); + let board = gitlab_board( + &test_api(&base, "gitlab"), + &vec![ + "team".into(), + "repo".into(), + "-".into(), + "boards".into(), + "1".into(), + ], + "https://gitlab.test/team/repo/-/boards/1", + ) + .unwrap(); + assert_eq!( + board + .columns + .iter() + .map(|c| c.title.as_str()) + .collect::>(), + ["Open", "Doing", "QA", "Closed"] + ); + assert_eq!(board.columns[0].cards[0].title, "Unassigned"); + assert_eq!(board.columns[1].cards.len(), 1); + assert!(board.columns[2].cards.is_empty()); + assert_eq!(board.columns[3].cards[0].title, "Finished"); + worker.join().unwrap(); + } + #[test] + fn gitea_import_keeps_native_order_and_column_membership() { + let (base, worker) = mock_api(vec![ + ("/projects/1 ", json!({"title":"Roadmap"})), + ( + "/projects/1/columns ", + json!([{"id":2,"title":"QA","sorting":2},{"id":1,"title":"Build","sorting":1}]), + ), + ( + "/columns/1/issues?page=1", + json!([{"id":4,"number":4,"title":"Build task"}]), + ), + ("/columns/1/issues?page=2", json!([])), + ("/columns/2/issues?page=1", json!([])), + ]); + let board = gitea_board( + &test_api(&base, "gitea"), + &vec!["owner".into(), "repo".into(), "projects".into(), "1".into()], + "https://gitea.test/owner/repo/projects/1", + ) + .unwrap(); + assert_eq!(board.columns[0].title, "Build"); + assert_eq!(board.columns[0].cards[0].number, 4); + assert!(board.columns[1].cards.is_empty()); + worker.join().unwrap(); + } + #[test] + fn azure_import_uses_board_specific_fields_and_split_columns() { + let (base, worker) = mock_api(vec![ + ( + "/Project/Team/_apis/work/boards/Stories?", + json!({"name":"Stories","fields":{"columnField":{"referenceName":"WEF_Test.Column"},"doneField":{"referenceName":"WEF_Test.Done"},"rowField":{"referenceName":"WEF_Test.Row"}},"columns":[{"id":"a","name":"Build","isSplit":true,"itemLimit":3,"stateMappings":{"User Story":"Active"}},{"id":"b","name":"QA","isSplit":false}]}), + ), + ( + "/teamfieldvalues?", + json!({"field":{"referenceName":"System.AreaPath"},"values":[{"value":"Project","includeChildren":true}]}), + ), + ("/wiql?", json!({"workItems":[{"id":1},{"id":2}]})), + ( + "/workitems?", + json!({"value":[ + {"id":1,"fields":{"System.Title":"In progress","System.State":"Active","WEF_Test.Column":"Build","WEF_Test.Done":false,"WEF_Test.Row":"Expedite"}}, + {"id":2,"fields":{"System.Title":"Ready","System.State":"Active","WEF_Test.Column":"Build","WEF_Test.Done":true}} + ]}), + ), + ]); + let board = azure_board( + &test_api(&base, "azure-devops"), + &vec![ + "Project".into(), + "_boards".into(), + "board".into(), + "t".into(), + "Team".into(), + "Stories".into(), + ], + "https://dev.azure.com/org/Project/_boards/board/t/Team/Stories", + ) + .unwrap(); + assert_eq!(board.columns[0].title, "Build · Doing"); + assert_eq!(board.columns[1].title, "Build · Done"); + assert_eq!(board.columns[0].cards[0].title, "In progress"); + assert_eq!(board.columns[0].cards[0].lane, "Expedite"); + assert_eq!(board.columns[1].cards[0].title, "Ready"); + assert!(board.columns[2].cards.is_empty()); + worker.join().unwrap(); + } + + #[test] + fn github_import_uses_view_field_values_and_follows_item_cursors() { + let field = json!({"id":"field","name":"Workflow","options":[{"id":"ready","name":"Ready"},{"id":"qa","name":"Testing"}]}); + let (base, worker) = mock_api(vec![ + ( + "POST /graphql ", + json!({"data":{"organization":{"projectV2":{"id":"project","title":"Delivery","fields":{"pageInfo":{"hasNextPage":false}},"views":{"pageInfo":{"hasNextPage":false},"nodes":[{"number":2,"name":"Team board","layout":"BOARD_LAYOUT","filter":"","groupByFields":{"nodes":[field]}}]}}}}}), + ), + ( + "POST /graphql ", + json!({"data":{"node":{"items":{"pageInfo":{"hasNextPage":true,"endCursor":"second"},"nodes":[ + {"id":"item1","isArchived":false,"fieldValues":{"nodes":[{"optionId":"qa","field":{"id":"field"}}]},"content":{"title":"Testing item","number":3,"state":"CLOSED"}}, + {"id":"archived","isArchived":true,"content":{"title":"Archived"}} + ]}}}}), + ), + ( + "POST /graphql ", + json!({"data":{"node":{"items":{"pageInfo":{"hasNextPage":false},"nodes":[{"id":"draft","fieldValues":{"nodes":[]},"content":{"title":"Unassigned draft"}}]}}}}), + ), + ]); + let board = github_board_from_api( + &test_api(&base, "github"), + &vec![ + "orgs".into(), + "team".into(), + "projects".into(), + "1".into(), + "views".into(), + "2".into(), + ], + "https://github.com/orgs/team/projects/1/views/2", + &format!("{base}/graphql"), + ) + .unwrap(); + assert_eq!( + board + .columns + .iter() + .map(|c| c.title.as_str()) + .collect::>(), + ["No Workflow", "Ready", "Testing"] + ); + assert_eq!(board.columns[0].cards[0].title, "Unassigned draft"); + assert!(board.columns[1].cards.is_empty()); + assert_eq!(board.columns[2].cards[0].title, "Testing item"); + assert_eq!( + board.columns.iter().map(|c| c.cards.len()).sum::(), + 2 + ); + worker.join().unwrap(); + } +} diff --git a/src-tauri/src/integrations/issue_actions.rs b/src-tauri/src/integrations/issue_actions.rs new file mode 100644 index 0000000..0a108a6 --- /dev/null +++ b/src-tauri/src/integrations/issue_actions.rs @@ -0,0 +1,53 @@ +use super::*; +use super::issue_comments::{comment_url, request, comment_client}; +use serde_json::{Value,json}; +fn read(response: reqwest::blocking::Response, provider: &str) -> Result { + if !response.status().is_success() {return Err(response_error(response,provider));} + response.json().map_err(|e|e.to_string()) +} +fn completed_state(value: &Value) -> Result { + let states:Vec<_>=value["value"].as_array().into_iter().flatten().filter(|v|v["category"]=="Completed").filter_map(|v|v["name"].as_str()).filter(|s|!s.is_empty()).collect(); + if states.len()!=1 {return Err("No unique completed state is configured for this work item type. Open the issue in Azure to select its state.".into());} + Ok(states[0].into()) +} +#[tauri::command] +pub async fn close_integration_issue(provider:String,base_url:String,username:String,token:String,repository:String,number:u64) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let client=comment_client()?; + let mut url=comment_url(&provider,&base_url,&repository,number)?; + url.path_segments_mut().map_err(|_|"Invalid issue URL.")?.pop(); + if provider=="azure-devops" { + url.set_query(Some("api-version=7.1")); + let item=read(request(&client,reqwest::Method::GET,url.clone(),&provider,&username,&token)?.send().map_err(|e|e.to_string())?,&provider)?; + let kind=value_string(&item,&["fields","System.WorkItemType"]); + let mut states_url=reqwest::Url::parse(&normalized_base_url(&base_url)?).map_err(|e|e.to_string())?; + states_url.path_segments_mut().map_err(|_|"Invalid Azure URL.")?.pop_if_empty().extend([&repository,"_apis","wit","workitemtypes",&kind,"states"]); + states_url.set_query(Some("api-version=7.1")); + let states=read(request(&client,reqwest::Method::GET,states_url,&provider,&username,&token)?.send().map_err(|e|e.to_string())?,&provider)?; + let state=completed_state(&states)?; + if item["fields"]["System.State"]==state {return Ok(state);} + let rev=item["rev"].as_u64().ok_or("Azure returned no revision.")?; + let payload=json!([{"op":"test","path":"/rev","value":rev},{"op":"add","path":"/fields/System.State","value":state}]); + let result=read(request(&client,reqwest::Method::PATCH,url,&provider,&username,&token)?.header("Content-Type","application/json-patch+json").json(&payload).send().map_err(|e|e.to_string())?,&provider)?; + let actual=value_string(&result,&["fields","System.State"]); + if actual!=state {return Err("Azure did not confirm the completed state.".into());} + Ok(actual) + } else { + let gitlab=provider.starts_with("gitlab"); + let method=if gitlab {reqwest::Method::PUT}else{reqwest::Method::PATCH}; + let payload=if gitlab {json!({"state_event":"close"})}else{json!({"state":"closed"})}; + let result=read(request(&client,method,url,&provider,&username,&token)?.json(&payload).send().map_err(|e|format!("Closing was not confirmed: {e}"))?,&provider)?; + let state=value_string(&result,&["state"]); + if state!="closed" {return Err("The provider did not confirm the closed state.".into());} + Ok(state) + } + }).await.map_err(|e|e.to_string())? +} +#[cfg(test)] mod tests { + use super::*; + #[test] fn uses_custom_completed_state_and_rejects_ambiguous_configuration() { + assert_eq!(completed_state(&json!({"value":[{"name":"Review","category":"Resolved"},{"name":"Delivered","category":"Completed"}]})).unwrap(),"Delivered"); + assert!(completed_state(&json!({"value":[]})).is_err()); + assert!(completed_state(&json!({"value":[{"name":"A","category":"Completed"},{"name":"B","category":"Completed"}]})).is_err()); + } +} diff --git a/src-tauri/src/integrations/issue_comments.rs b/src-tauri/src/integrations/issue_comments.rs new file mode 100644 index 0000000..59ed552 --- /dev/null +++ b/src-tauri/src/integrations/issue_comments.rs @@ -0,0 +1,95 @@ +use super::*; +use serde_json::{Value, json}; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct IssueComment { id: String, author: String, body: String, created_at: String, body_html: bool } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct IssueCommentPage { comments: Vec, next_cursor: Option } + +pub(super) fn comment_url(provider: &str, base: &str, repository: &str, number: u64) -> Result { + if repository.trim().is_empty() || number == 0 { return Err("Missing issue repository or number.".into()); } + let base = normalized_base_url(base)?; + let mut url = reqwest::Url::parse(if provider == "github" { "https://api.github.com" } else { &base }).map_err(|e|e.to_string())?; + let num = number.to_string(); + let mut path = url.path_segments_mut().map_err(|_|"Invalid API URL.")?; + path.pop_if_empty(); + match provider { + "github" | "gitea" => { + let (owner, repo) = repository.split_once('/').filter(|(a,b)|!a.is_empty() && !b.is_empty() && !b.contains('/')).ok_or("Invalid issue repository.")?; + if provider == "gitea" { path.extend(["api","v1"]); } + path.extend(["repos",owner,repo,"issues",&num,"comments"]); + }, + "gitlab" | "gitlab-self-hosted" => { path.extend(["api","v4","projects",repository,"issues",&num,"notes"]); }, + "azure-devops" => { path.extend([repository,"_apis","wit","workItems",&num,"comments"]); }, + _ => return Err("Unsupported issue provider.".into()), + } + drop(path); + if provider == "azure-devops" { url.query_pairs_mut().append_pair("api-version","7.1-preview.4"); } + Ok(url) +} +pub(super) fn request(client: &Client, method: reqwest::Method, url: reqwest::Url, provider: &str, username: &str, token: &str) -> Result { + if token.trim().is_empty() { return Err("No token stored for this integration.".into()); } + let req = client.request(method,url).header(USER_AGENT,"Gitty").header(ACCEPT,"application/json"); + Ok(match provider { + "github" => req.bearer_auth(token), + "gitea" => req.header("Authorization",format!("token {token}")), + "gitlab" | "gitlab-self-hosted" => req.header("PRIVATE-TOKEN",token), + _ => req.basic_auth(if username.is_empty(){"gitty"}else{username},Some(token)), + }) +} +fn parse_comment(v: &Value, provider: &str) -> Option { + if v["system"] == true || v["isDeleted"] == true { return None; } + let azure = provider == "azure-devops"; + let id = if azure && v["id"].is_null() { json_id(&json!({"id":v["commentId"]})) } else { json_id(v) }; + Some(IssueComment { id, author: value_string(v, if azure { &["createdBy","displayName"] } else if provider.starts_with("gitlab") { &["author","username"] } else { &["user","login"] }), body:value_string(v,&[if azure{"text"}else{"body"}]), created_at:value_string(v,&[if azure{"createdDate"}else{"created_at"}]), body_html: azure && (v["format"] == "html" || v["format"] == 0) }) +} +pub(super) fn comment_client() -> Result { + Client::builder().timeout(Duration::from_secs(30)).connect_timeout(Duration::from_secs(7)).redirect(reqwest::redirect::Policy::none()).build().map_err(|e|e.to_string()) +} +#[tauri::command] +pub async fn list_integration_issue_comments(provider:String, base_url:String, username:String, token:String, repository:String, number:u64, cursor:Option) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let client=comment_client()?; + let mut url=comment_url(&provider,&base_url,&repository,number)?; + let page=if provider!="azure-devops" { cursor.as_deref().unwrap_or("1").parse::().map_err(|_|"Invalid comment page.")?.max(1) } else {1}; + if provider=="azure-devops" { + url.query_pairs_mut().append_pair("$top","100").append_pair("order","asc").append_pair("includeDeleted","false"); + if let Some(cursor)=cursor {url.query_pairs_mut().append_pair("continuationToken",&cursor);} + } else {url.query_pairs_mut().append_pair("page",&page.to_string()).append_pair("per_page","100").append_pair("limit","100").append_pair("sort","asc").append_pair("order_by","created_at");} + let response=request(&client,reqwest::Method::GET,url,&provider,&username,&token)?.send().map_err(|e|e.to_string())?; + if !response.status().is_success(){return Err(response_error(response,&provider));} + let linked_next=response.headers().get("link").and_then(|h|h.to_str().ok()).map(|s|s.contains("rel=\"next\"")); + let value:Value=response.json().map_err(|e|e.to_string())?; + let items=if provider=="azure-devops" {value["comments"].as_array()}else{value.as_array()}.ok_or("Invalid comment list.")?; + let next_cursor=if provider=="azure-devops" {value["continuationToken"].as_str().filter(|s|!s.is_empty()).map(str::to_string)}else if linked_next.unwrap_or(items.len()==100) {Some(page.saturating_add(1).to_string())}else{None}; + Ok(IssueCommentPage{comments:items.iter().filter_map(|v|parse_comment(v,&provider)).collect(),next_cursor}) + }).await.map_err(|e|e.to_string())? +} +#[tauri::command] +pub async fn add_integration_issue_comment(provider:String, base_url:String, username:String, token:String, repository:String, number:u64, body:String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + if body.trim().is_empty() || body.len()>100_000 {return Err("Comment must contain between 1 and 100,000 bytes.".into());} + let mut url=comment_url(&provider,&base_url,&repository,number)?; + if provider=="azure-devops" {url.query_pairs_mut().append_pair("format","markdown");} + let payload=if provider=="azure-devops"{json!({"text":body})}else{json!({"body":body})}; + let response=request(&comment_client()?,reqwest::Method::POST,url,&provider,&username,&token)?.json(&payload).send().map_err(|e|format!("Comment delivery not confirmed: {e}"))?; + if !response.status().is_success(){return Err(response_error(response,&provider));} + parse_comment(&response.json::().map_err(|e|e.to_string())?,&provider).ok_or("Invalid posted comment.".into()) + }).await.map_err(|e|e.to_string())? +} +#[cfg(test)] +mod tests { + use super::*; + #[test] fn issue_endpoints_use_issue_notes_and_encode_project_paths() { + assert_eq!(comment_url("gitlab","https://git.test","team/sub/repo",4).unwrap().as_str(),"https://git.test/api/v4/projects/team%2Fsub%2Frepo/issues/4/notes"); + assert_eq!(comment_url("azure-devops","https://dev.azure.com/org","My Project",4).unwrap().path(),"/org/My%20Project/_apis/wit/workItems/4/comments"); + assert!(comment_url("gitea","https://git.test","owner/repo/invalid",4).is_err()); + } + #[test] fn comments_filter_system_entries_and_preserve_html_format() { + assert!(parse_comment(&json!({"system":true}),"gitlab").is_none()); + let c=parse_comment(&json!({"commentId":7,"text":"

Hello

","format":"html","createdBy":{"displayName":"Alex"}}),"azure-devops").unwrap(); + assert_eq!(c.id,"7"); assert!(c.body_html); assert_eq!(c.author,"Alex"); + } +} diff --git a/src-tauri/src/integrations/issues.rs b/src-tauri/src/integrations/issues.rs new file mode 100644 index 0000000..82eca0d --- /dev/null +++ b/src-tauri/src/integrations/issues.rs @@ -0,0 +1,336 @@ +use super::*; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct IntegrationIssue { + id: String, + number: u64, + title: String, + description: String, + description_html: bool, + repository_name: String, + author: String, + state: String, + labels: Vec, + assignees: Vec, + web_url: String, + updated_at: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct IssuePage { + issues: Vec, + next_cursor: Option, +} + +fn parse_issue(value: &serde_json::Value, provider: &str) -> Option { + if value.get("pull_request").is_some_and(|v| !v.is_null()) { + return None; + } + let gitlab = provider.starts_with("gitlab"); + let number = value_u64(value, if gitlab { "iid" } else { "number" }); + if number == 0 { + return None; + } + let reference = value_string(value, &["references", "full"]); + let repository_name = if gitlab { + reference + .rsplit_once('#') + .map(|(repo, _)| repo.to_string()) + .unwrap_or_else(|| value_u64(value, "project_id").to_string()) + } else { + let name = value_string(value, &["repository", "full_name"]); + if name.is_empty() { + github_repository_name(&value_string(value, &["repository_url"])) + } else { + name + } + }; + Some(IntegrationIssue { + id: format!("{provider}:{}", value_u64(value, "id")), + number, + title: value_string(value, &["title"]), + description: value_string(value, &[if gitlab { "description" } else { "body" }]), + description_html: false, + repository_name, + author: value_string( + value, + if gitlab { + &["author", "username"] + } else { + &["user", "login"] + }, + ), + state: match value_string(value, &["state"]).as_str() { + "opened" => "open".into(), + state => state.to_string(), + }, + labels: value + .get("labels") + .and_then(|v| v.as_array()) + .into_iter() + .flatten() + .filter_map(|v| { + v.as_str() + .or_else(|| v.get("name").and_then(|n| n.as_str())) + .map(str::to_string) + }) + .collect(), + assignees: review_names( + value, + "assignees", + if gitlab { "username" } else { "login" }, + ), + web_url: value_string(value, &[if gitlab { "web_url" } else { "html_url" }]), + updated_at: value_string(value, &["updated_at"]), + }) +} + +fn json_response( + request: reqwest::blocking::RequestBuilder, + provider: &str, +) -> Result { + let response = request + .send() + .map_err(|err| format!("Could not reach {provider}: {err}"))?; + if !response.status().is_success() { + return Err(response_error(response, provider)); + } + response + .json() + .map_err(|err| format!("Invalid {provider} issue response: {err}")) +} + +fn azure_issue(value: &serde_json::Value, base: &str) -> IntegrationIssue { + let field = |name: &str| value_string(value, &["fields", name]); + let number = value_u64(value, "id"); + let assigned = value_string(value, &["fields", "System.AssignedTo", "displayName"]); + IntegrationIssue { + id: format!("azure-devops:{number}"), + number, + title: field("System.Title"), + description: field("System.Description"), + description_html: true, + repository_name: field("System.TeamProject"), + author: value_string(value, &["fields", "System.CreatedBy", "displayName"]), + state: field("System.State"), + labels: field("System.Tags") + .split(';') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(), + assignees: if assigned.is_empty() { + vec![] + } else { + vec![assigned] + }, + web_url: format!("{base}/_workitems/edit/{number}"), + updated_at: field("System.ChangedDate"), + } +} + +fn azure_issues( + client: &Client, + base: &str, + username: &str, + token: &str, + cursor: Option, +) -> Result { + let condition = cursor + .map(|id| format!("[System.Id] < {id}")) + .unwrap_or_else(|| "[System.Id] > 0".into()); + let payload = json_response(client.post(format!("{base}/_apis/wit/wiql")) + .basic_auth(username, Some(token)).query(&[("api-version", "7.1"), ("$top", "101")]) + .json(&serde_json::json!({"query": format!("SELECT [System.Id] FROM WorkItems WHERE {condition} ORDER BY [System.Id] DESC")})), "Azure DevOps")?; + let ids: Vec = payload + .get("workItems") + .and_then(|v| v.as_array()) + .ok_or("Azure DevOps returned no work item list.")? + .iter() + .filter_map(|v| v.get("id").and_then(|id| id.as_u64())) + .collect(); + let next_cursor = if ids.len() > 100 { + Some(ids[99].to_string()) + } else { + None + }; + if ids.is_empty() { + return Ok(IssuePage { + issues: vec![], + next_cursor, + }); + } + let ids = ids + .iter() + .take(100) + .map(u64::to_string) + .collect::>() + .join(","); + let payload = json_response( + client + .get(format!("{base}/_apis/wit/workitems")) + .basic_auth(username, Some(token)) + .query(&[("api-version", "7.1"), ("ids", ids.as_str())]), + "Azure DevOps", + )?; + let mut issues: Vec<_> = payload + .get("value") + .and_then(|v| v.as_array()) + .ok_or("Azure DevOps returned no work item details.")? + .iter() + .map(|v| azure_issue(v, base)) + .collect(); + issues.sort_by_key(|issue| std::cmp::Reverse(issue.number)); + Ok(IssuePage { + issues, + next_cursor, + }) +} + +#[tauri::command] +pub async fn list_integration_issues( + provider: String, + base_url: String, + username: String, + token: String, + cursor: Option, +) -> Result { + tokio::time::timeout( + REVIEW_REQUEST_TIMEOUT, + tauri::async_runtime::spawn_blocking(move || { + if token.trim().is_empty() { + return Err("No token is stored for this integration.".into()); + } + let cursor = cursor + .map(|c| { + c.parse::() + .map_err(|_| "Invalid issue cursor.".to_string()) + }) + .transpose()?; + let base = normalized_base_url(&base_url)?; + let client = client()?; + if provider == "azure-devops" { + return azure_issues( + &client, + &base, + if username.is_empty() { + "gitty" + } else { + &username + }, + &token, + cursor, + ); + } + let page = cursor.unwrap_or(1).max(1).to_string(); + let request = match provider.as_str() { + "github" => client + .get(format!("{}/issues", github_api_base_url(&base)?)) + .bearer_auth(&token) + .query(&[ + ("filter", "all"), + ("state", "all"), + ("sort", "created"), + ("direction", "desc"), + ("per_page", "100"), + ("page", page.as_str()), + ]), + "gitlab" | "gitlab-self-hosted" => client + .get(format!("{base}/api/v4/issues")) + .header("PRIVATE-TOKEN", &token) + .query(&[ + ("scope", "all"), + ("state", "all"), + ("order_by", "created_at"), + ("sort", "desc"), + ("per_page", "100"), + ("page", page.as_str()), + ]), + "gitea" => client + .get(format!("{base}/api/v1/repos/issues/search")) + .header("Authorization", format!("token {token}")) + .query(&[ + ("type", "issues"), + ("state", "all"), + ("limit", "100"), + ("page", page.as_str()), + ]), + _ => return Err("Unsupported integration provider.".into()), + } + .header(USER_AGENT, "Gitty") + .header(ACCEPT, "application/json"); + let response = request + .send() + .map_err(|err| format!("Could not reach {provider}: {err}"))?; + if !response.status().is_success() { + return Err(response_error(response, &provider)); + } + let linked_next = response + .headers() + .get("link") + .and_then(|v| v.to_str().ok()) + .map(|v| v.contains("rel=\"next\"")); + let values: Vec = response + .json() + .map_err(|err| format!("Invalid issue list: {err}"))?; + let next_cursor = if linked_next.unwrap_or(values.len() == 100) { + Some(cursor.unwrap_or(1).max(1).saturating_add(1).to_string()) + } else { + None + }; + Ok(IssuePage { + issues: values + .iter() + .filter_map(|v| parse_issue(v, &provider)) + .collect(), + next_cursor, + }) + }), + ) + .await + .map_err(|_| "The issue API did not respond within 35 seconds.".to_string())? + .map_err(|err| format!("Could not load issues: {err}"))? +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + #[test] + fn excludes_pull_requests_and_maps_github_issues() { + assert!(parse_issue(&json!({"number": 2, "pull_request": {}}), "github").is_none()); + let issue = parse_issue(&json!({"id": 4, "number": 2, "repository_url": "https://api.github.com/repos/team/app", "labels": [{"name":"bug"}], "state":"open"}), "github").unwrap(); + assert_eq!(issue.repository_name, "team/app"); + assert_eq!(issue.labels, ["bug"]); + } + #[test] + fn maps_both_gitlab_variants_and_gitea() { + for provider in ["gitlab", "gitlab-self-hosted"] { + let issue = parse_issue(&json!({"id": 8,"iid": 3,"references":{"full":"team/app#3"},"state":"opened","labels":["bug"]}), provider).unwrap(); + assert_eq!(issue.repository_name, "team/app"); + assert_eq!(issue.state, "open"); + } + let issue = parse_issue( + &json!({"id":9,"number":4,"repository":{"full_name":"team/app"},"pull_request":null}), + "gitea", + ) + .unwrap(); + assert_eq!(issue.repository_name, "team/app"); + } + #[test] + fn preserves_custom_azure_states_and_project_identity() { + let issue = azure_issue( + &json!({"id":42,"fields":{"System.State":"Ready for QA","System.TeamProject":"Project","System.Tags":"bug; urgent"}}), + "https://dev.azure.com/org", + ); + assert_eq!(issue.state, "Ready for QA"); + assert_eq!(issue.repository_name, "Project"); + assert_eq!(issue.labels, ["bug", "urgent"]); + assert_eq!( + issue.web_url, + "https://dev.azure.com/org/_workitems/edit/42" + ); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 4856721..f19b06b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -34,8 +34,8 @@ use git::{ unstage_files, untrack_paths, update_remote, }; use integrations::{ - list_integration_repository_branches, create_integration_review_request, add_integration_review_comment, get_integration_review_details, list_integration_repositories, list_integration_review_requests, open_in_browser, - run_integration_review_action, + add_integration_review_comment, get_integration_review_details, list_integration_repositories, list_integration_review_requests, open_in_browser, + run_integration_review_action, list_integration_issues, get_integration_board, list_integration_boards, move_integration_board_card, list_integration_issue_comments, add_integration_issue_comment, close_integration_issue, }; use std::path::{Path, PathBuf}; use std::sync::Mutex; @@ -441,6 +441,13 @@ async fn main() { create_integration_review_request, list_integration_repositories, list_integration_review_requests, + list_integration_issues, + get_integration_board, + list_integration_boards, + move_integration_board_card, + list_integration_issue_comments, + add_integration_issue_comment, + close_integration_issue, get_integration_review_details, add_integration_review_comment, run_integration_review_action, diff --git a/src/App.svelte b/src/App.svelte index 2fe680d..96034cc 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -13,6 +13,7 @@ import PullRequestStatusLoader from "./lib/components/PullRequestStatusLoader.svelte"; import type { PullRequestBadgeState } from "./lib/pullRequestBadges"; let pullRequestBadges: Record = {}; + import IssueCenter from "./lib/components/IssueCenter.svelte"; import RepositoryDashboard from "./lib/components/RepositoryDashboard.svelte"; import ReviewCenter from "./lib/components/ReviewCenter.svelte"; import RepoTabs from "./lib/RepoTabs.svelte"; @@ -222,7 +223,7 @@ import { setTelemetryEnabled, tracedInvoke } from "./lib/telemetry"; type UpdateToastState = "available" | "downloading" | "installed" | "error"; - type AppView = "management" | "review-center" | "repository"; + type AppView = "management" | "review-center" | "issues" | "repository"; type ReviewConflictPhase = "idle" | "preparing" | "conflicts" | "ready-to-continue" | "ready-to-push" | "complete" | "error"; type CredentialAction = "push" | "pull" | "fetch" | "clone" | "rename" | "delete"; type CredentialMode = "credentials" | "token"; @@ -5396,6 +5397,7 @@ language={appLanguage} onOpenManagement={openRepoManagement} onOpenReviewCenter={() => openReviewCenter()} + onOpenIssues={() => { activeView = "issues"; }} isActive={(path) => activeView === "repository" && sameRepoPath(activeRepoPath, path)} onSelect={selectRepoTab} onClose={closeRepoTab} @@ -5566,6 +5568,8 @@ onRemoveRecent={removeRepoFromRecent} onOpenPullRequests={(repository, sourceId) => openReviewCenter(repository, sourceId)} /> + {:else if activeView === "issues"} + {:else if activeView === "review-center"} - import { Folder, GitBranch, GitPullRequest, Plus, X } from "@lucide/svelte"; + import { CircleDot, Folder, GitBranch, GitPullRequest, Plus, X } from "@lucide/svelte"; interface RepositoryTabItem { path: string; @@ -7,11 +7,12 @@ branch: string | null; } - export let activeView: "management" | "review-center" | "repository" = "management"; + export let activeView: "management" | "review-center" | "issues" | "repository" = "management"; export let repoTabs: RepositoryTabItem[] = []; export let isBusy: boolean = false; export let language: "en" | "de" = "en"; export let onOpenManagement: () => void = () => {}; + export let onOpenIssues: () => void = () => {}; export let onOpenReviewCenter: () => void = () => {}; export let isActive: (path: string) => boolean = () => false; export let onSelect: (path: string) => void | Promise = () => {}; @@ -40,6 +41,11 @@ PR + {#each repoTabs as repo (repo.path)}
+ import { tick } from "svelte"; + import { marked } from "marked"; + import DOMPurify from "dompurify"; + import { Heading1, Heading2, Heading3, Bold, Italic, Strikethrough, Quote, Code2, Link, List, ListOrdered, ListTodo, Table2, AtSign, MessageSquare, CaseSensitive, WrapText, Send } from "@lucide/svelte"; + import { openInBrowser } from "../git"; + export let value = ""; + export let language: "de" | "en" = "en"; + export let disabled = false; + export let busy = false; + export let onSend: () => void | Promise; + let textarea: HTMLTextAreaElement; + let preview = false; + let monospace = false; + let wrap = true; + let linkError = ""; + $: de = language === "de"; + $: rendered = preview ? DOMPurify.sanitize(marked.parse(value, { async: false, gfm: true, breaks: true }), { + ALLOWED_TAGS: ["p","br","strong","em","del","s","blockquote","pre","code","h1","h2","h3","h4","h5","h6","ul","ol","li","a","table","thead","tbody","tr","th","td","hr","input"], + ALLOWED_ATTR: ["href","title","type","checked","disabled","align"], + }) : ""; + $: tools = [ + {icon:Heading1,label:de?"Überschrift 1":"Heading 1",prefix:"# ",line:true}, + {icon:Heading2,label:de?"Überschrift 2":"Heading 2",prefix:"## ",line:true}, + {icon:Heading3,label:de?"Überschrift 3":"Heading 3",prefix:"### ",line:true}, + {icon:Bold,label:de?"Fett":"Bold",prefix:"**",suffix:"**",group:true}, + {icon:Italic,label:de?"Kursiv":"Italic",prefix:"_",suffix:"_"}, + {icon:Strikethrough,label:de?"Durchgestrichen":"Strikethrough",prefix:"~~",suffix:"~~"}, + {icon:Quote,label:de?"Zitat":"Quote",prefix:"> ",line:true,group:true}, + {icon:Code2,label:"Code",prefix:"`",suffix:"`"}, + {icon:Link,label:"Link",prefix:"[",suffix:"](https://example.com)"}, + {icon:List,label:de?"Aufzählung":"Bullet list",prefix:"- ",line:true,group:true}, + {icon:ListOrdered,label:de?"Nummerierte Liste":"Numbered list",prefix:"1. ",line:true}, + {icon:ListTodo,label:de?"Aufgabenliste":"Task list",prefix:"- [ ] ",line:true}, + {icon:Table2,label:de?"Tabelle":"Table",prefix:"\n| A | B |\n| --- | --- |\n| | |\n"}, + {icon:AtSign,label:de?"Erwähnung einfügen":"Insert mention",prefix:"@",suffix:"",group:true}, + {icon:MessageSquare,label:de?"Issue-/PR-Verweis einfügen":"Insert issue/PR reference",prefix:"#",suffix:""}, + ]; + async function format(prefix: string, suffix = "", line = false) { + if (disabled || busy) return; + preview = false; + await tick(); + let start = textarea.selectionStart, end = textarea.selectionEnd; + if (line) { + start = value.lastIndexOf("\n", start - 1) + 1; + const selected = value.slice(start, end); + const replacement = selected.split("\n").map((text, i) => `${prefix === "1. " ? `${i + 1}. ` : prefix}${text}`).join("\n"); + value = value.slice(0,start) + replacement + value.slice(end); + await tick(); textarea.focus(); textarea.setSelectionRange(start, start+replacement.length); + } else { + const selected = value.slice(start,end); + // Multi-line code selections become fenced blocks. + if (prefix === "`" && selected.includes("\n")) { prefix = "\n```\n"; suffix = "\n```\n"; } + value = value.slice(0,start)+prefix+selected+suffix+value.slice(end); + await tick(); textarea.focus(); textarea.setSelectionRange(start+prefix.length,start+prefix.length+selected.length); + } + } + function keyboard(event: KeyboardEvent) { + if (!(event.ctrlKey || event.metaKey)) return; + const key = event.key.toLowerCase(); + if (key === "b" || key === "i" || key === "k") { event.preventDefault(); void format(key === "b" ? "**" : key === "i" ? "_" : "[",key === "b" ? "**" : key === "i" ? "_" : "](https://example.com)"); } + if (key === "enter") { event.preventDefault(); if (value.trim() && !busy && !disabled) void onSend(); } + } + function previewLinks(node: HTMLElement) { + node.addEventListener("click", previewClick); + return { destroy() { node.removeEventListener("click", previewClick); } }; + } + async function previewClick(event: MouseEvent) { + const anchor = event.target instanceof Element ? event.target.closest("a") : null; + if (!anchor) return; + event.preventDefault(); + const href = anchor.getAttribute("href") ?? ""; + if (!/^https?:\/\//i.test(href)) return; + try { await openInBrowser(href); } catch (cause) { linkError = String(cause); } + } + +
+
+ + +
+
+ {#each tools as tool}{/each} + + + +
+ {#if preview} + +
+ {#if value.trim()}{@html rendered}{:else}{de ? "Noch nichts zum Anzeigen." : "Nothing to preview yet."}{/if} +
+ {:else} + + {/if} + {#if linkError}{linkError}{/if} + +
+ diff --git a/src/lib/components/IntegrationBoardView.svelte b/src/lib/components/IntegrationBoardView.svelte new file mode 100644 index 0000000..0ffec56 --- /dev/null +++ b/src/lib/components/IntegrationBoardView.svelte @@ -0,0 +1,280 @@ + + + + + { if (event.key === "Escape") selected = null; }} /> + +
+
+ ({ value: item.webUrl, label: `${item.scope ? `${item.scope} · ` : ""}${item.title}` }))]} ariaLabel={de ? "Board auswählen" : "Select board"} onChange={value => { if (value && !moving) { boardUrl = value; void loadBoard(value, true); } }} /> + + {discovering ? (de ? "Boards werden im Hintergrund gesucht …" : "Finding boards in background …") : `${savedBoards.length} Boards`} + {#if !board && boardUrl && validBoardUrl(boardUrl)}{/if} +
+ {#if discoveryWarnings.length}
{de ? "Hinweise zur Board-Suche" : "Board discovery notices"} ({discoveryWarnings.length}){#each discoveryWarnings as warning}

{warning}

{/each}
{/if} + {#if board} +
+

{board.title}

{source.label} · {canMove ? (de ? "Karten zwischen Spalten verschieben" : "Move cards between columns") : (de ? "Lesende Ansicht" : "Read-only view")}{#if loading} · {de ? "Aktualisierung läuft …" : "Updating …"}{/if}
+
+ { repositoryFilter = value; selected = null; }} /> + + + + +
+
+ {/if} + {#if connectOpen || !board} +
{ event.preventDefault(); void loadBoard(boardUrl, true); }}> + +
+
+ {/if} + {#if moveMessage}

{moveMessage}

{/if} + {#if error}{/if} + {#if board} + {#if board.notice}

{notice(board.notice)}

{/if} +
+
+ {#each columns as column (column.id)} +
{ if (dragged && column.moveTarget && !moving && !loading && dragged.columnId !== column.id) { event.preventDefault(); if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; dropColumn = column.id; } }} + ondragleave={event => { if (!(event.relatedTarget instanceof Node) || !event.currentTarget.contains(event.relatedTarget)) dropColumn = ""; }} + ondrop={event => { event.preventDefault(); if (dragged) void moveCard(dragged.cardId, dragged.columnId, column.id); dropColumn = ""; }}> +

{column.title}

{column.cards.length}{#if column.limit} · WIP {column.limit}{/if}
+
+ {#each column.cards as card (`${column.id}:${card.id}`)} + + {:else}

{de ? "Keine Karten" : "No cards"}

{/each} +
+
+ {:else}

{de ? "Dieses Board hat keine Spalten." : "This board has no columns."}

{/each} +
+ {#if selected} + + {/if} +
+ {:else} +

{loading ? (de ? "Board wird im Hintergrund geladen" : "Loading board in background") : (de ? "Dein vorhandenes Board übernehmen" : "Load your existing board")}

{de ? "Wähle oben ein gefundenes Board aus oder füge einen Board-Link ein. Spalten und Karten werden aus der Integration gelesen; der Link wird für diese Integration gespeichert." : "Select a discovered board above or paste a board link. Columns and cards come from the integration; the link is saved for this integration."}

+ {#if source.provider === "gitea"}

{de ? "Gitea benötigt eine Serverversion mit Projects-/Columns-API." : "Gitea requires a server version with the Projects/Columns API."}

{/if} +
+ {/if} +
diff --git a/src/lib/components/IssueAssignees.svelte b/src/lib/components/IssueAssignees.svelte new file mode 100644 index 0000000..15b6c80 --- /dev/null +++ b/src/lib/components/IssueAssignees.svelte @@ -0,0 +1,8 @@ + + + {#if names.length}{names[0]}{names.length > 1 ? ` +${names.length - 1}` : ""}{:else}{emptyLabel}{/if} + diff --git a/src/lib/components/IssueCenter.svelte b/src/lib/components/IssueCenter.svelte new file mode 100644 index 0000000..8ca95bc --- /dev/null +++ b/src/lib/components/IssueCenter.svelte @@ -0,0 +1,259 @@ + + + + + { if (event.key === "Escape" && selectedId) void closeDetails(); }} /> + +
+
+

Issues

{de ? "Arbeit im Blick. Über alle Integrationen." : "Work in focus. Across your integrations."}

+ +
+ {#if !sources.length} +

{de ? "Noch keine Integration eingerichtet" : "No integrations configured"}

{de ? "Verbinde einen Anbieter, um Issues zu sehen." : "Connect a provider to view issues."}

+ {:else} +
+
+ + +
+ ({ value: item.id, label: item.label }))} ariaLabel={de ? "Integration auswählen" : "Select integration"} onChange={value => { sourceId = value; }} /> +
+ {#if viewMode === "board" && source} + {#key sourceKey}{/key} + {:else} +
+
+
+ + { repositoryFilter = value; selectedId = ""; }} /> + { stateFilter = value; }} /> + +
+ {#if error}{/if} +
+ {#if filtered.length}
IssueStatus{de ? "Zugewiesen" : "Assignee"}
{/if} + {#each groups as group (group.repository)} + {@const collapsed = collapsedRepositories.has(JSON.stringify([sourceKey, group.repository]))} + + {#if !collapsed} + {#each group.issues as issue (issue.id)} + + {/each} + {/if} + {:else} +

{loading && !issues.length ? (de ? "Issues werden geladen" : "Loading issues") : error && !issues.length ? (de ? "Issues konnten nicht geladen werden" : "Could not load issues") : (de ? "Keine passenden Issues" : "No matching issues")}

{de ? "Suche und Filter gelten für die bereits geladenen Einträge." : "Search and filters apply to the items already loaded."}

+ {/each} + {#if nextCursor}{/if} +
+
{filtered.length !== issues.length ? `${filtered.length} / ` : ""}{issues.length} {de ? "geladene Issues" : "loaded issues"}{#if loading}{de ? "Aktualisierung im Hintergrund …" : "Updating in background …"}{/if}
+
+
+ {#if selected} + + {/if} + {/if} + {/if} +
diff --git a/src/lib/components/IssueComments.svelte b/src/lib/components/IssueComments.svelte new file mode 100644 index 0000000..ca672e4 --- /dev/null +++ b/src/lib/components/IssueComments.svelte @@ -0,0 +1,83 @@ + + +
+

{de ? "Kommentare" : "Comments"}

{comments.length}{cursor ? "+" : ""}
+ {#if error}{/if} + {#if loading}{de ? "Kommentare werden geladen …" : "Loading comments …"}{/if} +
+ {#each comments as comment (comment.id)} +
{#if comment.createdAt}{/if}

{text(comment)}

+ {:else}{#if !loading && !error}{de ? "Noch keine Kommentare." : "No comments yet."}{/if}{/each} +
+ {#if cursor}{/if} + + {#if notice}{notice}{/if} +
diff --git a/src/lib/components/IssueLabels.svelte b/src/lib/components/IssueLabels.svelte new file mode 100644 index 0000000..a6e8229 --- /dev/null +++ b/src/lib/components/IssueLabels.svelte @@ -0,0 +1,5 @@ + +{#each labels as label}{label}{/each} diff --git a/src/lib/components/ReviewCenter.svelte b/src/lib/components/ReviewCenter.svelte index fb7607e..f576a06 100644 --- a/src/lib/components/ReviewCenter.svelte +++ b/src/lib/components/ReviewCenter.svelte @@ -1,13 +1,13 @@