feat(integrations): add multi-provider board and issue integrations
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
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
//! 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Persist one user-requested column move. Resolve all mutation metadata from a fresh board.
|
||||
use super::*;
|
||||
|
||||
fn gitlab_move_body(board: &IntegrationBoard, source: &BoardColumn, target: &BoardColumn) -> Result<Value, String> {
|
||||
let metadata = target.move_target.as_ref().ok_or("This board grouping does not support moving cards.")?;
|
||||
let label = value_string(metadata, &["label"]);
|
||||
// Only remove this board's grouping labels. Unrelated issue labels stay intact.
|
||||
let remove = board.columns.iter().filter(|c| target.id == "open" || c.id == source.id).filter_map(|c| c.move_target.as_ref())
|
||||
.map(|m| value_string(m, &["label"]))
|
||||
.filter(|name| !name.is_empty() && *name != label).collect::<Vec<_>>();
|
||||
if target.id != "closed" && remove.iter().any(|label| strings(metadata, "protectedLabels", "name").contains(label)) {
|
||||
return Err("This move would remove a label required by the board filter. Open the original board to change its scope.".into());
|
||||
}
|
||||
let mut body = json!({"state_event": if target.id == "closed" { "close" } else { "reopen" }});
|
||||
if target.id != "closed" {
|
||||
body["remove_labels"] = json!(remove.join(","));
|
||||
if !label.is_empty() { body["add_labels"] = json!(label); }
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
fn azure_patch(item: &Value, metadata: &Value) -> Result<Value, String> {
|
||||
let kind = value_string(item, &["fields", "System.WorkItemType"]);
|
||||
let state = value_string(&metadata["states"], &[&kind]);
|
||||
if state.is_empty() { return Err("The target column has no state mapping for this work item type.".into()); }
|
||||
let rev = item["rev"].as_u64().ok_or("Azure returned no work item revision.")?;
|
||||
let field = value_string(metadata, &["columnField"]);
|
||||
wiql_field(&field)?;
|
||||
let mut patch = vec![json!({"op":"test","path":"/rev","value":rev}),
|
||||
json!({"op":"add","path":"/fields/System.State","value":state}),
|
||||
json!({"op":"add","path":format!("/fields/{field}"),"value":metadata["name"]})];
|
||||
let done_field = value_string(metadata, &["doneField"]);
|
||||
if !done_field.is_empty() {
|
||||
wiql_field(&done_field)?;
|
||||
patch.push(json!({"op":"add","path":format!("/fields/{done_field}"),"value":metadata["done"]}));
|
||||
}
|
||||
Ok(json!(patch))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn move_integration_board_card(provider: String, base_url: String, username: String, token: String, board_url: String, card_id: String, source_column_id: String, target_column_id: String) -> Result<(), 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 path = board_path(&base, &board_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 board = match provider.as_str() {
|
||||
"github" => github_board(&api, &path, &board_url)?,
|
||||
"gitlab" | "gitlab-self-hosted" => gitlab_board(&api, &path, &board_url)?,
|
||||
"azure-devops" => azure_board(&api, &path, &board_url)?,
|
||||
_ => return Err("Moving cards is not supported for this integration.".into()),
|
||||
};
|
||||
let card = board.columns.iter().find(|c| c.id == source_column_id).and_then(|c| c.cards.iter().find(|c| c.id == card_id))
|
||||
.ok_or("The card has moved or is no longer in this board. Refresh before trying again.")?;
|
||||
let target = board.columns.iter().find(|c| c.id == target_column_id).ok_or("The target column no longer exists.")?;
|
||||
let metadata = target.move_target.as_ref().ok_or("This column does not support moving cards.")?;
|
||||
if source_column_id == target_column_id { return Ok(()); }
|
||||
match provider.as_str() {
|
||||
"github" => {
|
||||
let mut input = json!({"projectId":metadata["project"],"fieldId":metadata["field"],"itemId":card.id});
|
||||
let query = if target.id == "unassigned" {
|
||||
"mutation($input:ClearProjectV2ItemFieldValueInput!){clearProjectV2ItemFieldValue(input:$input){projectV2Item{id}}}"
|
||||
} else {
|
||||
input["value"] = json!({"singleSelectOptionId":target.id});
|
||||
"mutation($input:UpdateProjectV2ItemFieldValueInput!){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id}}}"
|
||||
};
|
||||
api.graphql("https://api.github.com/graphql".into(), query, json!({"input":input}))?;
|
||||
},
|
||||
"gitlab" | "gitlab-self-hosted" => {
|
||||
if card.repository.is_empty() { return Err("The issue's repository could not be resolved.".into()); }
|
||||
let source = board.columns.iter().find(|c| c.id == source_column_id).ok_or("Source column no longer exists.")?;
|
||||
let body = gitlab_move_body(&board, source, target)?;
|
||||
api.read(api.request(reqwest::Method::PUT, endpoint(&base, &["api","v4","projects",&card.repository,"issues",&card.number.to_string()])?).json(&body))?;
|
||||
},
|
||||
"azure-devops" => {
|
||||
let url = endpoint(&base, &["_apis","wit","workitems",&card.number.to_string()])?;
|
||||
let item = api.read(api.request(reqwest::Method::GET,url.clone()).query(&[("api-version","7.1")]))?;
|
||||
let source = board.columns.iter().find(|c| c.id == source_column_id).and_then(|c| c.move_target.as_ref()).ok_or("Source column no longer exists.")?;
|
||||
let field = value_string(source, &["columnField"]);
|
||||
let done = value_string(source, &["doneField"]);
|
||||
if item["fields"][&field] != source["name"] || (!done.is_empty() && item["fields"][&done].as_bool().unwrap_or(false) != source["done"].as_bool().unwrap_or(false)) {
|
||||
return Err("The card has moved since the board was read. Refresh before trying again.".into());
|
||||
}
|
||||
let patch = azure_patch(&item,metadata)?;
|
||||
api.read(api.request(reqwest::Method::PATCH,url).query(&[("api-version","7.1")]).header("Content-Type","application/json-patch+json").json(&patch))?;
|
||||
},
|
||||
_ => unreachable!(),
|
||||
}
|
||||
Ok(())
|
||||
}).await.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn label_move_preserves_unrelated_labels_and_close_keeps_assignments() {
|
||||
let mut a = column("1".into(),"Todo".into()); a.move_target=Some(json!({"label":"Todo"}));
|
||||
let mut b = column("2".into(),"Build".into()); b.move_target=Some(json!({"label":"Build"}));
|
||||
let mut c = column("closed".into(),"Closed".into()); c.move_target=Some(json!({"state":"close"}));
|
||||
let board=IntegrationBoard{title:String::new(),web_url:String::new(),notice:String::new(),columns:vec![a,b,c]};
|
||||
assert_eq!(gitlab_move_body(&board,&board.columns[0],&board.columns[1]).unwrap(),json!({"add_labels":"Build","remove_labels":"Todo","state_event":"reopen"}));
|
||||
assert_eq!(gitlab_move_body(&board,&board.columns[0],&board.columns[2]).unwrap(),json!({"state_event":"close"}));
|
||||
}
|
||||
#[test]
|
||||
fn azure_move_checks_revision_and_uses_team_specific_fields() {
|
||||
let item=json!({"rev":7,"fields":{"System.WorkItemType":"Bug"}});
|
||||
let metadata=json!({"columnField":"WEF_Test.Column","doneField":"WEF_Test.Done","name":"Review","done":true,"states":{"Bug":"Active"}});
|
||||
let patch=azure_patch(&item,&metadata).unwrap();
|
||||
assert_eq!(patch[0],json!({"op":"test","path":"/rev","value":7}));
|
||||
assert_eq!(patch[2]["path"],"/fields/WEF_Test.Column");
|
||||
assert_eq!(patch[3]["value"],true);
|
||||
assert!(azure_patch(&json!({"rev":1,"fields":{"System.WorkItemType":"Task"}}),&metadata).is_err());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
use super::*;
|
||||
use super::issue_comments::{comment_url, request, comment_client};
|
||||
use serde_json::{Value,json};
|
||||
fn read(response: reqwest::blocking::Response, provider: &str) -> Result<Value,String> {
|
||||
if !response.status().is_success() {return Err(response_error(response,provider));}
|
||||
response.json().map_err(|e|e.to_string())
|
||||
}
|
||||
fn completed_state(value: &Value) -> Result<String,String> {
|
||||
let states:Vec<_>=value["value"].as_array().into_iter().flatten().filter(|v|v["category"]=="Completed").filter_map(|v|v["name"].as_str()).filter(|s|!s.is_empty()).collect();
|
||||
if states.len()!=1 {return Err("No unique completed state is configured for this work item type. Open the issue in Azure to select its state.".into());}
|
||||
Ok(states[0].into())
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn close_integration_issue(provider:String,base_url:String,username:String,token:String,repository:String,number:u64) -> Result<String,String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let client=comment_client()?;
|
||||
let mut url=comment_url(&provider,&base_url,&repository,number)?;
|
||||
url.path_segments_mut().map_err(|_|"Invalid issue URL.")?.pop();
|
||||
if provider=="azure-devops" {
|
||||
url.set_query(Some("api-version=7.1"));
|
||||
let item=read(request(&client,reqwest::Method::GET,url.clone(),&provider,&username,&token)?.send().map_err(|e|e.to_string())?,&provider)?;
|
||||
let kind=value_string(&item,&["fields","System.WorkItemType"]);
|
||||
let mut states_url=reqwest::Url::parse(&normalized_base_url(&base_url)?).map_err(|e|e.to_string())?;
|
||||
states_url.path_segments_mut().map_err(|_|"Invalid Azure URL.")?.pop_if_empty().extend([&repository,"_apis","wit","workitemtypes",&kind,"states"]);
|
||||
states_url.set_query(Some("api-version=7.1"));
|
||||
let states=read(request(&client,reqwest::Method::GET,states_url,&provider,&username,&token)?.send().map_err(|e|e.to_string())?,&provider)?;
|
||||
let state=completed_state(&states)?;
|
||||
if item["fields"]["System.State"]==state {return Ok(state);}
|
||||
let rev=item["rev"].as_u64().ok_or("Azure returned no revision.")?;
|
||||
let payload=json!([{"op":"test","path":"/rev","value":rev},{"op":"add","path":"/fields/System.State","value":state}]);
|
||||
let result=read(request(&client,reqwest::Method::PATCH,url,&provider,&username,&token)?.header("Content-Type","application/json-patch+json").json(&payload).send().map_err(|e|e.to_string())?,&provider)?;
|
||||
let actual=value_string(&result,&["fields","System.State"]);
|
||||
if actual!=state {return Err("Azure did not confirm the completed state.".into());}
|
||||
Ok(actual)
|
||||
} else {
|
||||
let gitlab=provider.starts_with("gitlab");
|
||||
let method=if gitlab {reqwest::Method::PUT}else{reqwest::Method::PATCH};
|
||||
let payload=if gitlab {json!({"state_event":"close"})}else{json!({"state":"closed"})};
|
||||
let result=read(request(&client,method,url,&provider,&username,&token)?.json(&payload).send().map_err(|e|format!("Closing was not confirmed: {e}"))?,&provider)?;
|
||||
let state=value_string(&result,&["state"]);
|
||||
if state!="closed" {return Err("The provider did not confirm the closed state.".into());}
|
||||
Ok(state)
|
||||
}
|
||||
}).await.map_err(|e|e.to_string())?
|
||||
}
|
||||
#[cfg(test)] mod tests {
|
||||
use super::*;
|
||||
#[test] fn uses_custom_completed_state_and_rejects_ambiguous_configuration() {
|
||||
assert_eq!(completed_state(&json!({"value":[{"name":"Review","category":"Resolved"},{"name":"Delivered","category":"Completed"}]})).unwrap(),"Delivered");
|
||||
assert!(completed_state(&json!({"value":[]})).is_err());
|
||||
assert!(completed_state(&json!({"value":[{"name":"A","category":"Completed"},{"name":"B","category":"Completed"}]})).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use super::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IssueComment { id: String, author: String, body: String, created_at: String, body_html: bool }
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IssueCommentPage { comments: Vec<IssueComment>, next_cursor: Option<String> }
|
||||
|
||||
pub(super) fn comment_url(provider: &str, base: &str, repository: &str, number: u64) -> Result<reqwest::Url, String> {
|
||||
if repository.trim().is_empty() || number == 0 { return Err("Missing issue repository or number.".into()); }
|
||||
let base = normalized_base_url(base)?;
|
||||
let mut url = reqwest::Url::parse(if provider == "github" { "https://api.github.com" } else { &base }).map_err(|e|e.to_string())?;
|
||||
let num = number.to_string();
|
||||
let mut path = url.path_segments_mut().map_err(|_|"Invalid API URL.")?;
|
||||
path.pop_if_empty();
|
||||
match provider {
|
||||
"github" | "gitea" => {
|
||||
let (owner, repo) = repository.split_once('/').filter(|(a,b)|!a.is_empty() && !b.is_empty() && !b.contains('/')).ok_or("Invalid issue repository.")?;
|
||||
if provider == "gitea" { path.extend(["api","v1"]); }
|
||||
path.extend(["repos",owner,repo,"issues",&num,"comments"]);
|
||||
},
|
||||
"gitlab" | "gitlab-self-hosted" => { path.extend(["api","v4","projects",repository,"issues",&num,"notes"]); },
|
||||
"azure-devops" => { path.extend([repository,"_apis","wit","workItems",&num,"comments"]); },
|
||||
_ => return Err("Unsupported issue provider.".into()),
|
||||
}
|
||||
drop(path);
|
||||
if provider == "azure-devops" { url.query_pairs_mut().append_pair("api-version","7.1-preview.4"); }
|
||||
Ok(url)
|
||||
}
|
||||
pub(super) fn request(client: &Client, method: reqwest::Method, url: reqwest::Url, provider: &str, username: &str, token: &str) -> Result<reqwest::blocking::RequestBuilder, String> {
|
||||
if token.trim().is_empty() { return Err("No token stored for this integration.".into()); }
|
||||
let req = client.request(method,url).header(USER_AGENT,"Gitty").header(ACCEPT,"application/json");
|
||||
Ok(match provider {
|
||||
"github" => req.bearer_auth(token),
|
||||
"gitea" => req.header("Authorization",format!("token {token}")),
|
||||
"gitlab" | "gitlab-self-hosted" => req.header("PRIVATE-TOKEN",token),
|
||||
_ => req.basic_auth(if username.is_empty(){"gitty"}else{username},Some(token)),
|
||||
})
|
||||
}
|
||||
fn parse_comment(v: &Value, provider: &str) -> Option<IssueComment> {
|
||||
if v["system"] == true || v["isDeleted"] == true { return None; }
|
||||
let azure = provider == "azure-devops";
|
||||
let id = if azure && v["id"].is_null() { json_id(&json!({"id":v["commentId"]})) } else { json_id(v) };
|
||||
Some(IssueComment { id, author: value_string(v, if azure { &["createdBy","displayName"] } else if provider.starts_with("gitlab") { &["author","username"] } else { &["user","login"] }), body:value_string(v,&[if azure{"text"}else{"body"}]), created_at:value_string(v,&[if azure{"createdDate"}else{"created_at"}]), body_html: azure && (v["format"] == "html" || v["format"] == 0) })
|
||||
}
|
||||
pub(super) fn comment_client() -> Result<Client,String> {
|
||||
Client::builder().timeout(Duration::from_secs(30)).connect_timeout(Duration::from_secs(7)).redirect(reqwest::redirect::Policy::none()).build().map_err(|e|e.to_string())
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn list_integration_issue_comments(provider:String, base_url:String, username:String, token:String, repository:String, number:u64, cursor:Option<String>) -> Result<IssueCommentPage,String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let client=comment_client()?;
|
||||
let mut url=comment_url(&provider,&base_url,&repository,number)?;
|
||||
let page=if provider!="azure-devops" { cursor.as_deref().unwrap_or("1").parse::<u32>().map_err(|_|"Invalid comment page.")?.max(1) } else {1};
|
||||
if provider=="azure-devops" {
|
||||
url.query_pairs_mut().append_pair("$top","100").append_pair("order","asc").append_pair("includeDeleted","false");
|
||||
if let Some(cursor)=cursor {url.query_pairs_mut().append_pair("continuationToken",&cursor);}
|
||||
} else {url.query_pairs_mut().append_pair("page",&page.to_string()).append_pair("per_page","100").append_pair("limit","100").append_pair("sort","asc").append_pair("order_by","created_at");}
|
||||
let response=request(&client,reqwest::Method::GET,url,&provider,&username,&token)?.send().map_err(|e|e.to_string())?;
|
||||
if !response.status().is_success(){return Err(response_error(response,&provider));}
|
||||
let linked_next=response.headers().get("link").and_then(|h|h.to_str().ok()).map(|s|s.contains("rel=\"next\""));
|
||||
let value:Value=response.json().map_err(|e|e.to_string())?;
|
||||
let items=if provider=="azure-devops" {value["comments"].as_array()}else{value.as_array()}.ok_or("Invalid comment list.")?;
|
||||
let next_cursor=if provider=="azure-devops" {value["continuationToken"].as_str().filter(|s|!s.is_empty()).map(str::to_string)}else if linked_next.unwrap_or(items.len()==100) {Some(page.saturating_add(1).to_string())}else{None};
|
||||
Ok(IssueCommentPage{comments:items.iter().filter_map(|v|parse_comment(v,&provider)).collect(),next_cursor})
|
||||
}).await.map_err(|e|e.to_string())?
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn add_integration_issue_comment(provider:String, base_url:String, username:String, token:String, repository:String, number:u64, body:String) -> Result<IssueComment,String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
if body.trim().is_empty() || body.len()>100_000 {return Err("Comment must contain between 1 and 100,000 bytes.".into());}
|
||||
let mut url=comment_url(&provider,&base_url,&repository,number)?;
|
||||
if provider=="azure-devops" {url.query_pairs_mut().append_pair("format","markdown");}
|
||||
let payload=if provider=="azure-devops"{json!({"text":body})}else{json!({"body":body})};
|
||||
let response=request(&comment_client()?,reqwest::Method::POST,url,&provider,&username,&token)?.json(&payload).send().map_err(|e|format!("Comment delivery not confirmed: {e}"))?;
|
||||
if !response.status().is_success(){return Err(response_error(response,&provider));}
|
||||
parse_comment(&response.json::<Value>().map_err(|e|e.to_string())?,&provider).ok_or("Invalid posted comment.".into())
|
||||
}).await.map_err(|e|e.to_string())?
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test] fn issue_endpoints_use_issue_notes_and_encode_project_paths() {
|
||||
assert_eq!(comment_url("gitlab","https://git.test","team/sub/repo",4).unwrap().as_str(),"https://git.test/api/v4/projects/team%2Fsub%2Frepo/issues/4/notes");
|
||||
assert_eq!(comment_url("azure-devops","https://dev.azure.com/org","My Project",4).unwrap().path(),"/org/My%20Project/_apis/wit/workItems/4/comments");
|
||||
assert!(comment_url("gitea","https://git.test","owner/repo/invalid",4).is_err());
|
||||
}
|
||||
#[test] fn comments_filter_system_entries_and_preserve_html_format() {
|
||||
assert!(parse_comment(&json!({"system":true}),"gitlab").is_none());
|
||||
let c=parse_comment(&json!({"commentId":7,"text":"<p>Hello</p>","format":"html","createdBy":{"displayName":"Alex"}}),"azure-devops").unwrap();
|
||||
assert_eq!(c.id,"7"); assert!(c.body_html); assert_eq!(c.author,"Alex");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user