//! 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(); } }