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
337 lines
12 KiB
Rust
337 lines
12 KiB
Rust
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<String>,
|
|
assignees: Vec<String>,
|
|
web_url: String,
|
|
updated_at: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct IssuePage {
|
|
issues: Vec<IntegrationIssue>,
|
|
next_cursor: Option<String>,
|
|
}
|
|
|
|
fn parse_issue(value: &serde_json::Value, provider: &str) -> Option<IntegrationIssue> {
|
|
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<serde_json::Value, String> {
|
|
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<u64>,
|
|
) -> Result<IssuePage, String> {
|
|
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<u64> = 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::<Vec<_>>()
|
|
.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<String>,
|
|
) -> Result<IssuePage, String> {
|
|
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::<u64>()
|
|
.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<serde_json::Value> = 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"
|
|
);
|
|
}
|
|
}
|