diff --git a/src-tauri/src/integrations.rs b/src-tauri/src/integrations.rs index 08e77cf..5f1e654 100644 --- a/src-tauri/src/integrations.rs +++ b/src-tauri/src/integrations.rs @@ -1,3 +1,6 @@ +mod merge; +pub use merge::get_integration_review_merge_options; +use merge::merge_payload; mod issue_creation; pub use issue_creation::*; mod issue_actions; @@ -864,13 +867,14 @@ fn github_review_action( repository_name: &str, number: u64, action: &str, + merge_method: Option<&str>, ) -> Result<(), String> { if repository_name.split('/').count() != 2 { return Err("GitHub returned an invalid repository name.".to_string()); } let endpoint = format!("{}/repos/{repository_name}/pulls/{number}", github_api_base_url(base_url)?); let request = match action { - "merge" => client.put(format!("{endpoint}/merge")).json(&serde_json::json!({})), + "merge" => client.put(format!("{endpoint}/merge")).json(&merge_payload("github", merge_method)?), "approve" => client.post(format!("{endpoint}/reviews")).json(&serde_json::json!({ "event": "APPROVE" })), "close" => client.patch(&endpoint).json(&serde_json::json!({ "state": "closed" })), "reopen" => client.patch(&endpoint).json(&serde_json::json!({ "state": "open" })), @@ -893,13 +897,14 @@ fn gitlab_review_action( repository_id: &str, number: u64, action: &str, + merge_method: Option<&str>, ) -> Result<(), String> { if repository_id.trim().is_empty() { return Err("GitLab returned an invalid project identifier.".to_string()); } let endpoint = format!("{base_url}/api/v4/projects/{repository_id}/merge_requests/{number}"); let request = match action { - "merge" => client.put(format!("{endpoint}/merge")), + "merge" => client.put(format!("{endpoint}/merge")).json(&merge_payload("gitlab", merge_method)?), "approve" => client.post(format!("{endpoint}/approve")), "close" => client.put(&endpoint).query(&[("state_event", "close")]), "reopen" => client.put(&endpoint).query(&[("state_event", "reopen")]), @@ -921,13 +926,14 @@ fn gitea_review_action( repository_name: &str, number: u64, action: &str, + merge_method: Option<&str>, ) -> Result<(), String> { if repository_name.split('/').count() != 2 { return Err("Gitea returned an invalid repository name.".to_string()); } let endpoint = format!("{base_url}/api/v1/repos/{repository_name}/pulls/{number}"); let request = match action { - "merge" => client.post(format!("{endpoint}/merge")).json(&serde_json::json!({ "Do": "merge" })), + "merge" => client.post(format!("{endpoint}/merge")).json(&merge_payload("gitea", merge_method)?), "approve" => client.post(format!("{endpoint}/reviews")).json(&serde_json::json!({ "event": "APPROVED", "body": "" })), "close" => client.patch(&endpoint).json(&serde_json::json!({ "state": "closed" })), "reopen" => client.patch(&endpoint).json(&serde_json::json!({ "state": "open" })), @@ -978,6 +984,7 @@ fn azure_review_action( repository_id: &str, number: u64, action: &str, + merge_method: Option<&str>, ) -> Result<(), String> { let endpoint = azure_review_endpoint(base_url, repository_name, repository_id, number)?; let auth_user = if username.trim().is_empty() { "gitty" } else { username }; @@ -1007,7 +1014,12 @@ fn azure_review_action( let payload = current.json::().map_err(|err| format!("Azure DevOps returned an unreadable pull request: {err}"))?; let commit_id = value_string(&payload, &["lastMergeSourceCommit", "commitId"]); if commit_id.is_empty() { return Err("Azure DevOps did not return the current source commit.".to_string()); } - serde_json::json!({ "status": "completed", "lastMergeSourceCommit": { "commitId": commit_id } }) + { + let mut body = merge_payload("azure-devops", merge_method)?; + body["status"] = serde_json::json!("completed"); + body["lastMergeSourceCommit"] = serde_json::json!({ "commitId": commit_id }); + body + } } _ => return Err("Unsupported review action.".to_string()), }; @@ -1184,19 +1196,21 @@ pub async fn run_integration_review_action( repository_name: String, number: u64, action: String, + merge_method: Option, ) -> Result<(), 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.".to_string()); } if !matches!(action.as_str(), "merge" | "approve" | "close" | "reopen") { return Err("Unsupported review action.".to_string()); } + if action == "merge" { merge_payload(&provider, merge_method.as_deref())?; } let base_url = normalized_base_url(&base_url)?; let client = client()?; match provider.as_str() { - "github" => github_review_action(&client, &base_url, &token, &repository_name, number, &action), - "gitlab" | "gitlab-self-hosted" => gitlab_review_action(&client, &base_url, &token, &repository_id, number, &action), - "gitea" => gitea_review_action(&client, &base_url, &token, &repository_name, number, &action), - "azure-devops" => azure_review_action(&client, &base_url, &username, &token, &repository_name, &repository_id, number, &action), + "github" => github_review_action(&client, &base_url, &token, &repository_name, number, &action, merge_method.as_deref()), + "gitlab" | "gitlab-self-hosted" => gitlab_review_action(&client, &base_url, &token, &repository_id, number, &action, merge_method.as_deref()), + "gitea" => gitea_review_action(&client, &base_url, &token, &repository_name, number, &action, merge_method.as_deref()), + "azure-devops" => azure_review_action(&client, &base_url, &username, &token, &repository_name, &repository_id, number, &action, merge_method.as_deref()), _ => Err("Unsupported integration provider.".to_string()), } }), diff --git a/src-tauri/src/integrations/merge.rs b/src-tauri/src/integrations/merge.rs new file mode 100644 index 0000000..17d7398 --- /dev/null +++ b/src-tauri/src/integrations/merge.rs @@ -0,0 +1,121 @@ +use super::*; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewMergeOptions { + methods: Vec, + default_method: String, +} + +fn merge_options(provider: &str, repository: &serde_json::Value) -> Result { + let candidates: &[(&str, &str)] = match provider { + "gitea" => &[("merge", "allow_merge_commits"), ("rebase", "allow_rebase"), ("rebase-merge", "allow_rebase_explicit"), ("squash", "allow_squash_merge"), ("fast-forward-only", "allow_fast_forward_only_merge")], + "github" => &[("merge", "allow_merge_commit"), ("squash", "allow_squash_merge"), ("rebase", "allow_rebase_merge")], + "azure-devops" => &[("merge", ""), ("squash", ""), ("rebase", ""), ("rebase-merge", "")], + "gitlab" | "gitlab-self-hosted" => &[], + _ => return Err("Unsupported integration provider.".into()), + }; + let mut methods: Vec = candidates.iter() + .filter(|(_, field)| field.is_empty() || repository.get(*field).and_then(serde_json::Value::as_bool) == Some(true)) + .map(|(method, _)| method.to_string()).collect(); + let preferred = if provider.starts_with("gitlab") { + match repository.get("squash_option").and_then(serde_json::Value::as_str) { + Some("always") => { methods.push("squash".into()); "squash" }, + Some("never") => { methods.push("merge".into()); "merge" }, + Some("default_on") => { methods.extend(["merge".into(), "squash".into()]); "squash" }, + Some("default_off") => { methods.extend(["merge".into(), "squash".into()]); "merge" }, + // Older servers may not expose squash settings; leave the server's default intact. + _ => { methods.push("default".into()); "default" }, + } + } else { + repository.get("default_merge_style").and_then(serde_json::Value::as_str).unwrap_or("merge") + }; + let default_method = methods.iter().find(|method| method.as_str() == preferred) + .or_else(|| methods.first()).cloned().unwrap_or_default(); + Ok(ReviewMergeOptions { methods, default_method }) +} + +pub(super) fn merge_payload(provider: &str, method: Option<&str>) -> Result { + let method = method.unwrap_or("default"); + match (provider, method) { + ("github", "default") | ("gitlab" | "gitlab-self-hosted" | "azure-devops", "default") => Ok(serde_json::json!({})), + ("github", "merge" | "squash" | "rebase") => Ok(serde_json::json!({ "merge_method": method })), + ("gitea", "default") => Ok(serde_json::json!({ "Do": "merge" })), + ("gitea", "merge" | "squash" | "rebase" | "rebase-merge" | "fast-forward-only") => Ok(serde_json::json!({ "Do": method })), + ("gitlab" | "gitlab-self-hosted", "merge" | "squash") => Ok(serde_json::json!({ "squash": method == "squash" })), + ("azure-devops", "merge" | "squash" | "rebase" | "rebase-merge") => { + let strategy = match method { "merge" => "noFastForward", "rebase-merge" => "rebaseMerge", other => other }; + Ok(serde_json::json!({ "completionOptions": { "mergeStrategy": strategy } })) + }, + _ => Err("Unsupported merge method for this integration provider.".into()), + } +} + +#[tauri::command] +pub async fn get_integration_review_merge_options(provider: String, base_url: String, token: String, repository_id: String, repository_name: String) -> 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 base = normalized_base_url(&base_url)?; + if provider == "azure-devops" { return merge_options(&provider, &serde_json::json!({})); } + let client = client()?; + let request = match provider.as_str() { + "github" | "gitea" => { + if repository_name.split('/').count() != 2 { return Err("Invalid repository name.".into()); } + if provider == "github" { + client.get(format!("{}/repos/{repository_name}", github_api_base_url(&base)?)) + .bearer_auth(&token).header(ACCEPT, "application/vnd.github+json") + } else { + client.get(format!("{base}/api/v1/repos/{repository_name}")) + .header("Authorization", format!("token {token}")) + } + }, + "gitlab" | "gitlab-self-hosted" => { + if repository_id.is_empty() { return Err("Invalid project identifier.".into()); } + client.get(format!("{base}/api/v4/projects/{repository_id}")).header("PRIVATE-TOKEN", &token) + }, + _ => return Err("Unsupported integration provider.".into()), + }; + let response = request.header(USER_AGENT, "Gitty").send().map_err(|err| format!("Could not load merge options: {err}"))?; + if !response.status().is_success() { return Err(response_error(response, &provider)); } + let repository = response.json::().map_err(|err| format!("Could not read merge options: {err}"))?; + merge_options(&provider, &repository) + })).await.map_err(|_| "The integration API did not respond within 35 seconds.".to_string())? + .map_err(|err| format!("Could not load merge options: {err}"))? +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn repository_settings_filter_methods_and_select_allowed_default() { + let options = merge_options("gitea", &serde_json::json!({"allow_merge_commits":false,"allow_rebase":true,"allow_rebase_explicit":true,"allow_squash_merge":true,"default_merge_style":"squash"})).unwrap(); + assert_eq!(options.methods, ["rebase", "rebase-merge", "squash"]); + assert_eq!(options.default_method, "squash"); + let options = merge_options("github", &serde_json::json!({"allow_squash_merge":true})).unwrap(); + assert_eq!(options.methods, ["squash"]); + assert_eq!(options.default_method, "squash"); + assert!(merge_options("gitea", &serde_json::json!({})).unwrap().methods.is_empty()); + } + #[test] + fn gitlab_respects_required_and_forbidden_squashing() { + for (setting, expected) in [("always", "squash"), ("never", "merge"), ("default_on", "squash"), ("default_off", "merge")] { + let options = merge_options("gitlab", &serde_json::json!({"squash_option":setting})).unwrap(); + assert_eq!(options.default_method, expected); + assert_eq!(options.methods.len(), if setting.starts_with("default") { 2 } else { 1 }); + } + } + #[test] + fn payloads_use_provider_specific_methods_and_reject_invalid_choices() { + for method in ["merge", "rebase", "rebase-merge", "squash", "fast-forward-only"] { + assert_eq!(merge_payload("gitea", Some(method)).unwrap()["Do"], method); + } + assert_eq!(merge_payload("github", Some("rebase")).unwrap()["merge_method"], "rebase"); + assert_eq!(merge_payload("azure-devops", Some("rebase-merge")).unwrap()["completionOptions"]["mergeStrategy"], "rebaseMerge"); + assert_eq!(merge_payload("azure-devops", Some("merge")).unwrap()["completionOptions"]["mergeStrategy"], "noFastForward"); + assert_eq!(merge_payload("gitlab", Some("squash")).unwrap()["squash"], true); + assert_eq!(merge_payload("gitlab", Some("merge")).unwrap()["squash"], false); + assert!(merge_payload("github", Some("fast-forward-only")).is_err()); + assert!(merge_payload("gitlab", Some("rebase")).is_err()); + assert!(merge_payload("gitea", Some("manually-merged")).is_err()); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index fe5faf2..cbf787c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -38,7 +38,7 @@ use integrations::{ create_integration_review_request, list_integration_repository_branches, add_integration_review_comment, get_integration_review_details, list_integration_repositories, list_integration_review_requests, open_in_browser, create_integration_issue, list_azure_issue_projects, list_azure_issue_types, - run_integration_review_action, list_integration_issues, get_integration_board, list_integration_boards, move_integration_board_card, list_integration_issue_comments, add_integration_issue_comment, close_integration_issue, list_azure_issue_states, set_azure_issue_state, + run_integration_review_action, get_integration_review_merge_options, list_integration_issues, get_integration_board, list_integration_boards, move_integration_board_card, list_integration_issue_comments, add_integration_issue_comment, close_integration_issue, list_azure_issue_states, set_azure_issue_state, }; use std::path::{Path, PathBuf}; use std::sync::Mutex; @@ -463,7 +463,7 @@ async fn main() { set_azure_issue_state, get_integration_review_details, add_integration_review_comment, - run_integration_review_action, + run_integration_review_action, get_integration_review_merge_options, open_in_browser, set_sync_badge, close_splashscreen, diff --git a/src/lib/components/ConfirmDialog.svelte b/src/lib/components/ConfirmDialog.svelte index 8920b93..2394c13 100644 --- a/src/lib/components/ConfirmDialog.svelte +++ b/src/lib/components/ConfirmDialog.svelte @@ -5,6 +5,7 @@ * untranslated, event-blocking browser dialog. */ import { AlertTriangle, Check, LoaderCircle, Trash2, X } from "@lucide/svelte"; + import SelectMenu from "./SelectMenu.svelte"; import { t } from "../i18n.svelte"; export interface ConfirmRequest { @@ -25,6 +26,7 @@ input?: { label: string; placeholder?: string; value?: string; optional?: boolean }; /** Destructive actions get the red confirm button and warning icon. */ danger?: boolean; + select?: { label: string; value: string; options: { value: string; label: string }[] }; } interface Props { @@ -47,13 +49,13 @@ let value = $state(""); let inputElement = $state(null); let missingInput = $derived(Boolean(request.input) && request.input?.optional !== true && value.trim().length === 0); - let blocked = $derived((Boolean(request.checkbox?.required) && !checked) || missingInput); + let blocked = $derived((Boolean(request.checkbox?.required) && !checked) || missingInput || (Boolean(request.select) && !request.select?.options.some(option => option.value === value))); $effect(() => { // Start from the defaults again whenever a different confirmation is shown. request.title; checked = request.checkbox?.defaultChecked ?? false; - value = request.input?.value ?? ""; + value = request.select?.value ?? request.input?.value ?? ""; }); $effect(() => { @@ -144,6 +146,13 @@ {/if} + {#if request.select} +
+ {request.select.label} + value = selected} /> +
+ {/if} + {#if request.checkbox}