feat(integrations): add merge-method selection and provider-specific payloads
Introduce dedicated merge handling for integration review merges: - Add src-tauri/src/integrations/merge.rs: implements merge_options (read provider repo settings), merge_payload (build provider-specific merge body) and a Tauri command get_integration_review_merge_options. Includes unit tests for behavior. - Wire merge module into integrations.rs and pass an optional merge_method into provider-specific review action functions (GitHub, GitLab, Gitea, Azure DevOps). run_integration_review_action now accepts an optional merge_method, validates it early, and includes provider-specific merge payloads when performing a merge. - Export the new command in src-tauri/src/main.rs so the frontend can request merge options. Frontend changes to support selecting a merge method before merging: - ConfirmDialog.svelte: add SelectMenu support and a select field to confirm requests. - ReviewCenter.svelte: fetch integration merge options, show a merge-method selector in the merge confirmation, and pass the chosen method to the review action. - Update types and git bindings to surface IntegrationMergeOptions / IntegrationMergeMethod and the getIntegrationReviewMergeOptions call (git.ts / types.ts changes staged). Effect: users can pick a merge method appropriate to the provider/project; the integration layer generates the correct API payload per provider. Tests added for merge logic.
This commit is contained in:
@@ -1,3 +1,6 @@
|
|||||||
|
mod merge;
|
||||||
|
pub use merge::get_integration_review_merge_options;
|
||||||
|
use merge::merge_payload;
|
||||||
mod issue_creation;
|
mod issue_creation;
|
||||||
pub use issue_creation::*;
|
pub use issue_creation::*;
|
||||||
mod issue_actions;
|
mod issue_actions;
|
||||||
@@ -864,13 +867,14 @@ fn github_review_action(
|
|||||||
repository_name: &str,
|
repository_name: &str,
|
||||||
number: u64,
|
number: u64,
|
||||||
action: &str,
|
action: &str,
|
||||||
|
merge_method: Option<&str>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if repository_name.split('/').count() != 2 {
|
if repository_name.split('/').count() != 2 {
|
||||||
return Err("GitHub returned an invalid repository name.".to_string());
|
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 endpoint = format!("{}/repos/{repository_name}/pulls/{number}", github_api_base_url(base_url)?);
|
||||||
let request = match action {
|
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" })),
|
"approve" => client.post(format!("{endpoint}/reviews")).json(&serde_json::json!({ "event": "APPROVE" })),
|
||||||
"close" => client.patch(&endpoint).json(&serde_json::json!({ "state": "closed" })),
|
"close" => client.patch(&endpoint).json(&serde_json::json!({ "state": "closed" })),
|
||||||
"reopen" => client.patch(&endpoint).json(&serde_json::json!({ "state": "open" })),
|
"reopen" => client.patch(&endpoint).json(&serde_json::json!({ "state": "open" })),
|
||||||
@@ -893,13 +897,14 @@ fn gitlab_review_action(
|
|||||||
repository_id: &str,
|
repository_id: &str,
|
||||||
number: u64,
|
number: u64,
|
||||||
action: &str,
|
action: &str,
|
||||||
|
merge_method: Option<&str>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if repository_id.trim().is_empty() {
|
if repository_id.trim().is_empty() {
|
||||||
return Err("GitLab returned an invalid project identifier.".to_string());
|
return Err("GitLab returned an invalid project identifier.".to_string());
|
||||||
}
|
}
|
||||||
let endpoint = format!("{base_url}/api/v4/projects/{repository_id}/merge_requests/{number}");
|
let endpoint = format!("{base_url}/api/v4/projects/{repository_id}/merge_requests/{number}");
|
||||||
let request = match action {
|
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")),
|
"approve" => client.post(format!("{endpoint}/approve")),
|
||||||
"close" => client.put(&endpoint).query(&[("state_event", "close")]),
|
"close" => client.put(&endpoint).query(&[("state_event", "close")]),
|
||||||
"reopen" => client.put(&endpoint).query(&[("state_event", "reopen")]),
|
"reopen" => client.put(&endpoint).query(&[("state_event", "reopen")]),
|
||||||
@@ -921,13 +926,14 @@ fn gitea_review_action(
|
|||||||
repository_name: &str,
|
repository_name: &str,
|
||||||
number: u64,
|
number: u64,
|
||||||
action: &str,
|
action: &str,
|
||||||
|
merge_method: Option<&str>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if repository_name.split('/').count() != 2 {
|
if repository_name.split('/').count() != 2 {
|
||||||
return Err("Gitea returned an invalid repository name.".to_string());
|
return Err("Gitea returned an invalid repository name.".to_string());
|
||||||
}
|
}
|
||||||
let endpoint = format!("{base_url}/api/v1/repos/{repository_name}/pulls/{number}");
|
let endpoint = format!("{base_url}/api/v1/repos/{repository_name}/pulls/{number}");
|
||||||
let request = match action {
|
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": "" })),
|
"approve" => client.post(format!("{endpoint}/reviews")).json(&serde_json::json!({ "event": "APPROVED", "body": "" })),
|
||||||
"close" => client.patch(&endpoint).json(&serde_json::json!({ "state": "closed" })),
|
"close" => client.patch(&endpoint).json(&serde_json::json!({ "state": "closed" })),
|
||||||
"reopen" => client.patch(&endpoint).json(&serde_json::json!({ "state": "open" })),
|
"reopen" => client.patch(&endpoint).json(&serde_json::json!({ "state": "open" })),
|
||||||
@@ -978,6 +984,7 @@ fn azure_review_action(
|
|||||||
repository_id: &str,
|
repository_id: &str,
|
||||||
number: u64,
|
number: u64,
|
||||||
action: &str,
|
action: &str,
|
||||||
|
merge_method: Option<&str>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let endpoint = azure_review_endpoint(base_url, repository_name, repository_id, number)?;
|
let endpoint = azure_review_endpoint(base_url, repository_name, repository_id, number)?;
|
||||||
let auth_user = if username.trim().is_empty() { "gitty" } else { username };
|
let auth_user = if username.trim().is_empty() { "gitty" } else { username };
|
||||||
@@ -1007,7 +1014,12 @@ fn azure_review_action(
|
|||||||
let payload = current.json::<serde_json::Value>().map_err(|err| format!("Azure DevOps returned an unreadable pull request: {err}"))?;
|
let payload = current.json::<serde_json::Value>().map_err(|err| format!("Azure DevOps returned an unreadable pull request: {err}"))?;
|
||||||
let commit_id = value_string(&payload, &["lastMergeSourceCommit", "commitId"]);
|
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()); }
|
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()),
|
_ => return Err("Unsupported review action.".to_string()),
|
||||||
};
|
};
|
||||||
@@ -1184,19 +1196,21 @@ pub async fn run_integration_review_action(
|
|||||||
repository_name: String,
|
repository_name: String,
|
||||||
number: u64,
|
number: u64,
|
||||||
action: String,
|
action: String,
|
||||||
|
merge_method: Option<String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
tokio::time::timeout(
|
tokio::time::timeout(
|
||||||
REVIEW_REQUEST_TIMEOUT,
|
REVIEW_REQUEST_TIMEOUT,
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
if token.trim().is_empty() { return Err("No token is stored for this integration.".to_string()); }
|
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 !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 base_url = normalized_base_url(&base_url)?;
|
||||||
let client = client()?;
|
let client = client()?;
|
||||||
match provider.as_str() {
|
match provider.as_str() {
|
||||||
"github" => github_review_action(&client, &base_url, &token, &repository_name, 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),
|
"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),
|
"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),
|
"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()),
|
_ => Err("Unsupported integration provider.".to_string()),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ReviewMergeOptions {
|
||||||
|
methods: Vec<String>,
|
||||||
|
default_method: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_options(provider: &str, repository: &serde_json::Value) -> Result<ReviewMergeOptions, String> {
|
||||||
|
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<String> = 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<serde_json::Value, String> {
|
||||||
|
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<ReviewMergeOptions, 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 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::<serde_json::Value>().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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ use integrations::{
|
|||||||
create_integration_review_request, list_integration_repository_branches,
|
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,
|
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,
|
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::path::{Path, PathBuf};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
@@ -463,7 +463,7 @@ async fn main() {
|
|||||||
set_azure_issue_state,
|
set_azure_issue_state,
|
||||||
get_integration_review_details,
|
get_integration_review_details,
|
||||||
add_integration_review_comment,
|
add_integration_review_comment,
|
||||||
run_integration_review_action,
|
run_integration_review_action, get_integration_review_merge_options,
|
||||||
open_in_browser,
|
open_in_browser,
|
||||||
set_sync_badge,
|
set_sync_badge,
|
||||||
close_splashscreen,
|
close_splashscreen,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* untranslated, event-blocking browser dialog.
|
* untranslated, event-blocking browser dialog.
|
||||||
*/
|
*/
|
||||||
import { AlertTriangle, Check, LoaderCircle, Trash2, X } from "@lucide/svelte";
|
import { AlertTriangle, Check, LoaderCircle, Trash2, X } from "@lucide/svelte";
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
import { t } from "../i18n.svelte";
|
import { t } from "../i18n.svelte";
|
||||||
|
|
||||||
export interface ConfirmRequest {
|
export interface ConfirmRequest {
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
input?: { label: string; placeholder?: string; value?: string; optional?: boolean };
|
input?: { label: string; placeholder?: string; value?: string; optional?: boolean };
|
||||||
/** Destructive actions get the red confirm button and warning icon. */
|
/** Destructive actions get the red confirm button and warning icon. */
|
||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
|
select?: { label: string; value: string; options: { value: string; label: string }[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -47,13 +49,13 @@
|
|||||||
let value = $state("");
|
let value = $state("");
|
||||||
let inputElement = $state<HTMLInputElement | null>(null);
|
let inputElement = $state<HTMLInputElement | null>(null);
|
||||||
let missingInput = $derived(Boolean(request.input) && request.input?.optional !== true && value.trim().length === 0);
|
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(() => {
|
$effect(() => {
|
||||||
// Start from the defaults again whenever a different confirmation is shown.
|
// Start from the defaults again whenever a different confirmation is shown.
|
||||||
request.title;
|
request.title;
|
||||||
checked = request.checkbox?.defaultChecked ?? false;
|
checked = request.checkbox?.defaultChecked ?? false;
|
||||||
value = request.input?.value ?? "";
|
value = request.select?.value ?? request.input?.value ?? "";
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -144,6 +146,13 @@
|
|||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if request.select}
|
||||||
|
<div class="confirm-input">
|
||||||
|
<span>{request.select.label}</span>
|
||||||
|
<SelectMenu {value} options={request.select.options} ariaLabel={request.select.label} disabled={isBusy} onChange={(selected) => value = selected} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if request.checkbox}
|
{#if request.checkbox}
|
||||||
<label class="confirm-check">
|
<label class="confirm-check">
|
||||||
<input type="checkbox" bind:checked disabled={isBusy} />
|
<input type="checkbox" bind:checked disabled={isBusy} />
|
||||||
|
|||||||
@@ -13,9 +13,9 @@
|
|||||||
CircleCheck, Clock3, CircleDotDashed, ExternalLink, GitBranch, GitMerge, GitPullRequest, Inbox,
|
CircleCheck, Clock3, CircleDotDashed, ExternalLink, GitBranch, GitMerge, GitPullRequest, Inbox,
|
||||||
LoaderCircle, PanelRightOpen, RefreshCw, RotateCcw, Search, Settings2, X, XCircle,
|
LoaderCircle, PanelRightOpen, RefreshCw, RotateCcw, Search, Settings2, X, XCircle,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
import { addIntegrationReviewComment, getIntegrationReviewDetails, listIntegrationReviewRequests, openInBrowser, runIntegrationReviewAction } from "../git";
|
import { addIntegrationReviewComment, getIntegrationReviewMergeOptions, getIntegrationReviewDetails, listIntegrationReviewRequests, openInBrowser, runIntegrationReviewAction } from "../git";
|
||||||
import { configuredIntegrationSources, integrationCredentialKey, providerLabel } from "../integrations";
|
import { configuredIntegrationSources, integrationCredentialKey, providerLabel } from "../integrations";
|
||||||
import type { AppLanguage, GitIntegrationSettings, IntegrationReviewAction, IntegrationReviewRequest, IntegrationReviewState, StoredCredential } from "../types";
|
import type { AppLanguage, GitIntegrationSettings, IntegrationMergeMethod, IntegrationMergeOptions, IntegrationReviewAction, IntegrationReviewRequest, IntegrationReviewState, StoredCredential } from "../types";
|
||||||
|
|
||||||
type LocalResolutionPhase = "idle" | "preparing" | "conflicts" | "ready-to-continue" | "ready-to-push" | "complete" | "error";
|
type LocalResolutionPhase = "idle" | "preparing" | "conflicts" | "ready-to-continue" | "ready-to-push" | "complete" | "error";
|
||||||
|
|
||||||
@@ -384,36 +384,48 @@
|
|||||||
return de ? "Request wieder öffnen" : "Reopen request";
|
return de ? "Request wieder öffnen" : "Reopen request";
|
||||||
}
|
}
|
||||||
|
|
||||||
let reviewConfirmRequest = $state<ConfirmRequest | null>(null);
|
function mergeMethodLabel(method: IntegrationMergeMethod, request: IntegrationReviewRequest): string {
|
||||||
let reviewConfirmResolve: ((confirmed: boolean) => void) | null = null;
|
if (request.provider.startsWith("gitlab")) {
|
||||||
|
if (method === "merge") return de ? "Ohne Squash zusammenführen" : "Merge without squashing";
|
||||||
|
if (method === "squash") return de ? "Mit Squash zusammenführen" : "Squash and merge";
|
||||||
|
}
|
||||||
|
switch (method) {
|
||||||
|
case "merge": return de ? "Merge-Commit erstellen" : "Create merge commit";
|
||||||
|
case "rebase": return de ? "Rebase, dann Fast-forward" : "Rebase, then fast-forward";
|
||||||
|
case "rebase-merge": return de ? "Rebase, dann Merge-Commit erstellen" : "Rebase, then create merge commit";
|
||||||
|
case "squash": return de ? "Squash-Commit erstellen" : "Create squash commit";
|
||||||
|
case "fast-forward-only": return de ? "Nur Fast-forward" : "Fast-forward only";
|
||||||
|
default: return de ? "Projektstandard verwenden" : "Use project default";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function askReviewConfirmation(request: IntegrationReviewRequest, action: IntegrationReviewAction): Promise<boolean> {
|
let reviewConfirmRequest = $state<ConfirmRequest | null>(null);
|
||||||
|
let reviewConfirmResolve: ((result: { confirmed: boolean; method?: IntegrationMergeMethod }) => void) | null = null;
|
||||||
|
|
||||||
|
function askReviewConfirmation(request: IntegrationReviewRequest, action: IntegrationReviewAction, mergeOptions?: IntegrationMergeOptions): Promise<{ confirmed: boolean; method?: IntegrationMergeMethod }> {
|
||||||
const values = { number: request.number };
|
const values = { number: request.number };
|
||||||
reviewConfirmRequest = action === "merge"
|
reviewConfirmRequest = action === "merge"
|
||||||
? { title: t("confirm.review.mergeTitle", values), message: t("confirm.review.mergeMessage"), items: [request.title], confirmLabel: t("confirm.review.mergeAction"), danger: false }
|
? { title: t("confirm.review.mergeTitle", values), message: t("confirm.review.mergeMessage"), items: [request.title], confirmLabel: t("confirm.review.mergeAction"), danger: false, select: mergeOptions ? { label: de ? "Merge-Methode" : "Merge method", value: mergeOptions.defaultMethod, options: mergeOptions.methods.map(method => ({ value: method, label: mergeMethodLabel(method, request) })) } : undefined }
|
||||||
: action === "close"
|
: action === "close"
|
||||||
? { title: t("confirm.review.closeTitle", values), message: t("confirm.review.closeMessage"), items: [request.title], confirmLabel: t("confirm.review.closeAction") }
|
? { title: t("confirm.review.closeTitle", values), message: t("confirm.review.closeMessage"), items: [request.title], confirmLabel: t("confirm.review.closeAction") }
|
||||||
: { title: t("confirm.review.reopenTitle", values), message: t("confirm.review.reopenMessage"), items: [request.title], confirmLabel: t("confirm.review.reopenAction"), danger: false };
|
: { title: t("confirm.review.reopenTitle", values), message: t("confirm.review.reopenMessage"), items: [request.title], confirmLabel: t("confirm.review.reopenAction"), danger: false };
|
||||||
|
|
||||||
return new Promise<boolean>((resolve) => {
|
return new Promise<{ confirmed: boolean; method?: IntegrationMergeMethod }>((resolve) => {
|
||||||
reviewConfirmResolve = resolve;
|
reviewConfirmResolve = resolve;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function answerReviewConfirmation(confirmed: boolean) {
|
function answerReviewConfirmation(confirmed: boolean, value?: string) {
|
||||||
|
const method = reviewConfirmRequest?.select?.options.find(option => option.value === value)?.value as IntegrationMergeMethod | undefined;
|
||||||
const resolve = reviewConfirmResolve;
|
const resolve = reviewConfirmResolve;
|
||||||
reviewConfirmRequest = null;
|
reviewConfirmRequest = null;
|
||||||
reviewConfirmResolve = null;
|
reviewConfirmResolve = null;
|
||||||
resolve?.(confirmed);
|
resolve?.({ confirmed, method });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function performReviewAction(request: IntegrationReviewRequest, action: IntegrationReviewAction) {
|
async function performReviewAction(request: IntegrationReviewRequest, action: IntegrationReviewAction) {
|
||||||
const source = activeSource;
|
const source = activeSource;
|
||||||
if (!source || actionBusyId) return;
|
if (!source || actionBusyId) return;
|
||||||
if (action !== "approve") {
|
|
||||||
const confirmed = await askReviewConfirmation(request, action);
|
|
||||||
if (!confirmed) return;
|
|
||||||
}
|
|
||||||
actionMenuId = "";
|
actionMenuId = "";
|
||||||
actionNotice = "";
|
actionNotice = "";
|
||||||
actionBusyId = request.id;
|
actionBusyId = request.id;
|
||||||
@@ -421,7 +433,15 @@
|
|||||||
try {
|
try {
|
||||||
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
|
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
|
||||||
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
|
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
|
||||||
await withTimeout(runIntegrationReviewAction(source.provider, source.baseUrl, credential.username, credential.password, request, action), source.label);
|
let mergeMethod: IntegrationMergeMethod | undefined;
|
||||||
|
if (action !== "approve") {
|
||||||
|
const options = action === "merge" ? await withTimeout(getIntegrationReviewMergeOptions(source.provider, source.baseUrl, credential.password, request), source.label) : undefined;
|
||||||
|
if (options && options.methods.length === 0) throw new Error(de ? "Für dieses Repository ist keine unterstützte Merge-Methode freigegeben." : "No supported merge method is enabled for this repository.");
|
||||||
|
const result = await askReviewConfirmation(request, action, options);
|
||||||
|
if (!result.confirmed) return;
|
||||||
|
mergeMethod = result.method;
|
||||||
|
}
|
||||||
|
await withTimeout(runIntegrationReviewAction(source.provider, source.baseUrl, credential.username, credential.password, request, action, mergeMethod), source.label);
|
||||||
actionNotice = de ? `${reviewActionLabel(action)} erfolgreich.` : `${reviewActionLabel(action)} succeeded.`;
|
actionNotice = de ? `${reviewActionLabel(action)} erfolgreich.` : `${reviewActionLabel(action)} succeeded.`;
|
||||||
requests = [];
|
requests = [];
|
||||||
loadedStates = new Set();
|
loadedStates = new Set();
|
||||||
@@ -723,7 +743,7 @@
|
|||||||
{#if reviewConfirmRequest}
|
{#if reviewConfirmRequest}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
request={reviewConfirmRequest}
|
request={reviewConfirmRequest}
|
||||||
onConfirm={() => answerReviewConfirmation(true)}
|
onConfirm={({ value }) => answerReviewConfirmation(true, value)}
|
||||||
onCancel={() => answerReviewConfirmation(false)}
|
onCancel={() => answerReviewConfirmation(false)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
+8
-2
@@ -19,6 +19,8 @@ import type {
|
|||||||
GitIntegrationRepository,
|
GitIntegrationRepository,
|
||||||
IntegrationReviewRequest,
|
IntegrationReviewRequest,
|
||||||
IntegrationReviewAction,
|
IntegrationReviewAction,
|
||||||
|
IntegrationMergeMethod,
|
||||||
|
IntegrationMergeOptions,
|
||||||
GitLfsStatus,
|
GitLfsStatus,
|
||||||
GitRepositoryFile,
|
GitRepositoryFile,
|
||||||
GitRemote,
|
GitRemote,
|
||||||
@@ -66,8 +68,12 @@ export function listIntegrationReviewRequests(provider: GitIntegrationProvider,
|
|||||||
return invoke<IntegrationReviewRequest[]>("list_integration_review_requests", { provider, baseUrl, username, token, state });
|
return invoke<IntegrationReviewRequest[]>("list_integration_review_requests", { provider, baseUrl, username, token, state });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runIntegrationReviewAction(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest, action: IntegrationReviewAction): Promise<void> {
|
export function getIntegrationReviewMergeOptions(provider: GitIntegrationProvider, baseUrl: string, token: string, request: IntegrationReviewRequest): Promise<IntegrationMergeOptions> {
|
||||||
return invoke<void>("run_integration_review_action", { provider, baseUrl, username, token, repositoryId: request.repositoryId, repositoryName: request.repositoryName, number: request.number, action });
|
return invoke("get_integration_review_merge_options", { provider, baseUrl, token, repositoryId: request.repositoryId, repositoryName: request.repositoryName });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runIntegrationReviewAction(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest, action: IntegrationReviewAction, mergeMethod?: IntegrationMergeMethod): Promise<void> {
|
||||||
|
return invoke<void>("run_integration_review_action", { provider, baseUrl, username, token, repositoryId: request.repositoryId, repositoryName: request.repositoryName, number: request.number, action, mergeMethod });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getIntegrationReviewDetails(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest): Promise<IntegrationReviewRequest> {
|
export function getIntegrationReviewDetails(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest): Promise<IntegrationReviewRequest> {
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ export interface GitIntegrationRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type IntegrationReviewState = "open" | "draft" | "merged" | "closed";
|
export type IntegrationReviewState = "open" | "draft" | "merged" | "closed";
|
||||||
|
export type IntegrationMergeMethod = "default" | "merge" | "squash" | "rebase" | "rebase-merge" | "fast-forward-only";
|
||||||
|
export interface IntegrationMergeOptions { methods: IntegrationMergeMethod[]; defaultMethod: IntegrationMergeMethod; }
|
||||||
|
|
||||||
export type IntegrationReviewAction = "merge" | "approve" | "close" | "reopen";
|
export type IntegrationReviewAction = "merge" | "approve" | "close" | "reopen";
|
||||||
|
|
||||||
export interface IntegrationReviewComment {
|
export interface IntegrationReviewComment {
|
||||||
|
|||||||
Reference in New Issue
Block a user