From 0539020545a3aa3c023b00dbbab827b841d611da Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Tue, 8 Sep 2026 21:19:54 +0200 Subject: [PATCH] feat(integrations): add multi-provider board and issue integrations Add backend integration modules to discover, read, and modify provider-hosted boards, issues, and comments across multiple providers. Expose Tauri commands for board discovery, listing, and card moves, and implement safe issue actions and comment APIs. Wire new Svelte UI components to render boards, issue centers, comments, labels, and assignees, and add sanitized markdown rendering. - Add board discovery, board reading, and card-move APIs - Add Svelte components and styles for integrated board UI - Use marked + DOMPurify for safe markdown rendering --- package-lock.json | 23 + package.json | 2 + src-tauri/src/integrations.rs | 9 + src-tauri/src/integrations/board_discovery.rs | 177 +++ src-tauri/src/integrations/board_moves.rs | 115 ++ src-tauri/src/integrations/boards.rs | 1165 +++++++++++++++++ src-tauri/src/integrations/issue_actions.rs | 53 + src-tauri/src/integrations/issue_comments.rs | 95 ++ src-tauri/src/integrations/issues.rs | 336 +++++ src-tauri/src/main.rs | 9 +- src/App.svelte | 6 +- src/app.css | 3 +- src/lib/RepoTabs.svelte | 10 +- src/lib/components/CommentEditor.svelte | 109 ++ .../components/IntegrationBoardView.svelte | 280 ++++ src/lib/components/IssueAssignees.svelte | 8 + src/lib/components/IssueCenter.svelte | 259 ++++ src/lib/components/IssueComments.svelte | 83 ++ src/lib/components/IssueLabels.svelte | 5 + src/lib/components/ReviewCenter.svelte | 40 +- src/lib/git.ts | 27 + src/lib/issuePresentation.ts | 20 + src/lib/issueWorkspace.css | 253 ++++ src/lib/types.ts | 43 + 24 files changed, 3098 insertions(+), 32 deletions(-) create mode 100644 src-tauri/src/integrations/board_discovery.rs create mode 100644 src-tauri/src/integrations/board_moves.rs create mode 100644 src-tauri/src/integrations/boards.rs create mode 100644 src-tauri/src/integrations/issue_actions.rs create mode 100644 src-tauri/src/integrations/issue_comments.rs create mode 100644 src-tauri/src/integrations/issues.rs create mode 100644 src/lib/components/CommentEditor.svelte create mode 100644 src/lib/components/IntegrationBoardView.svelte create mode 100644 src/lib/components/IssueAssignees.svelte create mode 100644 src/lib/components/IssueCenter.svelte create mode 100644 src/lib/components/IssueComments.svelte create mode 100644 src/lib/components/IssueLabels.svelte create mode 100644 src/lib/issuePresentation.ts create mode 100644 src/lib/issueWorkspace.css 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 d189e77..f60ed0a 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 40b4e87..c84ea6d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -35,7 +35,7 @@ use git::{ }; use integrations::{ add_integration_review_comment, get_integration_review_details, list_integration_repositories, list_integration_review_requests, open_in_browser, - run_integration_review_action, + 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; @@ -439,6 +439,13 @@ async fn main() { cred_delete, 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 d91a7b8..5eb39e3 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 e3d4b77..99aa2cd 100644 --- a/src/lib/components/ReviewCenter.svelte +++ b/src/lib/components/ReviewCenter.svelte @@ -1,12 +1,13 @@