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,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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user