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