mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): add PR merge conflict recovery (#2164)
This commit is contained in:
@@ -32,6 +32,68 @@ pub struct ProjectRepoMergeResult {
|
||||
pub status_publication_error: Option<String>,
|
||||
}
|
||||
|
||||
/// Machine-readable recovery metadata for a failed pull-request merge.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectPullRequestMergeRecovery {
|
||||
action: String,
|
||||
target_branch: String,
|
||||
source_branch: String,
|
||||
}
|
||||
|
||||
/// Structured pull-request merge failure returned across the Tauri boundary.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectPullRequestMergeError {
|
||||
code: String,
|
||||
message: String,
|
||||
recovery: Option<ProjectPullRequestMergeRecovery>,
|
||||
}
|
||||
|
||||
impl ProjectPullRequestMergeError {
|
||||
fn new(code: &str, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: code.to_string(),
|
||||
message: message.into(),
|
||||
recovery: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn conflict(target_branch: String, source_branch: String) -> Self {
|
||||
Self {
|
||||
code: "merge_conflict".to_string(),
|
||||
message: "Pull request has merge conflicts.".to_string(),
|
||||
recovery: Some(ProjectPullRequestMergeRecovery {
|
||||
action: "open_terminal".to_string(),
|
||||
target_branch,
|
||||
source_branch,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ProjectPullRequestMergeError {
|
||||
fn from(message: String) -> Self {
|
||||
Self::new("merge_failed", message)
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_merge_error(
|
||||
message: String,
|
||||
has_conflicts: bool,
|
||||
target_branch: &str,
|
||||
source_branch: &str,
|
||||
) -> ProjectPullRequestMergeError {
|
||||
if has_conflicts {
|
||||
ProjectPullRequestMergeError::conflict(target_branch.to_string(), source_branch.to_string())
|
||||
} else {
|
||||
ProjectPullRequestMergeError::new(
|
||||
"merge_failed",
|
||||
format!("Pull request merge failed: {message}"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProjectRepoMergeGitResult {
|
||||
message: String,
|
||||
merge_commit: String,
|
||||
@@ -403,7 +465,7 @@ pub async fn merge_project_pull_request(
|
||||
input: ProjectPullRequestMergeInput,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ProjectRepoMergeResult, String> {
|
||||
) -> Result<ProjectRepoMergeResult, ProjectPullRequestMergeError> {
|
||||
let ProjectPullRequestMergeInput {
|
||||
target_clone_url,
|
||||
source_clone_url,
|
||||
@@ -420,10 +482,12 @@ pub async fn merge_project_pull_request(
|
||||
validate_workspace_clone_url(&source_clone_url, &state)?;
|
||||
let target_owner = target_owner.trim().to_ascii_lowercase();
|
||||
if target_owner.len() != 64 || !target_owner.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err("Invalid target repository owner.".to_string());
|
||||
return Err("Invalid target repository owner.".to_string().into());
|
||||
}
|
||||
if clone_url_owner(&target_clone_url).as_deref() != Some(target_owner.as_str()) {
|
||||
return Err("Target clone URL does not match the repository owner.".to_string());
|
||||
return Err("Target clone URL does not match the repository owner."
|
||||
.to_string()
|
||||
.into());
|
||||
}
|
||||
let owner_identity = project_owner_identity(&app, &state, &target_owner)?;
|
||||
let merger_pubkey = owner_identity.keys.public_key().to_hex();
|
||||
@@ -432,7 +496,9 @@ pub async fn merge_project_pull_request(
|
||||
let source_branch = normalize_branch_option(Some(&source_branch))
|
||||
.ok_or_else(|| "Invalid source branch.".to_string())?;
|
||||
if target_branch == source_branch && same_repository(&target_clone_url, &source_clone_url) {
|
||||
return Err("Source and target branches must be different.".to_string());
|
||||
return Err("Source and target branches must be different."
|
||||
.to_string()
|
||||
.into());
|
||||
}
|
||||
let expected_commit = normalize_commit(&expected_commit)
|
||||
.ok_or_else(|| "Invalid pull request commit.".to_string())?;
|
||||
@@ -444,87 +510,109 @@ pub async fn merge_project_pull_request(
|
||||
)?;
|
||||
let auth = build_git_auth_config_for_keys(&owner_identity.keys)?;
|
||||
|
||||
let git_result = tauri::async_runtime::spawn_blocking(move || {
|
||||
let temp_dir = tempfile::tempdir().map_err(|error| format!("create temp dir: {error}"))?;
|
||||
let repo_dir = temp_dir.path().join("repo");
|
||||
let repo_path = repo_dir
|
||||
.to_str()
|
||||
.ok_or_else(|| "temporary repository path is not UTF-8".to_string())?;
|
||||
run_git(
|
||||
&[
|
||||
"clone",
|
||||
"--filter=blob:none",
|
||||
"--no-tags",
|
||||
"--branch",
|
||||
target_branch.as_str(),
|
||||
"--single-branch",
|
||||
"--end-of-options",
|
||||
target_clone_url.as_str(),
|
||||
repo_path,
|
||||
],
|
||||
None,
|
||||
&auth,
|
||||
)?;
|
||||
run_git(
|
||||
&[
|
||||
"fetch",
|
||||
"--quiet",
|
||||
"--end-of-options",
|
||||
source_clone_url.as_str(),
|
||||
source_branch.as_str(),
|
||||
],
|
||||
Some(&repo_dir),
|
||||
&auth,
|
||||
)?;
|
||||
let source_head = run_git(&["rev-parse", "FETCH_HEAD"], Some(&repo_dir), &auth)
|
||||
.ok()
|
||||
.and_then(|output| first_output_line(&output))
|
||||
.ok_or_else(|| "Could not resolve the pull request branch.".to_string())?;
|
||||
if source_head.to_ascii_lowercase() != expected_commit {
|
||||
return Err(
|
||||
"The pull request branch changed. Refresh the pull request before merging."
|
||||
.to_string(),
|
||||
let git_result = tauri::async_runtime::spawn_blocking(
|
||||
move || -> Result<ProjectRepoMergeGitResult, ProjectPullRequestMergeError> {
|
||||
let temp_dir =
|
||||
tempfile::tempdir().map_err(|error| format!("create temp dir: {error}"))?;
|
||||
let repo_dir = temp_dir.path().join("repo");
|
||||
let repo_path = repo_dir
|
||||
.to_str()
|
||||
.ok_or_else(|| "temporary repository path is not UTF-8".to_string())?;
|
||||
run_git(
|
||||
&[
|
||||
"clone",
|
||||
"--filter=blob:none",
|
||||
"--no-tags",
|
||||
"--branch",
|
||||
target_branch.as_str(),
|
||||
"--single-branch",
|
||||
"--end-of-options",
|
||||
target_clone_url.as_str(),
|
||||
repo_path,
|
||||
],
|
||||
None,
|
||||
&auth,
|
||||
)?;
|
||||
run_git(
|
||||
&[
|
||||
"fetch",
|
||||
"--quiet",
|
||||
"--end-of-options",
|
||||
source_clone_url.as_str(),
|
||||
source_branch.as_str(),
|
||||
],
|
||||
Some(&repo_dir),
|
||||
&auth,
|
||||
)?;
|
||||
let source_head = run_git(&["rev-parse", "FETCH_HEAD"], Some(&repo_dir), &auth)
|
||||
.ok()
|
||||
.and_then(|output| first_output_line(&output))
|
||||
.ok_or_else(|| "Could not resolve the pull request branch.".to_string())?;
|
||||
if source_head.to_ascii_lowercase() != expected_commit {
|
||||
return Err(ProjectPullRequestMergeError::new(
|
||||
"branch_changed",
|
||||
"The pull request branch changed. Refresh the pull request before merging."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let merge_email = format!("{merger_pubkey}@users.noreply.buzz");
|
||||
let merge_result = run_git(
|
||||
&[
|
||||
"-c",
|
||||
"user.name=Buzz User",
|
||||
"-c",
|
||||
format!("user.email={merge_email}").as_str(),
|
||||
"merge",
|
||||
"--no-edit",
|
||||
"--end-of-options",
|
||||
expected_commit.as_str(),
|
||||
],
|
||||
Some(&repo_dir),
|
||||
&auth,
|
||||
);
|
||||
}
|
||||
if let Err(error) = merge_result {
|
||||
let has_conflicts = run_git(
|
||||
&["diff", "--name-only", "--diff-filter=U"],
|
||||
Some(&repo_dir),
|
||||
&auth,
|
||||
)
|
||||
.is_ok_and(|output| !output.trim().is_empty());
|
||||
return Err(classify_merge_error(
|
||||
error,
|
||||
has_conflicts,
|
||||
&target_branch,
|
||||
&source_branch,
|
||||
));
|
||||
}
|
||||
let merge_commit = run_git(&["rev-parse", "HEAD"], Some(&repo_dir), &auth)
|
||||
.ok()
|
||||
.and_then(|output| first_output_line(&output))
|
||||
.ok_or_else(|| "Could not resolve the merge commit.".to_string())?;
|
||||
run_git(
|
||||
&[
|
||||
"push",
|
||||
"--end-of-options",
|
||||
"origin",
|
||||
format!("HEAD:{target_branch}").as_str(),
|
||||
],
|
||||
Some(&repo_dir),
|
||||
&auth,
|
||||
)?;
|
||||
|
||||
let merge_email = format!("{merger_pubkey}@users.noreply.buzz");
|
||||
run_git(
|
||||
&[
|
||||
"-c",
|
||||
"user.name=Buzz User",
|
||||
"-c",
|
||||
format!("user.email={merge_email}").as_str(),
|
||||
"merge",
|
||||
"--no-edit",
|
||||
"--end-of-options",
|
||||
expected_commit.as_str(),
|
||||
],
|
||||
Some(&repo_dir),
|
||||
&auth,
|
||||
)
|
||||
.map_err(|error| format!("Pull request cannot be merged cleanly: {error}"))?;
|
||||
let merge_commit = run_git(&["rev-parse", "HEAD"], Some(&repo_dir), &auth)
|
||||
.ok()
|
||||
.and_then(|output| first_output_line(&output))
|
||||
.ok_or_else(|| "Could not resolve the merge commit.".to_string())?;
|
||||
run_git(
|
||||
&[
|
||||
"push",
|
||||
"--end-of-options",
|
||||
"origin",
|
||||
format!("HEAD:{target_branch}").as_str(),
|
||||
],
|
||||
Some(&repo_dir),
|
||||
&auth,
|
||||
)?;
|
||||
|
||||
Ok(ProjectRepoMergeGitResult {
|
||||
message: format!("Merged {source_branch} into {target_branch}."),
|
||||
merge_commit,
|
||||
})
|
||||
})
|
||||
Ok(ProjectRepoMergeGitResult {
|
||||
message: format!("Merged {source_branch} into {target_branch}."),
|
||||
merge_commit,
|
||||
})
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| format!("pull request merge task failed: {error}"))??;
|
||||
.map_err(|error| {
|
||||
ProjectPullRequestMergeError::new(
|
||||
"merge_task_failed",
|
||||
format!("pull request merge task failed: {error}"),
|
||||
)
|
||||
})??;
|
||||
let status_event = build_merged_status_event(
|
||||
&owner_identity.keys,
|
||||
&repo_address,
|
||||
@@ -554,8 +642,9 @@ pub async fn merge_project_pull_request(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
build_merged_status_event, build_review_request_event, normalize_commit, same_repository,
|
||||
validate_merge_status_metadata,
|
||||
build_merged_status_event, build_review_request_event, classify_merge_error,
|
||||
normalize_commit, same_repository, validate_merge_status_metadata,
|
||||
ProjectPullRequestMergeError,
|
||||
};
|
||||
use nostr::{Event, JsonUtil, Keys, Timestamp};
|
||||
|
||||
@@ -583,6 +672,51 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_conflict_error_has_stable_recovery_metadata() {
|
||||
let error =
|
||||
ProjectPullRequestMergeError::conflict("main".to_string(), "feature/demo".to_string());
|
||||
|
||||
assert_eq!(error.code, "merge_conflict");
|
||||
assert_eq!(error.message, "Pull request has merge conflicts.");
|
||||
let recovery = error.recovery.expect("conflict recovery");
|
||||
assert_eq!(recovery.action, "open_terminal");
|
||||
assert_eq!(recovery.target_branch, "main");
|
||||
assert_eq!(recovery.source_branch, "feature/demo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_conflict_error_serializes_for_tauri_clients() {
|
||||
let error =
|
||||
ProjectPullRequestMergeError::conflict("main".to_string(), "feature/demo".to_string());
|
||||
let value = serde_json::to_value(error).expect("serialize merge conflict");
|
||||
|
||||
assert_eq!(value["code"], "merge_conflict");
|
||||
assert_eq!(value["recovery"]["targetBranch"], "main");
|
||||
assert_eq!(value["recovery"]["sourceBranch"], "feature/demo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_error_classification_only_recovers_conflicts() {
|
||||
let conflict = classify_merge_error(
|
||||
"CONFLICT (content): Merge conflict in src/main.rs".to_string(),
|
||||
true,
|
||||
"main",
|
||||
"feature/demo",
|
||||
);
|
||||
assert_eq!(conflict.code, "merge_conflict");
|
||||
assert!(conflict.recovery.is_some());
|
||||
|
||||
let other = classify_merge_error(
|
||||
"fatal: refusing to merge unrelated histories".to_string(),
|
||||
false,
|
||||
"main",
|
||||
"feature/demo",
|
||||
);
|
||||
assert_eq!(other.code, "merge_failed");
|
||||
assert!(other.recovery.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_status_is_signed_by_repository_owner() {
|
||||
let keys = Keys::generate();
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
//! Opens an OS terminal window at a project's local git checkout, cloning
|
||||
//! the repository from the relay first when no local checkout exists.
|
||||
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Command;
|
||||
use tauri::State;
|
||||
|
||||
use crate::app_state::AppState;
|
||||
|
||||
use super::project_git_exec::{build_git_auth_config, validate_workspace_clone_url};
|
||||
use super::project_git::{first_output_line, normalize_branch_option};
|
||||
use super::project_git_diff::clean_commit;
|
||||
use super::project_git_exec::{build_git_auth_config, run_git, validate_workspace_clone_url};
|
||||
use super::project_git_workflow::clone_project_repository_blocking;
|
||||
use super::project_repo_paths::find_local_repo_dir;
|
||||
|
||||
@@ -19,6 +21,37 @@ pub struct ProjectTerminalResult {
|
||||
pub cloned: bool,
|
||||
}
|
||||
|
||||
/// Inputs for preparing an authenticated local merge-conflict recovery.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectMergeRecoveryTerminalInput {
|
||||
repos_dir: Option<String>,
|
||||
project_dtag: String,
|
||||
target_clone_url: String,
|
||||
source_clone_url: String,
|
||||
target_branch: String,
|
||||
source_branch: String,
|
||||
expected_commit: String,
|
||||
}
|
||||
|
||||
/// Local checkout and ref prepared for terminal-based conflict resolution.
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectMergeRecoveryTerminalResult {
|
||||
path: String,
|
||||
cloned: bool,
|
||||
recovery_ref: String,
|
||||
target_ref: String,
|
||||
}
|
||||
|
||||
fn merge_recovery_ref(expected_commit: &str) -> String {
|
||||
format!("refs/buzz/merge-recovery/{expected_commit}")
|
||||
}
|
||||
|
||||
fn merge_recovery_target_ref(target_commit: &str) -> String {
|
||||
format!("refs/buzz/merge-recovery-target/{target_commit}")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch_terminal_at(path: &std::path::Path) -> Result<(), String> {
|
||||
let status = Command::new("open")
|
||||
@@ -122,3 +155,123 @@ pub async fn open_project_terminal(
|
||||
.await
|
||||
.map_err(|error| format!("open terminal task failed: {error}"))?
|
||||
}
|
||||
|
||||
/// Authenticates and fetches the exact pull-request commit before opening a
|
||||
/// terminal. The user's worktree is not switched or modified.
|
||||
#[tauri::command]
|
||||
pub async fn open_project_merge_recovery_terminal(
|
||||
input: ProjectMergeRecoveryTerminalInput,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ProjectMergeRecoveryTerminalResult, String> {
|
||||
validate_workspace_clone_url(&input.target_clone_url, &state)?;
|
||||
validate_workspace_clone_url(&input.source_clone_url, &state)?;
|
||||
let target_branch = normalize_branch_option(Some(&input.target_branch))
|
||||
.ok_or_else(|| "Invalid target branch.".to_string())?;
|
||||
let source_branch = normalize_branch_option(Some(&input.source_branch))
|
||||
.ok_or_else(|| "Invalid source branch.".to_string())?;
|
||||
let expected_commit = clean_commit(Some(input.expected_commit.trim().to_ascii_lowercase()))
|
||||
.ok_or_else(|| "Invalid pull request commit.".to_string())?;
|
||||
let auth = build_git_auth_config(&state)?;
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let existing_dir = find_local_repo_dir(
|
||||
input.repos_dir.as_deref(),
|
||||
&input.project_dtag,
|
||||
Some(&input.target_clone_url),
|
||||
)
|
||||
.ok()
|
||||
.flatten();
|
||||
let (repo_dir, cloned) = if let Some(repo_dir) = existing_dir {
|
||||
(repo_dir, false)
|
||||
} else {
|
||||
let clone_result = clone_project_repository_blocking(
|
||||
input.repos_dir.as_deref(),
|
||||
&input.project_dtag,
|
||||
&input.target_clone_url,
|
||||
Some(&target_branch),
|
||||
&auth,
|
||||
)?;
|
||||
(std::path::PathBuf::from(clone_result.path), true)
|
||||
};
|
||||
|
||||
run_git(
|
||||
&[
|
||||
"fetch",
|
||||
"--quiet",
|
||||
"--no-tags",
|
||||
"--end-of-options",
|
||||
input.target_clone_url.as_str(),
|
||||
target_branch.as_str(),
|
||||
],
|
||||
Some(&repo_dir),
|
||||
&auth,
|
||||
)?;
|
||||
let target_head = run_git(&["rev-parse", "FETCH_HEAD"], Some(&repo_dir), &auth)
|
||||
.ok()
|
||||
.and_then(|output| first_output_line(&output))
|
||||
.ok_or_else(|| "Could not resolve the target branch.".to_string())?;
|
||||
let target_ref = merge_recovery_target_ref(&target_head);
|
||||
run_git(
|
||||
&["update-ref", target_ref.as_str(), target_head.as_str()],
|
||||
Some(&repo_dir),
|
||||
&auth,
|
||||
)?;
|
||||
|
||||
run_git(
|
||||
&[
|
||||
"fetch",
|
||||
"--quiet",
|
||||
"--no-tags",
|
||||
"--end-of-options",
|
||||
input.source_clone_url.as_str(),
|
||||
source_branch.as_str(),
|
||||
],
|
||||
Some(&repo_dir),
|
||||
&auth,
|
||||
)?;
|
||||
let source_head = run_git(&["rev-parse", "FETCH_HEAD"], Some(&repo_dir), &auth)
|
||||
.ok()
|
||||
.and_then(|output| first_output_line(&output))
|
||||
.ok_or_else(|| "Could not resolve the pull request branch.".to_string())?;
|
||||
if source_head.to_ascii_lowercase() != expected_commit {
|
||||
return Err(
|
||||
"The pull request branch changed. Refresh the pull request before resolving conflicts."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let recovery_ref = merge_recovery_ref(&expected_commit);
|
||||
run_git(
|
||||
&["update-ref", recovery_ref.as_str(), expected_commit.as_str()],
|
||||
Some(&repo_dir),
|
||||
&auth,
|
||||
)?;
|
||||
launch_terminal_at(&repo_dir)?;
|
||||
Ok(ProjectMergeRecoveryTerminalResult {
|
||||
path: repo_dir.display().to_string(),
|
||||
cloned,
|
||||
recovery_ref,
|
||||
target_ref,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("open merge recovery terminal task failed: {error}"))?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{merge_recovery_ref, merge_recovery_target_ref};
|
||||
|
||||
#[test]
|
||||
fn recovery_ref_is_namespaced_by_verified_commit() {
|
||||
let commit = "a".repeat(40);
|
||||
assert_eq!(
|
||||
merge_recovery_ref(&commit),
|
||||
format!("refs/buzz/merge-recovery/{commit}"),
|
||||
);
|
||||
assert_eq!(
|
||||
merge_recovery_target_ref(&commit),
|
||||
format!("refs/buzz/merge-recovery-target/{commit}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -679,6 +679,7 @@ pub fn run() {
|
||||
publish_project_pull_request_merged_status,
|
||||
merge_project_pull_request,
|
||||
open_project_terminal,
|
||||
open_project_merge_recovery_terminal,
|
||||
search_users,
|
||||
get_presence,
|
||||
get_os_idle_seconds,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { projectPullRequestConflictCommands } from "./projectPullRequestConflictRecovery.ts";
|
||||
|
||||
test("builds copyable commands without executing merge recovery", () => {
|
||||
assert.deepEqual(
|
||||
projectPullRequestConflictCommands({
|
||||
recoveryRef: `refs/buzz/merge-recovery/${"a".repeat(40)}`,
|
||||
targetBranch: "main",
|
||||
targetRef: `refs/buzz/merge-recovery-target/${"b".repeat(40)}`,
|
||||
}),
|
||||
[
|
||||
'test -z "$(git status --porcelain=v1)" &&',
|
||||
`(git switch 'main' || git switch --create 'main' 'refs/buzz/merge-recovery-target/${"b".repeat(40)}') &&`,
|
||||
`git merge-base --is-ancestor HEAD 'refs/buzz/merge-recovery-target/${"b".repeat(40)}' &&`,
|
||||
`git merge --ff-only 'refs/buzz/merge-recovery-target/${"b".repeat(40)}' &&`,
|
||||
`git merge 'refs/buzz/merge-recovery/${"a".repeat(40)}'`,
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("quotes recovery values as inert shell arguments", () => {
|
||||
const commands = projectPullRequestConflictCommands({
|
||||
recoveryRef: `refs/buzz/merge-recovery/${"b".repeat(40)}`,
|
||||
targetBranch: "release candidate",
|
||||
targetRef: `refs/buzz/merge-recovery-target/${"c".repeat(40)}`,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
commands[1],
|
||||
`(git switch 'release candidate' || git switch --create 'release candidate' 'refs/buzz/merge-recovery-target/${"c".repeat(40)}') &&`,
|
||||
);
|
||||
assert.equal(
|
||||
commands[4],
|
||||
`git merge 'refs/buzz/merge-recovery/${"b".repeat(40)}'`,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
function quoteShellArgument(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
/** Build user-visible recovery commands; callers decide whether to copy them. */
|
||||
export function projectPullRequestConflictCommands({
|
||||
recoveryRef,
|
||||
targetBranch,
|
||||
targetRef,
|
||||
}: {
|
||||
recoveryRef: string;
|
||||
targetBranch: string;
|
||||
targetRef: string;
|
||||
}): string[] {
|
||||
const quotedTargetBranch = quoteShellArgument(targetBranch);
|
||||
const quotedTargetRef = quoteShellArgument(targetRef);
|
||||
return [
|
||||
'test -z "$(git status --porcelain=v1)" &&',
|
||||
`(git switch ${quotedTargetBranch} || git switch --create ${quotedTargetBranch} ${quotedTargetRef}) &&`,
|
||||
`git merge-base --is-ancestor HEAD ${quotedTargetRef} &&`,
|
||||
`git merge --ff-only ${quotedTargetRef} &&`,
|
||||
`git merge ${quoteShellArgument(recoveryRef)}`,
|
||||
];
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
import { GitMerge } from "lucide-react";
|
||||
import { AlertTriangle, Copy, GitMerge, SquareTerminal } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { Project, ProjectPullRequest } from "@/features/projects/hooks";
|
||||
import { projectPullRequestConflictCommands } from "@/features/projects/projectPullRequestConflictRecovery";
|
||||
import {
|
||||
useMergeProjectPullRequestMutation,
|
||||
usePublishProjectPullRequestMergedMutation,
|
||||
} from "@/features/projects/pullRequestMutations";
|
||||
import {
|
||||
ProjectPullRequestMergeError,
|
||||
type ProjectPullRequestMergeRecovery,
|
||||
} from "@/shared/api/projectGit";
|
||||
import { copyTextToClipboard } from "@/shared/lib/clipboard";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -19,36 +25,85 @@ import {
|
||||
} from "@/shared/ui/alert-dialog";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
export type OpenMergeRecoveryTerminal = (input: {
|
||||
expectedCommit: string;
|
||||
sourceBranch: string;
|
||||
sourceCloneUrl: string;
|
||||
targetBranch: string;
|
||||
}) => Promise<{ recoveryRef: string; targetRef: string }>;
|
||||
|
||||
export function MergePullRequestButton({
|
||||
onOpenTerminal,
|
||||
project,
|
||||
pullRequest,
|
||||
}: {
|
||||
onOpenTerminal?: OpenMergeRecoveryTerminal;
|
||||
project: Project;
|
||||
pullRequest: ProjectPullRequest;
|
||||
}) {
|
||||
const [confirmOpen, setConfirmOpen] = React.useState(false);
|
||||
const [unpublishedStatusEvent, setUnpublishedStatusEvent] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isPreparingRecovery, setIsPreparingRecovery] = React.useState(false);
|
||||
const [conflictRecoveryState, setConflictRecoveryState] = React.useState<{
|
||||
pullRequestId: string;
|
||||
recovery: ProjectPullRequestMergeRecovery;
|
||||
} | null>(null);
|
||||
const [unpublishedStatusState, setUnpublishedStatusState] = React.useState<{
|
||||
event: string;
|
||||
pullRequestId: string;
|
||||
} | null>(null);
|
||||
const [preparedRecoveryState, setPreparedRecoveryState] = React.useState<{
|
||||
pullRequestId: string;
|
||||
recoveryRef: string;
|
||||
targetRef: string;
|
||||
} | null>(null);
|
||||
const mergeMutation = useMergeProjectPullRequestMutation(project);
|
||||
const publishMergedMutation =
|
||||
usePublishProjectPullRequestMergedMutation(project);
|
||||
const targetBranch = pullRequest.targetBranch ?? project.defaultBranch;
|
||||
const conflictRecovery =
|
||||
conflictRecoveryState?.pullRequestId === pullRequest.id
|
||||
? conflictRecoveryState.recovery
|
||||
: null;
|
||||
const unpublishedStatusEvent =
|
||||
unpublishedStatusState?.pullRequestId === pullRequest.id
|
||||
? unpublishedStatusState.event
|
||||
: null;
|
||||
const preparedRecovery =
|
||||
preparedRecoveryState?.pullRequestId === pullRequest.id
|
||||
? preparedRecoveryState
|
||||
: null;
|
||||
|
||||
const handleMerge = React.useCallback(async () => {
|
||||
try {
|
||||
const result = await mergeMutation.mutateAsync({ pullRequest });
|
||||
if (result.statusPublicationError) {
|
||||
setUnpublishedStatusEvent(result.statusEvent);
|
||||
setUnpublishedStatusState({
|
||||
event: result.statusEvent,
|
||||
pullRequestId: pullRequest.id,
|
||||
});
|
||||
toast.warning(result.message, {
|
||||
description: result.statusPublicationError,
|
||||
});
|
||||
} else {
|
||||
setUnpublishedStatusEvent(null);
|
||||
setUnpublishedStatusState(null);
|
||||
toast.success(result.message);
|
||||
}
|
||||
setConflictRecoveryState(null);
|
||||
setPreparedRecoveryState(null);
|
||||
setConfirmOpen(false);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ProjectPullRequestMergeError &&
|
||||
error.code === "merge_conflict" &&
|
||||
error.recovery
|
||||
) {
|
||||
setConflictRecoveryState({
|
||||
pullRequestId: pullRequest.id,
|
||||
recovery: error.recovery,
|
||||
});
|
||||
setPreparedRecoveryState(null);
|
||||
setConfirmOpen(false);
|
||||
}
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
@@ -57,13 +112,58 @@ export function MergePullRequestButton({
|
||||
}
|
||||
}, [mergeMutation, pullRequest]);
|
||||
|
||||
const recoveryCommands =
|
||||
conflictRecovery && preparedRecovery
|
||||
? projectPullRequestConflictCommands({
|
||||
recoveryRef: preparedRecovery.recoveryRef,
|
||||
targetBranch: conflictRecovery.targetBranch,
|
||||
targetRef: preparedRecovery.targetRef,
|
||||
})
|
||||
: [];
|
||||
|
||||
const handleOpenRecoveryTerminal = React.useCallback(async () => {
|
||||
const sourceCloneUrl = pullRequest.cloneUrls[0] ?? project.cloneUrls[0];
|
||||
if (!conflictRecovery || !pullRequest.commit || !sourceCloneUrl) return;
|
||||
setIsPreparingRecovery(true);
|
||||
try {
|
||||
const result = await onOpenTerminal?.({
|
||||
expectedCommit: pullRequest.commit,
|
||||
sourceBranch: conflictRecovery.sourceBranch,
|
||||
sourceCloneUrl,
|
||||
targetBranch: conflictRecovery.targetBranch,
|
||||
});
|
||||
if (!result) return;
|
||||
setPreparedRecoveryState({
|
||||
pullRequestId: pullRequest.id,
|
||||
recoveryRef: result.recoveryRef,
|
||||
targetRef: result.targetRef,
|
||||
});
|
||||
toast.success("Recovery commit fetched and terminal opened.");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to prepare merge recovery.",
|
||||
);
|
||||
} finally {
|
||||
setIsPreparingRecovery(false);
|
||||
}
|
||||
}, [
|
||||
conflictRecovery,
|
||||
onOpenTerminal,
|
||||
project.cloneUrls,
|
||||
pullRequest.cloneUrls,
|
||||
pullRequest.commit,
|
||||
pullRequest.id,
|
||||
]);
|
||||
|
||||
const handlePublishMergedStatus = React.useCallback(async () => {
|
||||
if (!unpublishedStatusEvent) return;
|
||||
try {
|
||||
await publishMergedMutation.mutateAsync({
|
||||
statusEvent: unpublishedStatusEvent,
|
||||
});
|
||||
setUnpublishedStatusEvent(null);
|
||||
setUnpublishedStatusState(null);
|
||||
toast.success("Published merged pull request status.");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
@@ -75,55 +175,115 @@ export function MergePullRequestButton({
|
||||
}, [publishMergedMutation, unpublishedStatusEvent]);
|
||||
|
||||
return (
|
||||
<AlertDialog onOpenChange={setConfirmOpen} open={confirmOpen}>
|
||||
<Button
|
||||
className="h-8 gap-1.5 bg-purple-600 px-3.5 text-white shadow-sm hover:bg-purple-700"
|
||||
disabled={mergeMutation.isPending || publishMergedMutation.isPending}
|
||||
onClick={() => {
|
||||
if (unpublishedStatusEvent) {
|
||||
void handlePublishMergedStatus();
|
||||
} else {
|
||||
setConfirmOpen(true);
|
||||
}
|
||||
}}
|
||||
size="xs"
|
||||
type="button"
|
||||
>
|
||||
<GitMerge className="h-3.5 w-3.5" />
|
||||
{publishMergedMutation.isPending
|
||||
? "Publishing…"
|
||||
: unpublishedStatusEvent
|
||||
? "Publish merged status"
|
||||
: "Merge"}
|
||||
</Button>
|
||||
<AlertDialogContent data-testid="merge-pull-request-confirm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Merge pull request?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Merge {pullRequest.branchName} into {targetBranch} and push the
|
||||
result to the repository. The remote will reject the operation if
|
||||
the branch changed or conflicts.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={mergeMutation.isPending}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<div className="contents">
|
||||
<AlertDialog onOpenChange={setConfirmOpen} open={confirmOpen}>
|
||||
<Button
|
||||
className="h-8 gap-1.5 bg-purple-600 px-3.5 text-white shadow-sm hover:bg-purple-700"
|
||||
disabled={mergeMutation.isPending || publishMergedMutation.isPending}
|
||||
onClick={() => {
|
||||
if (unpublishedStatusEvent) {
|
||||
void handlePublishMergedStatus();
|
||||
} else {
|
||||
setConfirmOpen(true);
|
||||
}
|
||||
}}
|
||||
size="xs"
|
||||
type="button"
|
||||
>
|
||||
<GitMerge className="h-3.5 w-3.5" />
|
||||
{publishMergedMutation.isPending
|
||||
? "Publishing…"
|
||||
: unpublishedStatusEvent
|
||||
? "Publish merged status"
|
||||
: "Merge"}
|
||||
</Button>
|
||||
<AlertDialogContent data-testid="merge-pull-request-confirm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Merge pull request?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Merge {pullRequest.branchName} into {targetBranch} and push the
|
||||
result to the repository. The remote will reject the operation if
|
||||
the branch changed or conflicts.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={mergeMutation.isPending}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button
|
||||
data-testid="merge-pull-request-confirm-button"
|
||||
disabled={mergeMutation.isPending}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void handleMerge();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{mergeMutation.isPending ? "Merging…" : "Merge pull request"}
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{conflictRecovery ? (
|
||||
<div
|
||||
className="w-full basis-full space-y-2 rounded-lg border border-amber-500/35 bg-amber-500/10 p-3"
|
||||
data-testid="merge-conflict-recovery"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Resolve conflicts in your local checkout
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Prepare the local checkout, then switch to{" "}
|
||||
{conflictRecovery.targetBranch} with the commands shown. After
|
||||
resolving and committing, push the target branch and retry the
|
||||
merge.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{preparedRecovery ? (
|
||||
<pre className="overflow-x-auto rounded-md bg-background/80 p-2 font-mono text-xs text-foreground">
|
||||
{recoveryCommands.join("\n")}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="rounded-md bg-background/80 p-2 text-xs text-muted-foreground">
|
||||
Resolve in Terminal securely fetches the target and pull request
|
||||
commits before showing copyable commands.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
data-testid="merge-pull-request-confirm-button"
|
||||
disabled={mergeMutation.isPending}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void handleMerge();
|
||||
}}
|
||||
disabled={!onOpenTerminal || isPreparingRecovery}
|
||||
onClick={() => void handleOpenRecoveryTerminal()}
|
||||
size="xs"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{mergeMutation.isPending ? "Merging…" : "Merge pull request"}
|
||||
<SquareTerminal className="h-3.5 w-3.5" />
|
||||
{isPreparingRecovery ? "Preparing…" : "Resolve in Terminal"}
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<Button
|
||||
disabled={!preparedRecovery}
|
||||
onClick={() =>
|
||||
copyTextToClipboard(
|
||||
recoveryCommands.join("\n"),
|
||||
"Recovery commands copied",
|
||||
)
|
||||
}
|
||||
size="xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
Copy commands
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
profilePanelViewFromSearch,
|
||||
} from "@/features/profile/ui/UserProfilePanelUtils";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { openProjectMergeRecoveryTerminal } from "@/shared/api/projectGit";
|
||||
import { useMainInsetRef } from "@/shared/layout/MainInsetContext";
|
||||
import {
|
||||
channelChrome,
|
||||
@@ -576,6 +577,26 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
|
||||
hasLocalCheckout,
|
||||
});
|
||||
}, [activeBranch, hasLocalCheckout, openTerminal, project]);
|
||||
const handleOpenMergeRecoveryTerminal = React.useCallback(
|
||||
async (input: {
|
||||
expectedCommit: string;
|
||||
sourceBranch: string;
|
||||
sourceCloneUrl: string;
|
||||
targetBranch: string;
|
||||
}) => {
|
||||
const targetCloneUrl = project?.cloneUrls[0];
|
||||
if (!project || !targetCloneUrl) {
|
||||
throw new Error("No project selected.");
|
||||
}
|
||||
return openProjectMergeRecoveryTerminal({
|
||||
...input,
|
||||
projectDtag: project.dtag,
|
||||
reposDir: activeCommunity?.reposDir,
|
||||
targetCloneUrl,
|
||||
});
|
||||
},
|
||||
[activeCommunity?.reposDir, project],
|
||||
);
|
||||
|
||||
if (projectQuery.isLoading) {
|
||||
return null;
|
||||
@@ -850,6 +871,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
|
||||
localSnapshotError={localRepoSnapshotQuery.error}
|
||||
localSnapshotLoading={localRepoSnapshotQuery.isLoading}
|
||||
onBranchChange={setSelectedBranch}
|
||||
onOpenMergeRecoveryTerminal={handleOpenMergeRecoveryTerminal}
|
||||
onOpenTerminal={() => {
|
||||
void handleOpenTerminal();
|
||||
}}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
ProjectFeedRowMonoCell,
|
||||
} from "./ProjectFeedRow";
|
||||
import { CopyCommitHashButton } from "./ProjectCommitCopyButton";
|
||||
import type { OpenMergeRecoveryTerminal } from "./MergePullRequestButton";
|
||||
import { OverviewRailSection } from "./ProjectOverviewPanel";
|
||||
import {
|
||||
ProfileAuthorName,
|
||||
@@ -448,12 +449,14 @@ export function PullRequestMetaRail({
|
||||
function PullRequestDetail({
|
||||
mode,
|
||||
onOpenCommit,
|
||||
onOpenTerminal,
|
||||
profiles,
|
||||
project,
|
||||
pullRequest,
|
||||
}: {
|
||||
mode: PullRequestPanelMode;
|
||||
onOpenCommit?: (commitHash: string) => void;
|
||||
onOpenTerminal?: OpenMergeRecoveryTerminal;
|
||||
profiles?: UserProfileLookup;
|
||||
project: Project;
|
||||
pullRequest: ProjectPullRequest;
|
||||
@@ -663,7 +666,11 @@ function PullRequestDetail({
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<PullRequestReviewCard project={project} pullRequest={pullRequest} />
|
||||
<PullRequestReviewCard
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
project={project}
|
||||
pullRequest={pullRequest}
|
||||
/>
|
||||
<h4 className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
Add Your Comment
|
||||
@@ -687,6 +694,7 @@ export function PullRequestsPanel({
|
||||
isLoading,
|
||||
mode = "conversation",
|
||||
onOpenCommit,
|
||||
onOpenTerminal,
|
||||
onSelectedPullRequestIdChange,
|
||||
profiles,
|
||||
project,
|
||||
@@ -697,6 +705,7 @@ export function PullRequestsPanel({
|
||||
isLoading: boolean;
|
||||
mode?: PullRequestPanelMode;
|
||||
onOpenCommit?: (commitHash: string) => void;
|
||||
onOpenTerminal?: OpenMergeRecoveryTerminal;
|
||||
onSelectedPullRequestIdChange: (id: string | null) => void;
|
||||
profiles?: UserProfileLookup;
|
||||
project: Project;
|
||||
@@ -738,6 +747,7 @@ export function PullRequestsPanel({
|
||||
<PullRequestDetail
|
||||
mode={mode}
|
||||
onOpenCommit={onOpenCommit}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
profiles={profiles}
|
||||
project={project}
|
||||
pullRequest={selectedPullRequest}
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { RepoSourceHeaderControls } from "./ProjectRepositorySource";
|
||||
import { ProjectCommitDetailPanel } from "./ProjectCommitDetailPanel";
|
||||
import { ActivityPanel, ContributorsPanel } from "./ProjectDetailFeedPanels";
|
||||
import { ProjectIssuesPanel } from "./ProjectIssuesPanel";
|
||||
import type { OpenMergeRecoveryTerminal } from "./MergePullRequestButton";
|
||||
import { ProjectOverviewPanel } from "./ProjectOverviewPanel";
|
||||
import {
|
||||
PullRequestDetailHeader,
|
||||
@@ -124,6 +125,7 @@ export function WorkspaceTabs({
|
||||
onSelectedPullRequestIdChange,
|
||||
onSelectedTabChange,
|
||||
onBranchChange,
|
||||
onOpenMergeRecoveryTerminal,
|
||||
onOpenTerminal,
|
||||
snapshot,
|
||||
snapshotError,
|
||||
@@ -160,6 +162,7 @@ export function WorkspaceTabs({
|
||||
/** Reports the active tab so the screen breadcrumb can mirror it. */
|
||||
onSelectedTabChange?: (tab: string) => void;
|
||||
onBranchChange: (branch: string | null) => void;
|
||||
onOpenMergeRecoveryTerminal?: OpenMergeRecoveryTerminal;
|
||||
onOpenTerminal?: () => void;
|
||||
snapshot: ProjectRepoSnapshot | null | undefined;
|
||||
snapshotError: unknown;
|
||||
@@ -260,7 +263,7 @@ export function WorkspaceTabs({
|
||||
<Button
|
||||
aria-label="Open terminal"
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onOpenTerminal}
|
||||
onClick={() => onOpenTerminal()}
|
||||
size="icon"
|
||||
title={terminalTitle ?? "Open terminal"}
|
||||
variant="ghost"
|
||||
@@ -305,6 +308,7 @@ export function WorkspaceTabs({
|
||||
isLoading={pullRequestsLoading}
|
||||
mode={mode}
|
||||
onOpenCommit={onSelectedCommitHashChange}
|
||||
onOpenTerminal={onOpenMergeRecoveryTerminal}
|
||||
onSelectedPullRequestIdChange={
|
||||
onSelectedPullRequestIdChange
|
||||
}
|
||||
@@ -399,6 +403,7 @@ export function WorkspaceTabs({
|
||||
error={pullRequestsError}
|
||||
isLoading={pullRequestsLoading}
|
||||
onOpenCommit={onSelectedCommitHashChange}
|
||||
onOpenTerminal={onOpenMergeRecoveryTerminal}
|
||||
onSelectedPullRequestIdChange={onSelectedPullRequestIdChange}
|
||||
profiles={profiles}
|
||||
project={project}
|
||||
|
||||
@@ -16,13 +16,18 @@ import {
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { MergePullRequestButton } from "./MergePullRequestButton";
|
||||
import {
|
||||
MergePullRequestButton,
|
||||
type OpenMergeRecoveryTerminal,
|
||||
} from "./MergePullRequestButton";
|
||||
|
||||
/** GitHub-style review state and actions rendered in the conversation flow. */
|
||||
export function PullRequestReviewCard({
|
||||
onOpenTerminal,
|
||||
project,
|
||||
pullRequest,
|
||||
}: {
|
||||
onOpenTerminal?: OpenMergeRecoveryTerminal;
|
||||
project: Project;
|
||||
pullRequest: ProjectPullRequest;
|
||||
}) {
|
||||
@@ -236,6 +241,7 @@ export function PullRequestReviewCard({
|
||||
) : null}
|
||||
{canMerge ? (
|
||||
<MergePullRequestButton
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
project={project}
|
||||
pullRequest={pullRequest}
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
ProjectRepoSnapshot,
|
||||
ProjectRepoSyncStatus,
|
||||
} from "@/shared/api/types";
|
||||
import { invokeTauri } from "@/shared/api/tauri";
|
||||
import { invokeTauri, TauriInvokeError } from "@/shared/api/tauri";
|
||||
|
||||
type RawProjectRepoCommit = {
|
||||
hash: string;
|
||||
@@ -337,6 +337,34 @@ export async function openProjectTerminal(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function openProjectMergeRecoveryTerminal(input: {
|
||||
reposDir?: string | null;
|
||||
projectDtag: string;
|
||||
targetCloneUrl: string;
|
||||
sourceCloneUrl: string;
|
||||
targetBranch: string;
|
||||
sourceBranch: string;
|
||||
expectedCommit: string;
|
||||
}): Promise<{
|
||||
path: string;
|
||||
cloned: boolean;
|
||||
recoveryRef: string;
|
||||
targetRef: string;
|
||||
}> {
|
||||
const result = await invokeTauri<{
|
||||
path: string;
|
||||
cloned: boolean;
|
||||
recoveryRef: string;
|
||||
targetRef: string;
|
||||
}>("open_project_merge_recovery_terminal", {
|
||||
input: {
|
||||
...input,
|
||||
reposDir: input.reposDir ?? null,
|
||||
},
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function pushProjectLocalRepository(input: {
|
||||
reposDir?: string | null;
|
||||
projectDtag: string;
|
||||
@@ -405,6 +433,84 @@ type RawProjectRepoMergeResult = {
|
||||
status_publication_error: string | null;
|
||||
};
|
||||
|
||||
export type ProjectPullRequestMergeRecovery = {
|
||||
action: "open_terminal";
|
||||
targetBranch: string;
|
||||
sourceBranch: string;
|
||||
};
|
||||
|
||||
/** Machine-readable pull-request merge failure returned by the desktop shell. */
|
||||
export class ProjectPullRequestMergeError extends Error {
|
||||
readonly code: string;
|
||||
readonly recovery: ProjectPullRequestMergeRecovery | null;
|
||||
|
||||
constructor(
|
||||
code: string,
|
||||
message: string,
|
||||
recovery: ProjectPullRequestMergeRecovery | null,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ProjectPullRequestMergeError";
|
||||
this.code = code;
|
||||
this.recovery = recovery;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeErrorPayload(error: unknown): unknown {
|
||||
const payload = error instanceof TauriInvokeError ? error.payload : error;
|
||||
if (typeof payload !== "string") return payload;
|
||||
try {
|
||||
return JSON.parse(payload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a structured native merge error without classifying generic failures. */
|
||||
export function parseProjectPullRequestMergeError(
|
||||
error: unknown,
|
||||
): ProjectPullRequestMergeError | null {
|
||||
const payload = mergeErrorPayload(error);
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const candidate = payload as {
|
||||
code?: unknown;
|
||||
message?: unknown;
|
||||
recovery?: unknown;
|
||||
};
|
||||
if (
|
||||
typeof candidate.code !== "string" ||
|
||||
typeof candidate.message !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
let recovery: ProjectPullRequestMergeRecovery | null = null;
|
||||
if (candidate.recovery !== null && candidate.recovery !== undefined) {
|
||||
if (typeof candidate.recovery !== "object") return null;
|
||||
const value = candidate.recovery as {
|
||||
action?: unknown;
|
||||
sourceBranch?: unknown;
|
||||
targetBranch?: unknown;
|
||||
};
|
||||
if (
|
||||
value.action !== "open_terminal" ||
|
||||
typeof value.targetBranch !== "string" ||
|
||||
typeof value.sourceBranch !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
recovery = {
|
||||
action: value.action,
|
||||
sourceBranch: value.sourceBranch,
|
||||
targetBranch: value.targetBranch,
|
||||
};
|
||||
}
|
||||
return new ProjectPullRequestMergeError(
|
||||
candidate.code,
|
||||
candidate.message,
|
||||
recovery,
|
||||
);
|
||||
}
|
||||
|
||||
export async function mergeProjectPullRequest(input: {
|
||||
targetCloneUrl: string;
|
||||
sourceCloneUrl: string;
|
||||
@@ -417,12 +523,17 @@ export async function mergeProjectPullRequest(input: {
|
||||
sourceBranch: string;
|
||||
expectedCommit: string;
|
||||
}): Promise<ProjectRepoMergeResult> {
|
||||
const result = await invokeTauri<RawProjectRepoMergeResult>(
|
||||
"merge_project_pull_request",
|
||||
{
|
||||
input,
|
||||
},
|
||||
);
|
||||
let result: RawProjectRepoMergeResult;
|
||||
try {
|
||||
result = await invokeTauri<RawProjectRepoMergeResult>(
|
||||
"merge_project_pull_request",
|
||||
{
|
||||
input,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
throw parseProjectPullRequestMergeError(error) ?? error;
|
||||
}
|
||||
return {
|
||||
message: result.message,
|
||||
mergeCommit: result.merge_commit,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
parseProjectPullRequestMergeError,
|
||||
ProjectPullRequestMergeError,
|
||||
} from "./projectGit.ts";
|
||||
|
||||
const CONFLICT = {
|
||||
code: "merge_conflict",
|
||||
message: "Pull request has merge conflicts.",
|
||||
recovery: {
|
||||
action: "open_terminal",
|
||||
targetBranch: "main",
|
||||
sourceBranch: "feature/demo",
|
||||
},
|
||||
};
|
||||
|
||||
test("parses structured merge conflict recovery metadata", () => {
|
||||
const error = parseProjectPullRequestMergeError(CONFLICT);
|
||||
|
||||
assert.ok(error instanceof ProjectPullRequestMergeError);
|
||||
assert.equal(error.code, "merge_conflict");
|
||||
assert.deepEqual(error.recovery, CONFLICT.recovery);
|
||||
});
|
||||
|
||||
test("parses JSON-serialized Tauri merge errors", () => {
|
||||
const error = parseProjectPullRequestMergeError(JSON.stringify(CONFLICT));
|
||||
|
||||
assert.ok(error instanceof ProjectPullRequestMergeError);
|
||||
assert.equal(error.message, "Pull request has merge conflicts.");
|
||||
});
|
||||
|
||||
test("rejects malformed recovery metadata", () => {
|
||||
assert.equal(
|
||||
parseProjectPullRequestMergeError({
|
||||
...CONFLICT,
|
||||
recovery: { ...CONFLICT.recovery, targetBranch: null },
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(parseProjectPullRequestMergeError(new Error("offline")), null);
|
||||
});
|
||||
@@ -241,13 +241,24 @@ type RawSetCanvasResult = {
|
||||
event_id: string;
|
||||
};
|
||||
|
||||
/** Error normalized from a rejected Tauri invocation with its wire payload. */
|
||||
export class TauriInvokeError extends Error {
|
||||
readonly payload: unknown;
|
||||
|
||||
constructor(message: string, payload: unknown) {
|
||||
super(message);
|
||||
this.name = "TauriInvokeError";
|
||||
this.payload = payload;
|
||||
}
|
||||
}
|
||||
|
||||
function toTauriError(error: unknown): Error {
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (typeof error === "string") {
|
||||
return new Error(error);
|
||||
return new TauriInvokeError(error, error);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -256,13 +267,13 @@ function toTauriError(error: unknown): Error {
|
||||
"message" in error &&
|
||||
typeof error.message === "string"
|
||||
) {
|
||||
return new Error(error.message);
|
||||
return new TauriInvokeError(error.message, error);
|
||||
}
|
||||
|
||||
try {
|
||||
return new Error(JSON.stringify(error));
|
||||
return new TauriInvokeError(JSON.stringify(error), error);
|
||||
} catch {
|
||||
return new Error("Unknown Tauri error");
|
||||
return new TauriInvokeError("Unknown Tauri error", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -940,6 +940,16 @@ declare global {
|
||||
}>;
|
||||
/** Project event kinds rejected once, in order, to exercise retry flows. */
|
||||
__BUZZ_E2E_REJECT_PROJECT_EVENT_KINDS__?: number[];
|
||||
/** Structured merge error returned by the mock native merge command. */
|
||||
__BUZZ_E2E_PROJECT_MERGE_ERROR__?: {
|
||||
code: string;
|
||||
message: string;
|
||||
recovery: {
|
||||
action: "open_terminal";
|
||||
sourceBranch: string;
|
||||
targetBranch: string;
|
||||
} | null;
|
||||
};
|
||||
/** Overrides the first mock repository owner for delegated-owner tests. */
|
||||
__BUZZ_E2E_PROJECT_OWNER_OVERRIDE__?: string;
|
||||
/** Project history kinds rejected with CLOSED for aggregate-query tests. */
|
||||
@@ -9390,6 +9400,9 @@ export function maybeInstallE2eTauriMocks() {
|
||||
"Only the repository owner or the owner of its managed agent can merge pull requests.",
|
||||
);
|
||||
}
|
||||
if (window.__BUZZ_E2E_PROJECT_MERGE_ERROR__) {
|
||||
throw window.__BUZZ_E2E_PROJECT_MERGE_ERROR__;
|
||||
}
|
||||
const mergeCommit = "abcdef0123456789abcdef0123456789abcdef01";
|
||||
const statusEvent = createMockEvent(
|
||||
KIND_GIT_STATUS_MERGED,
|
||||
@@ -9431,6 +9444,22 @@ export function maybeInstallE2eTauriMocks() {
|
||||
status_publication_error: statusPublicationError,
|
||||
};
|
||||
}
|
||||
case "open_project_merge_recovery_terminal": {
|
||||
const { input } = payload as {
|
||||
input: { expectedCommit: string };
|
||||
};
|
||||
return {
|
||||
path: "/tmp/buzz/REPOS/buzz",
|
||||
cloned: false,
|
||||
recoveryRef: `refs/buzz/merge-recovery/${input.expectedCommit}`,
|
||||
targetRef: `refs/buzz/merge-recovery-target/${"f".repeat(40)}`,
|
||||
};
|
||||
}
|
||||
case "open_project_terminal":
|
||||
return {
|
||||
path: "/tmp/buzz/REPOS/buzz",
|
||||
cloned: false,
|
||||
};
|
||||
case "get_relay_ws_url":
|
||||
return getRelayWsUrl(activeConfig);
|
||||
case "get_default_relay_url":
|
||||
|
||||
@@ -4,6 +4,7 @@ import { waitForAnimations } from "../helpers/animations";
|
||||
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
||||
|
||||
const SHOTS = "test-results/project-pr-review";
|
||||
const RECOVERY_SHOTS = "test-results/project-pr-conflict-recovery";
|
||||
const REVIEWER_AGENT_PUBKEY = "a".repeat(64);
|
||||
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
|
||||
|
||||
@@ -267,6 +268,78 @@ test("PR creator/owner can toggle draft, request reviews, and approve", async ({
|
||||
});
|
||||
});
|
||||
|
||||
test("merge conflicts offer persistent terminal recovery", async ({ page }) => {
|
||||
await enableProjectsFeature(page);
|
||||
await installMockBridge(page);
|
||||
await openBuzzProject(page);
|
||||
await page.evaluate(() => {
|
||||
window.__BUZZ_E2E_PROJECT_MERGE_ERROR__ = {
|
||||
code: "merge_conflict",
|
||||
message: "Pull request has merge conflicts.",
|
||||
recovery: {
|
||||
action: "open_terminal",
|
||||
sourceBranch: "feature",
|
||||
targetBranch: "main",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await page.getByRole("tab", { name: "Pull Request" }).click();
|
||||
const aliceRow = page
|
||||
.getByTestId("project-pull-request-row")
|
||||
.filter({ hasText: "alice" })
|
||||
.first();
|
||||
await aliceRow.getByRole("button", { name: /^#/ }).click();
|
||||
await page.getByRole("button", { name: "Merge", exact: true }).click();
|
||||
await page.getByTestId("merge-pull-request-confirm-button").click();
|
||||
|
||||
const recovery = page.getByTestId("merge-conflict-recovery");
|
||||
await expect(recovery).toBeVisible();
|
||||
await expect(
|
||||
recovery.getByRole("button", { name: "Copy commands" }),
|
||||
).toBeDisabled();
|
||||
await waitForAnimations(page);
|
||||
await recovery.screenshot({
|
||||
path: `${RECOVERY_SHOTS}/01-merge-conflict.png`,
|
||||
});
|
||||
await recovery.getByRole("button", { name: "Resolve in Terminal" }).click();
|
||||
await expect(
|
||||
page.getByText("Recovery commit fetched and terminal opened."),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Recovery commit fetched and terminal opened."),
|
||||
).toBeHidden({ timeout: 10_000 });
|
||||
await expect(recovery).toContainText("git switch 'main'");
|
||||
await expect(recovery).toContainText("git merge 'refs/buzz/merge-recovery/");
|
||||
await expect(
|
||||
recovery.getByRole("button", { name: "Copy commands" }),
|
||||
).toBeEnabled();
|
||||
await waitForAnimations(page);
|
||||
await recovery.screenshot({
|
||||
path: `${RECOVERY_SHOTS}/02-merge-conflict-prepared.png`,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
window.__BUZZ_E2E_COMMAND_PAYLOADS__?.find(
|
||||
(entry) => entry.command === "open_project_merge_recovery_terminal",
|
||||
) ?? null,
|
||||
),
|
||||
)
|
||||
.toMatchObject({
|
||||
command: "open_project_merge_recovery_terminal",
|
||||
payload: {
|
||||
input: {
|
||||
expectedCommit: expect.any(String),
|
||||
sourceBranch: "feature",
|
||||
targetBranch: "main",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("reviewer can leave a commit-scoped inline diff comment", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user