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
178 lines
10 KiB
Rust
178 lines
10 KiB
Rust
//! 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<BoardReference>, warnings: Vec<String> }
|
|
|
|
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<BoardDirectory, 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 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();
|
|
}
|
|
}
|