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