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" ); } }