From f81d46d7ed02aa4fcbbf71888fb65c2582ef9336 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Thu, 16 Jul 2026 22:38:35 +0200 Subject: [PATCH 1/8] feat(desktop): complete the Projects pull request workflow Let users clone repositories, create and update pull requests, and safely merge them without leaving the desktop app. Harden native Git operations and preserve recoverability when relay status publication fails. --- desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/commands/project_git.rs | 95 ++++-- .../src/commands/project_git_diff.rs | 6 +- .../src/commands/project_git_exec.rs | 109 ++++++- .../src/commands/project_git_workflow.rs | 290 ++++++++++++++++++ .../src/commands/project_repo_paths.rs | 86 +++++- .../src/commands/project_terminal.rs | 87 ++---- desktop/src-tauri/src/lib.rs | 2 + desktop/src/features/projects/hooks.ts | 8 +- .../projects/lib/projectsViewHelpers.ts | 4 +- .../projects/pullRequestMutations.test.mjs | 82 +++++ .../features/projects/pullRequestMutations.ts | 268 ++++++++++++++++ .../features/projects/pullRequestReviews.ts | 10 +- .../src/features/projects/repoSyncHooks.ts | 89 +++++- .../projects/ui/CreatePullRequestDialog.tsx | 169 ++++++++++ .../projects/ui/MergePullRequestButton.tsx | 129 ++++++++ .../projects/ui/ProjectDetailScreen.tsx | 219 ++++++++++++- .../projects/ui/ProjectPullRequestsPanel.tsx | 16 +- .../projects/ui/ProjectWorkspaceTabs.tsx | 93 +++++- desktop/src/shared/api/projectGit.ts | 70 ++++- desktop/src/shared/api/projectGitTypes.ts | 15 + desktop/src/shared/api/types.ts | 2 + desktop/src/testing/e2eBridge.ts | 117 +++++-- desktop/tests/e2e/project-pr-review.spec.ts | 204 +++++++++++- 24 files changed, 1999 insertions(+), 173 deletions(-) create mode 100644 desktop/src-tauri/src/commands/project_git_workflow.rs create mode 100644 desktop/src/features/projects/pullRequestMutations.test.mjs create mode 100644 desktop/src/features/projects/pullRequestMutations.ts create mode 100644 desktop/src/features/projects/ui/CreatePullRequestDialog.tsx create mode 100644 desktop/src/features/projects/ui/MergePullRequestButton.tsx diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index bf87faac7e..592d4d8584 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -35,6 +35,7 @@ mod profile; mod project_git; mod project_git_diff; mod project_git_exec; +mod project_git_workflow; mod project_repo_paths; mod project_terminal; mod relay_members; @@ -80,6 +81,7 @@ pub use prevent_sleep::*; pub use profile::*; pub use project_git::*; pub use project_git_diff::*; +pub use project_git_workflow::*; pub use project_terminal::*; pub use relay_members::*; pub use relay_reconnect::*; diff --git a/desktop/src-tauri/src/commands/project_git.rs b/desktop/src-tauri/src/commands/project_git.rs index 743e29f740..3af0abd2da 100644 --- a/desktop/src-tauri/src/commands/project_git.rs +++ b/desktop/src-tauri/src/commands/project_git.rs @@ -1,5 +1,5 @@ use super::project_git_exec::{ - build_git_auth_config, clean_branch, run_git, validate_clone_url, GitAuthConfig, + build_git_auth_config, clean_branch, run_git, validate_workspace_clone_url, GitAuthConfig, }; use super::project_repo_paths::{canonical_repos_roots, find_local_repo_dir}; use crate::app_state::AppState; @@ -57,6 +57,7 @@ pub struct ProjectRepoSyncStatusInfo { pub remote_branch: Option, pub remote_head: Option, pub remote_short_head: Option, + pub merge_base: Option, pub ahead_count: usize, pub behind_count: usize, pub has_uncommitted_changes: bool, @@ -70,6 +71,9 @@ pub struct ProjectRepoSyncStatusInfo { pub struct ProjectRepoPushResult { pub pushed: bool, pub message: String, + pub branch: String, + pub commit: String, + pub merge_base: Option, } #[derive(Serialize)] pub struct ProjectRepoPullResult { @@ -104,7 +108,7 @@ fn short_hash(hash: &str) -> String { hash.chars().take(7).collect() } -fn first_output_line(output: &str) -> Option { +pub(crate) fn first_output_line(output: &str) -> Option { output .lines() .next() @@ -492,6 +496,7 @@ fn compare_local_remote_status( repo_dir: &std::path::Path, clone_url: &str, branch_name: Option<&str>, + base_branch: Option<&str>, auth: &GitAuthConfig, ) -> ProjectRepoSyncStatusInfo { let local_branch = run_git(&["branch", "--show-current"], Some(repo_dir), auth) @@ -518,18 +523,20 @@ fn compare_local_remote_status( auth, ); } - let _ = run_git( - &[ - "fetch", - "--quiet", - "--depth=100", - "--end-of-options", - "origin", - branch.as_str(), - ], - Some(repo_dir), - auth, - ); + let base_branch = + normalize_branch_option(base_branch).filter(|base_branch| *base_branch != branch); + let mut fetch_args = vec![ + "fetch", + "--quiet", + "--depth=100", + "--end-of-options", + "origin", + branch.as_str(), + ]; + if let Some(base_branch) = base_branch.as_deref() { + fetch_args.push(base_branch); + } + let _ = run_git(&fetch_args, Some(repo_dir), auth); let local_head = run_git(&["rev-parse", "HEAD"], Some(repo_dir), auth) .ok() @@ -542,6 +549,19 @@ fn compare_local_remote_status( ) .ok() .and_then(|output| first_output_line(&output)); + let merge_base = base_branch.as_deref().and_then(|base_branch| { + run_git( + &[ + "merge-base", + "HEAD", + format!("origin/{base_branch}").as_str(), + ], + Some(repo_dir), + auth, + ) + .ok() + .and_then(|output| first_output_line(&output)) + }); let status = run_git(&["status", "--porcelain"], Some(repo_dir), auth).unwrap_or_default(); let has_uncommitted_changes = has_uncommitted_changes(&status); let has_untracked_files = has_untracked_files(&status); @@ -576,6 +596,10 @@ fn compare_local_remote_status( let push_block_reason = if local_head.is_none() { Some("No local commits to push.".to_string()) + } else if local_branch.as_deref() != Some(branch.as_str()) { + Some(format!( + "Local checkout is on a different branch than {branch}." + )) } else if has_uncommitted_changes || has_untracked_files { Some("Commit or discard local changes before pushing.".to_string()) } else if behind_count > 0 { @@ -615,6 +639,7 @@ fn compare_local_remote_status( remote_branch: Some(branch), remote_head: remote_head.clone(), remote_short_head: remote_head.as_deref().map(short_hash), + merge_base, ahead_count, behind_count, has_uncommitted_changes, @@ -657,7 +682,7 @@ pub async fn get_project_repo_snapshot( target_commit: Option, state: State<'_, AppState>, ) -> Result { - validate_clone_url(&clone_url)?; + validate_workspace_clone_url(&clone_url, &state)?; let auth = build_git_auth_config(&state)?; let branch = clean_branch(default_branch); let base_branch = clean_branch(base_branch); @@ -793,10 +818,11 @@ pub async fn get_project_repo_sync_status( repos_dir: Option, project_dtag: String, clone_url: String, - default_branch: Option, + branch_name: Option, + base_branch: Option, state: State<'_, AppState>, ) -> Result { - validate_clone_url(&clone_url)?; + validate_workspace_clone_url(&clone_url, &state)?; let auth = build_git_auth_config(&state)?; tauri::async_runtime::spawn_blocking(move || { @@ -808,11 +834,12 @@ pub async fn get_project_repo_sync_status( local_branch: None, local_head: None, local_short_head: None, - remote_branch: default_branch + remote_branch: branch_name .as_deref() .and_then(|branch| normalize_branch_option(Some(branch))), remote_head: None, remote_short_head: None, + merge_base: None, ahead_count: 0, behind_count: 0, has_uncommitted_changes: false, @@ -827,7 +854,8 @@ pub async fn get_project_repo_sync_status( Ok(compare_local_remote_status( &repo_dir, &clone_url, - default_branch.as_deref(), + branch_name.as_deref(), + base_branch.as_deref(), &auth, )) }) @@ -840,10 +868,11 @@ pub async fn push_project_local_repository( repos_dir: Option, project_dtag: String, clone_url: String, - default_branch: Option, + branch_name: Option, + base_branch: Option, state: State<'_, AppState>, ) -> Result { - validate_clone_url(&clone_url)?; + validate_workspace_clone_url(&clone_url, &state)?; let auth = build_git_auth_config(&state)?; tauri::async_runtime::spawn_blocking(move || { @@ -852,8 +881,13 @@ pub async fn push_project_local_repository( else { return Err("No local checkout found.".to_string()); }; - let status = - compare_local_remote_status(&repo_dir, &clone_url, default_branch.as_deref(), &auth); + let status = compare_local_remote_status( + &repo_dir, + &clone_url, + branch_name.as_deref(), + base_branch.as_deref(), + &auth, + ); if !status.can_push { return Err(status .push_block_reason @@ -861,8 +895,12 @@ pub async fn push_project_local_repository( } let branch = status .remote_branch - .as_deref() + .clone() .ok_or_else(|| "No branch selected for push.".to_string())?; + let commit = status + .local_head + .clone() + .ok_or_else(|| "No local commit selected for push.".to_string())?; run_git( &[ "push", @@ -877,6 +915,9 @@ pub async fn push_project_local_repository( Ok(ProjectRepoPushResult { pushed: true, message: format!("Pushed {branch} to remote."), + branch, + commit, + merge_base: status.merge_base, }) }) .await @@ -890,10 +931,10 @@ pub async fn pull_project_local_repository( repos_dir: Option, project_dtag: String, clone_url: String, - default_branch: Option, + branch_name: Option, state: State<'_, AppState>, ) -> Result { - validate_clone_url(&clone_url)?; + validate_workspace_clone_url(&clone_url, &state)?; let auth = build_git_auth_config(&state)?; tauri::async_runtime::spawn_blocking(move || { @@ -903,7 +944,7 @@ pub async fn pull_project_local_repository( return Err("No local checkout found.".to_string()); }; let status = - compare_local_remote_status(&repo_dir, &clone_url, default_branch.as_deref(), &auth); + compare_local_remote_status(&repo_dir, &clone_url, branch_name.as_deref(), None, &auth); if !status.can_pull { return Err(status .pull_block_reason diff --git a/desktop/src-tauri/src/commands/project_git_diff.rs b/desktop/src-tauri/src/commands/project_git_diff.rs index 9111272211..0bd07cbffd 100644 --- a/desktop/src-tauri/src/commands/project_git_diff.rs +++ b/desktop/src-tauri/src/commands/project_git_diff.rs @@ -1,5 +1,5 @@ use super::project_git_exec::{ - build_git_auth_config, clean_branch, run_git, validate_clone_url, GitAuthConfig, + build_git_auth_config, clean_branch, run_git, validate_workspace_clone_url, GitAuthConfig, }; use super::project_repo_paths::find_local_repo_dir; use crate::app_state::AppState; @@ -37,7 +37,7 @@ fn clean_target_ref(value: Option) -> Option { }) } -fn clean_commit(value: Option) -> Option { +pub(crate) fn clean_commit(value: Option) -> Option { value .filter(|value| matches!(value.len(), 40 | 64)) .filter(|value| value.chars().all(|c| c.is_ascii_hexdigit())) @@ -388,7 +388,7 @@ pub async fn get_project_repo_diff( target_commit: Option, state: State<'_, AppState>, ) -> Result { - validate_clone_url(&clone_url)?; + validate_workspace_clone_url(&clone_url, &state)?; let auth = build_git_auth_config(&state)?; let branch = clean_branch(default_branch); let base_branch = clean_branch(base_branch); diff --git a/desktop/src-tauri/src/commands/project_git_exec.rs b/desktop/src-tauri/src/commands/project_git_exec.rs index 651ecf7ad7..db67483940 100644 --- a/desktop/src-tauri/src/commands/project_git_exec.rs +++ b/desktop/src-tauri/src/commands/project_git_exec.rs @@ -41,7 +41,11 @@ pub(crate) fn run_git( if let Some(cwd) = cwd { command.current_dir(cwd); } - configure_git_auth(&mut command, auth); + let needs_credentials = matches!( + args.first().copied(), + Some("clone" | "fetch" | "push" | "pull" | "ls-remote") + ); + configure_git_auth(&mut command, auth, needs_credentials); command.stdin(Stdio::null()); command.stdout(Stdio::piped()); command.stderr(Stdio::piped()); @@ -92,9 +96,20 @@ pub(crate) fn run_git( Ok(stdout) } -fn configure_git_auth(command: &mut Command, auth: &GitAuthConfig) { +fn configure_git_auth(command: &mut Command, auth: &GitAuthConfig, needs_credentials: bool) { command.env("GIT_TERMINAL_PROMPT", "0"); command.env("GIT_CONFIG_NOSYSTEM", "1"); + for key in [ + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_SSH_COMMAND", + "GIT_EXTERNAL_DIFF", + ] { + command.env_remove(key); + } // Git for Windows maps `/dev/null` to `NUL` internally, so this value // disables the global config file on every platform. command.env("GIT_CONFIG_GLOBAL", "/dev/null"); @@ -106,12 +121,25 @@ fn configure_git_auth(command: &mut Command, auth: &GitAuthConfig) { let mut entries: Vec<(&str, String)> = vec![ ("credential.helper", String::new()), ("core.hooksPath", "/dev/null".to_string()), + ("core.fsmonitor", "false".to_string()), + ("protocol.allow", "never".to_string()), + ("protocol.http.allow", "always".to_string()), + ("protocol.https.allow", "always".to_string()), + ("protocol.ext.allow", "never".to_string()), + ("protocol.file.allow", "never".to_string()), ]; - if let Some(cred_helper) = &auth.credential_helper { + if needs_credentials { + let Some(cred_helper) = &auth.credential_helper else { + return apply_git_config(command, &entries); + }; command.env("NOSTR_PRIVATE_KEY", &auth.nsec); entries.push(("credential.helper", cred_helper.display().to_string())); entries.push(("credential.useHttpPath", "true".to_string())); } + apply_git_config(command, &entries); +} + +fn apply_git_config(command: &mut Command, entries: &[(&str, String)]) { command.env("GIT_CONFIG_COUNT", entries.len().to_string()); for (index, (key, value)) in entries.iter().enumerate() { command.env(format!("GIT_CONFIG_KEY_{index}"), key); @@ -171,21 +199,61 @@ pub(crate) fn validate_clone_url(clone_url: &str) -> Result<(), String> { .path_segments() .map(|segments| segments.filter(|s| !s.is_empty()).collect::>()) .unwrap_or_default(); - let is_buzz_repo_path = segments.windows(3).any(|window| { - window[0] == "git" - && window[1].len() == 64 - && window[1].chars().all(|c| c.is_ascii_hexdigit()) - && !window[2].is_empty() - }); + let is_buzz_repo_path = segments + .iter() + .rposition(|segment| *segment == "git") + .filter(|index| segments.len() == index + 3) + .map(|index| { + segments[index + 1].len() == 64 + && segments[index + 1].chars().all(|c| c.is_ascii_hexdigit()) + && !segments[index + 2].is_empty() + }) + .unwrap_or(false); if !is_buzz_repo_path { return Err("clone URL must point at a Buzz git repository".into()); } Ok(()) } +pub(crate) fn clone_url_owner(clone_url: &str) -> Option { + let parsed = Url::parse(clone_url).ok()?; + let segments = parsed + .path_segments()? + .filter(|segment| !segment.is_empty()) + .collect::>(); + let index = segments.iter().rposition(|segment| *segment == "git")?; + (segments.len() == index + 3).then(|| segments[index + 1].to_ascii_lowercase()) +} + +pub(crate) fn validate_workspace_clone_url( + clone_url: &str, + state: &AppState, +) -> Result<(), String> { + let relay_base = crate::relay::relay_api_base_url_with_override(state); + validate_clone_url_against_relay(clone_url, &relay_base) +} + +fn validate_clone_url_against_relay(clone_url: &str, relay_base: &str) -> Result<(), String> { + validate_clone_url(clone_url)?; + let clone = Url::parse(clone_url).map_err(|error| format!("invalid clone URL: {error}"))?; + let relay = Url::parse(relay_base) + .map_err(|error| format!("configured relay URL is invalid: {error}"))?; + if clone.scheme() != relay.scheme() + || clone.host_str() != relay.host_str() + || clone.port_or_known_default() != relay.port_or_known_default() + { + return Err("clone URL must use the active workspace relay".into()); + } + let relay_path = relay.path().trim_end_matches('/'); + if !relay_path.is_empty() && !clone.path().starts_with(&format!("{relay_path}/")) { + return Err("clone URL must use the active workspace relay path".into()); + } + Ok(()) +} + #[cfg(test)] mod tests { - use super::{clean_branch, validate_clone_url}; + use super::{clean_branch, validate_clone_url, validate_clone_url_against_relay}; #[test] fn clean_branch_accepts_plain_and_prefixed_names() { @@ -220,5 +288,26 @@ mod tests { assert!(validate_clone_url("https://relay.example/git/short/repo").is_err()); assert!(validate_clone_url("https://evil.example/has/git/inpath").is_err()); assert!(validate_clone_url(&format!("ssh://relay.example/git/{owner}/repo")).is_err()); + assert!(validate_clone_url(&format!( + "https://relay.example/git/{owner}/repo/unexpected" + )) + .is_err()); + } + + #[test] + fn workspace_clone_url_requires_exact_relay_origin_and_prefix() { + let owner = "a".repeat(64); + let valid = format!("https://relay.example/prefix/git/{owner}/repo"); + assert!(validate_clone_url_against_relay(&valid, "https://relay.example/prefix").is_ok()); + assert!(validate_clone_url_against_relay(&valid, "http://relay.example/prefix").is_err()); + assert!( + validate_clone_url_against_relay(&valid, "https://relay.example:8443/prefix").is_err() + ); + assert!(validate_clone_url_against_relay(&valid, "https://relay.example/other").is_err()); + assert!(validate_clone_url_against_relay( + &format!("https://evil.example/prefix/git/{owner}/repo"), + "https://relay.example/prefix", + ) + .is_err()); } } diff --git a/desktop/src-tauri/src/commands/project_git_workflow.rs b/desktop/src-tauri/src/commands/project_git_workflow.rs new file mode 100644 index 0000000000..8d22f5dd25 --- /dev/null +++ b/desktop/src-tauri/src/commands/project_git_workflow.rs @@ -0,0 +1,290 @@ +//! Local clone and pull-request merge commands for the Projects workflow. + +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, clone_url_owner, run_git, validate_clone_url, + validate_workspace_clone_url, GitAuthConfig, +}; +use super::project_repo_paths::{ + canonical_repos_roots, canonicalize_repos_root, default_repos_root_candidates, + find_local_repo_dir, local_repo_candidates, +}; +use crate::app_state::AppState; +use serde::Serialize; +use tauri::State; + +#[derive(Serialize)] +pub struct ProjectRepoCloneResult { + pub path: String, + pub cloned: bool, + pub message: String, +} + +#[derive(Serialize)] +pub struct ProjectRepoMergeResult { + pub message: String, + pub merge_commit: String, +} + +fn normalize_commit(value: &str) -> Option { + clean_commit(Some(value.trim().to_ascii_lowercase())) +} + +fn same_repository(left: &str, right: &str) -> bool { + left.trim() + .trim_end_matches('/') + .trim_end_matches(".git") + .eq_ignore_ascii_case(right.trim().trim_end_matches('/').trim_end_matches(".git")) +} + +fn clone_destination_root(repos_dir: Option<&str>) -> Result { + match canonical_repos_roots(repos_dir) { + Ok(roots) => roots + .into_iter() + .next() + .ok_or_else(|| "reposDir is not accessible".to_string()), + Err(error) => { + if repos_dir.is_some() { + return Err(error); + } + let root = default_repos_root_candidates() + .into_iter() + .next() + .ok_or(error)?; + std::fs::create_dir_all(&root).map_err(|error| format!("create repos dir: {error}"))?; + canonicalize_repos_root(root) + } + } +} + +pub(crate) fn clone_project_repository_blocking( + repos_dir: Option<&str>, + project_dtag: &str, + clone_url: &str, + default_branch: Option<&str>, + auth: &GitAuthConfig, +) -> Result { + validate_clone_url(clone_url)?; + let branch = normalize_branch_option(default_branch); + if let Some(repo_dir) = find_local_repo_dir(repos_dir, project_dtag, Some(clone_url))? { + return Ok(ProjectRepoCloneResult { + path: repo_dir.display().to_string(), + cloned: false, + message: "Repository is already cloned.".to_string(), + }); + } + + let repos_root = clone_destination_root(repos_dir)?; + let repo_name = local_repo_candidates(project_dtag, Some(clone_url)) + .into_iter() + .next() + .ok_or_else(|| "Could not derive a directory name for the repository.".to_string())?; + let repo_dir = repos_root.join(repo_name); + if repo_dir.exists() { + return Err(format!( + "{} already exists but is not a git checkout.", + repo_dir.display() + )); + } + let repo_path = repo_dir + .to_str() + .ok_or_else(|| "repository path is not UTF-8".to_string())?; + + let mut clone_args = vec!["clone"]; + if let Some(ref branch) = branch { + clone_args.extend(["--branch", branch.as_str()]); + } + clone_args.extend(["--end-of-options", clone_url, repo_path]); + if let Err(error) = run_git(&clone_args, None, auth) { + if branch.is_none() { + return Err(error); + } + run_git( + &["clone", "--end-of-options", clone_url, repo_path], + None, + auth, + )?; + } + + Ok(ProjectRepoCloneResult { + path: repo_dir.display().to_string(), + cloned: true, + message: format!("Cloned repository to {}.", repo_dir.display()), + }) +} + +#[tauri::command] +pub async fn clone_project_repository( + repos_dir: Option, + project_dtag: String, + clone_url: String, + default_branch: Option, + state: State<'_, AppState>, +) -> Result { + validate_workspace_clone_url(&clone_url, &state)?; + let auth = build_git_auth_config(&state)?; + tauri::async_runtime::spawn_blocking(move || { + clone_project_repository_blocking( + repos_dir.as_deref(), + &project_dtag, + &clone_url, + default_branch.as_deref(), + &auth, + ) + }) + .await + .map_err(|error| format!("repo clone task failed: {error}"))? +} + +#[tauri::command] +pub async fn merge_project_pull_request( + target_clone_url: String, + source_clone_url: String, + target_owner: String, + target_branch: String, + source_branch: String, + expected_commit: String, + state: State<'_, AppState>, +) -> Result { + validate_workspace_clone_url(&target_clone_url, &state)?; + 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()); + } + 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()); + } + let merger_pubkey = state + .keys + .lock() + .map_err(|error| error.to_string())? + .public_key() + .to_hex(); + if merger_pubkey != target_owner { + return Err("Only the repository owner can merge pull requests.".to_string()); + } + let target_branch = normalize_branch_option(Some(&target_branch)) + .ok_or_else(|| "Invalid target branch.".to_string())?; + 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()); + } + let expected_commit = normalize_commit(&expected_commit) + .ok_or_else(|| "Invalid pull request commit.".to_string())?; + let auth = build_git_auth_config(&state)?; + + 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", + "--depth=100", + "--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 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(ProjectRepoMergeResult { + message: format!("Merged {source_branch} into {target_branch}."), + merge_commit, + }) + }) + .await + .map_err(|error| format!("pull request merge task failed: {error}"))? +} + +#[cfg(test)] +mod tests { + use super::{normalize_commit, same_repository}; + + #[test] + fn normalize_commit_accepts_sha1_and_sha256_hex() { + assert_eq!(normalize_commit(&"A".repeat(40)), Some("a".repeat(40))); + assert_eq!(normalize_commit(&"B".repeat(64)), Some("b".repeat(64))); + } + + #[test] + fn normalize_commit_rejects_invalid_values() { + assert_eq!(normalize_commit("abc"), None); + assert_eq!(normalize_commit(&"z".repeat(40)), None); + } + + #[test] + fn repository_comparison_normalizes_git_suffix_and_trailing_slash() { + assert!(same_repository( + "https://relay.example/git/owner/repo.git", + "https://relay.example/git/owner/repo/" + )); + assert!(!same_repository( + "https://relay.example/git/owner/repo", + "https://relay.example/git/fork/repo" + )); + } +} diff --git a/desktop/src-tauri/src/commands/project_repo_paths.rs b/desktop/src-tauri/src/commands/project_repo_paths.rs index 69cb2465c3..4193327c01 100644 --- a/desktop/src-tauri/src/commands/project_repo_paths.rs +++ b/desktop/src-tauri/src/commands/project_repo_paths.rs @@ -25,11 +25,89 @@ fn clone_url_repo_name(clone_url: &str) -> Option { local_repo_name_candidate(last_segment) } +fn clone_url_owner_repo_name(clone_url: &str) -> Option { + let parsed = Url::parse(clone_url).ok()?; + let parts = parsed + .path_segments()? + .filter(|part| !part.is_empty()) + .collect::>(); + let [.., owner, repo] = parts.as_slice() else { + return None; + }; + local_repo_name_candidate(&format!( + "{}--{}", + local_repo_name_candidate(owner)?, + local_repo_name_candidate(repo)? + )) +} + +fn normalized_clone_url(value: &str) -> &str { + value.trim().trim_end_matches('/').trim_end_matches(".git") +} + +fn checkout_git_config( + repo_dir: &std::path::Path, + repos_root: &std::path::Path, +) -> Option { + let dot_git = repo_dir.join(".git"); + let git_dir = if dot_git.is_dir() { + dot_git + } else { + let pointer = std::fs::read_to_string(dot_git).ok()?; + let git_dir = std::path::PathBuf::from(pointer.trim().strip_prefix("gitdir:")?.trim()); + if git_dir.is_absolute() { + git_dir + } else { + repo_dir.join(git_dir) + } + }; + let git_dir = git_dir.canonicalize().ok()?; + if !git_dir.starts_with(repos_root) { + return None; + } + let config = git_dir.join("config").canonicalize().ok()?; + config.starts_with(repos_root).then_some(config) +} + +fn checkout_origin_matches( + repo_dir: &std::path::Path, + repos_root: &std::path::Path, + clone_url: &str, +) -> bool { + let Some(config_path) = checkout_git_config(repo_dir, repos_root) else { + return false; + }; + let Ok(config) = std::fs::read_to_string(config_path) else { + return false; + }; + let mut in_origin = false; + for line in config.lines() { + let line = line.trim(); + if line.starts_with('[') { + in_origin = line == r#"[remote "origin"]"#; + continue; + } + if in_origin { + if let Some((key, value)) = line.split_once('=') { + if key.trim() == "url" { + return normalized_clone_url(value) == normalized_clone_url(clone_url); + } + } + } + } + false +} + pub(crate) fn local_repo_candidates(project_dtag: &str, clone_url: Option<&str>) -> Vec { let mut candidates = Vec::new(); - if let Some(candidate) = local_repo_name_candidate(project_dtag) { + if let Some(candidate) = clone_url.and_then(clone_url_owner_repo_name) { candidates.push(candidate); } + if let Some(candidate) = local_repo_name_candidate(project_dtag) { + if !candidates.iter().any(|existing| existing == &candidate) { + candidates.push(candidate); + } + } if let Some(candidate) = clone_url.and_then(clone_url_repo_name) { if !candidates.iter().any(|existing| existing == &candidate) { candidates.push(candidate); @@ -54,7 +132,11 @@ pub(crate) fn find_local_repo_dir( if !candidate_path.starts_with(&repos_root) || !candidate_path.is_dir() { continue; } - if candidate_path.join(".git").exists() { + if candidate_path.join(".git").exists() + && clone_url + .map(|url| checkout_origin_matches(&candidate_path, &repos_root, url)) + .unwrap_or(true) + { return Ok(Some(candidate_path)); } } diff --git a/desktop/src-tauri/src/commands/project_terminal.rs b/desktop/src-tauri/src/commands/project_terminal.rs index ee3967b8ff..24bc138407 100644 --- a/desktop/src-tauri/src/commands/project_terminal.rs +++ b/desktop/src-tauri/src/commands/project_terminal.rs @@ -7,12 +7,9 @@ use tauri::State; use crate::app_state::AppState; -use super::project_git::normalize_branch_option; -use super::project_git_exec::{build_git_auth_config, run_git, validate_clone_url}; -use super::project_repo_paths::{ - canonical_repos_roots, canonicalize_repos_root, default_repos_root_candidates, - find_local_repo_dir, local_repo_candidates, -}; +use super::project_git_exec::{build_git_auth_config, validate_workspace_clone_url}; +use super::project_git_workflow::clone_project_repository_blocking; +use super::project_repo_paths::find_local_repo_dir; /// Result of [`open_project_terminal`]: where the terminal opened and /// whether a fresh clone was made to get there. @@ -68,29 +65,6 @@ fn launch_terminal_at(path: &std::path::Path) -> Result<(), String> { Ok(()) } -/// Resolves the repos root a fresh clone should land in, creating the -/// default root when no explicit `reposDir` is configured and none of the -/// default candidates exist yet. -fn clone_destination_root(repos_dir: Option<&str>) -> Result { - match canonical_repos_roots(repos_dir) { - Ok(roots) => roots - .into_iter() - .next() - .ok_or_else(|| "reposDir is not accessible".to_string()), - Err(error) => { - if repos_dir.is_some() { - return Err(error); - } - let root = default_repos_root_candidates() - .into_iter() - .next() - .ok_or(error)?; - std::fs::create_dir_all(&root).map_err(|error| format!("create repos dir: {error}"))?; - canonicalize_repos_root(root) - } - } -} - /// Opens the OS terminal at the project's local checkout. When there is no /// local checkout yet, clones the repository from `clone_url` (authenticated /// with the identity key, same as push/snapshot) into the repos dir first, @@ -103,11 +77,12 @@ pub async fn open_project_terminal( default_branch: Option, state: State<'_, AppState>, ) -> Result { - // Auth is only needed for the clone path — a missing git binary must not - // block opening a terminal at an existing checkout. + if let Some(clone_url) = clone_url.as_deref() { + validate_workspace_clone_url(clone_url, &state)?; + } + // Auth is only needed for the clone path — keep the result outside the + // blocking task so it owns no borrowed Tauri state. let auth = build_git_auth_config(&state); - let branch = normalize_branch_option(default_branch.as_deref()); - tauri::async_runtime::spawn_blocking(move || { // An inaccessible repos root (fresh machine, nothing cloned yet) is // not fatal here — the clone path below creates the default root. A @@ -129,45 +104,19 @@ pub async fn open_project_terminal( .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| "No local checkout and no clone URL available.".to_string())?; - validate_clone_url(clone_url)?; let auth = auth?; - - let repos_root = clone_destination_root(repos_dir.as_deref())?; - let repo_name = local_repo_candidates(&project_dtag, Some(clone_url)) - .into_iter() - .next() - .ok_or_else(|| "Could not derive a directory name for the repository.".to_string())?; - let repo_dir = repos_root.join(&repo_name); - if repo_dir.exists() { - return Err(format!( - "{} already exists but is not a git checkout.", - repo_dir.display() - )); - } - let repo_path = repo_dir - .to_str() - .ok_or_else(|| "repository path is not UTF-8".to_string())?; - - let mut clone_args = vec!["clone"]; - if let Some(ref branch) = branch { - clone_args.push("--branch"); - clone_args.push(branch.as_str()); - } - clone_args.push(clone_url); - clone_args.push(repo_path); - if let Err(error) = run_git(&clone_args, None, &auth) { - // The requested branch may not exist on the remote — retry with - // the remote's default branch before giving up. - if branch.is_none() { - return Err(error); - } - run_git(&["clone", clone_url, repo_path], None, &auth)?; - } - + let clone_result = clone_project_repository_blocking( + repos_dir.as_deref(), + &project_dtag, + clone_url, + default_branch.as_deref(), + &auth, + )?; + let repo_dir = std::path::PathBuf::from(&clone_result.path); launch_terminal_at(&repo_dir)?; Ok(ProjectTerminalResult { - path: repo_dir.display().to_string(), - cloned: true, + path: clone_result.path, + cloned: clone_result.cloned, }) }) .await diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 99fe6f1983..acab440265 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -762,8 +762,10 @@ pub fn run() { get_project_local_repo_snapshot, get_project_repo_sync_status, list_project_local_repositories, + clone_project_repository, push_project_local_repository, pull_project_local_repository, + merge_project_pull_request, open_project_terminal, search_users, get_presence, diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index bd1598a890..5db9d211e3 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -273,7 +273,11 @@ async function fetchProject(projectId: string): Promise { limit: 10, }); - return isDeletedByA(project, deletionEvents) ? null : project; + if (isDeletedByA(project, deletionEvents)) return null; + const repoState = await fetchRepoState(project); + return repoState?.head + ? { ...project, defaultBranch: repoState.head } + : project; } function eventToRepoState(event: RelayEvent): RepoState { @@ -290,7 +294,7 @@ function eventToRepoState(event: RelayEvent): RepoState { } else if (name.startsWith("refs/tags/")) { tags.push({ name: name.slice("refs/tags/".length), commit: value }); } else if (name === "HEAD") { - head = value.replace(/^ref:\s*/, ""); + head = value.replace(/^ref:\s*/, "").replace(/^refs\/heads\//, ""); } } diff --git a/desktop/src/features/projects/lib/projectsViewHelpers.ts b/desktop/src/features/projects/lib/projectsViewHelpers.ts index 188b7a8781..eed6f53887 100644 --- a/desktop/src/features/projects/lib/projectsViewHelpers.ts +++ b/desktop/src/features/projects/lib/projectsViewHelpers.ts @@ -176,14 +176,14 @@ export function projectPeople( ]; } -function normalizeRepositoryUrl(url: string) { +export function normalizeRepositoryUrl(url: string) { try { const parsed = new URL(url); const normalizedPath = parsed.pathname .replace(/\/+$/, "") .replace(/\.git$/i, "") .toLowerCase(); - return `${parsed.hostname.toLowerCase()}${normalizedPath}`; + return `${parsed.protocol.toLowerCase()}//${parsed.host.toLowerCase()}${normalizedPath}`; } catch { return url .trim() diff --git a/desktop/src/features/projects/pullRequestMutations.test.mjs b/desktop/src/features/projects/pullRequestMutations.test.mjs new file mode 100644 index 0000000000..3954399908 --- /dev/null +++ b/desktop/src/features/projects/pullRequestMutations.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + projectPullRequestMergedTags, + projectPullRequestTags, + projectPullRequestUpdateTags, +} from "./pullRequestMutations.ts"; + +const OWNER = "a".repeat(64); +const AUTHOR = "b".repeat(64); +const REVIEWER = "c".repeat(64); +const PR_ID = "d".repeat(64); +const COMMIT = "e".repeat(40); +const MERGE_BASE = "f".repeat(40); + +const project = { + owner: OWNER, + repoAddress: `30617:${OWNER}:buzz`, + cloneUrls: [`https://relay.example/git/${OWNER}/buzz`], +}; + +test("projectPullRequestTags builds a NIP-34 kind 1618 tag set", () => { + const tags = projectPullRequestTags(project, { + title: "Add Projects workflow", + body: "", + branch: "projects-workflow", + commit: COMMIT, + mergeBase: MERGE_BASE, + reviewers: [REVIEWER, REVIEWER.toUpperCase()], + }); + + assert.deepEqual(tags, [ + ["a", project.repoAddress], + ["p", OWNER], + ["p", REVIEWER], + ["subject", "Add Projects workflow"], + ["c", COMMIT], + ["clone", project.cloneUrls[0]], + ["branch-name", "projects-workflow"], + ["merge-base", MERGE_BASE], + ]); +}); + +test("projectPullRequestUpdateTags uses uppercase NIP-22 root tags", () => { + const forkCloneUrl = `https://relay.example/git/${AUTHOR}/buzz`; + const tags = projectPullRequestUpdateTags( + project, + { id: PR_ID, author: AUTHOR, cloneUrls: [forkCloneUrl] }, + COMMIT, + MERGE_BASE, + ); + + assert.ok(tags.some((tag) => tag[0] === "E" && tag[1] === PR_ID)); + assert.ok(tags.some((tag) => tag[0] === "P" && tag[1] === AUTHOR)); + assert.ok(tags.some((tag) => tag[0] === "c" && tag[1] === COMMIT)); + assert.ok( + tags.some( + (tag) => + tag[0] === "clone" && + tag[1] === forkCloneUrl && + !tag.includes(project.cloneUrls[0]), + ), + ); +}); + +test("projectPullRequestMergedTags records the pushed merge commit", () => { + const tags = projectPullRequestMergedTags( + project, + { id: PR_ID, author: AUTHOR }, + COMMIT, + ); + + assert.deepEqual(tags, [ + ["e", PR_ID, "", "root"], + ["a", project.repoAddress], + ["p", OWNER], + ["p", AUTHOR], + ["merge-commit", COMMIT], + ["r", COMMIT], + ]); +}); diff --git a/desktop/src/features/projects/pullRequestMutations.ts b/desktop/src/features/projects/pullRequestMutations.ts new file mode 100644 index 0000000000..b6b9040bad --- /dev/null +++ b/desktop/src/features/projects/pullRequestMutations.ts @@ -0,0 +1,268 @@ +import { useMutation } from "@tanstack/react-query"; + +import { mergeProjectPullRequest } from "@/shared/api/projectGit"; +import { relayClient } from "@/shared/api/relayClient"; +import { signRelayEvent } from "@/shared/api/tauri"; +import { + KIND_GIT_PR_UPDATE, + KIND_GIT_PULL_REQUEST, + KIND_GIT_STATUS_MERGED, +} from "@/shared/constants/kinds"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import type { Project, ProjectPullRequest } from "./hooks"; +import { nextProjectPullRequestStatusCreatedAt } from "./projectPullRequests.mjs"; +import { useProjectPullRequestWriteInvalidation } from "./pullRequestReviews"; + +type CreateProjectPullRequestInput = { + title: string; + body: string; + branch: string; + commit: string; + mergeBase: string | null; + reviewers: string[]; +}; + +function uniquePubkeys(pubkeys: readonly string[]) { + return [...new Set(pubkeys.map(normalizePubkey))]; +} + +export function projectPullRequestTags( + project: Project, + input: CreateProjectPullRequestInput, +): string[][] { + const tags = [ + ["a", project.repoAddress], + ...uniquePubkeys([project.owner, ...input.reviewers]).map((pubkey) => [ + "p", + pubkey, + ]), + ["subject", input.title], + ["c", input.commit], + ["clone", ...project.cloneUrls], + ["branch-name", input.branch], + ]; + if (input.mergeBase) tags.push(["merge-base", input.mergeBase]); + return tags; +} + +export function projectPullRequestUpdateTags( + project: Project, + pullRequest: ProjectPullRequest, + commit: string, + mergeBase: string | null, +): string[][] { + const tags = [ + ["a", project.repoAddress], + ...uniquePubkeys([project.owner, pullRequest.author]).map((pubkey) => [ + "p", + pubkey, + ]), + ["E", pullRequest.id], + ["P", normalizePubkey(pullRequest.author)], + ["c", commit], + [ + "clone", + ...(pullRequest.cloneUrls.length > 0 + ? pullRequest.cloneUrls + : project.cloneUrls), + ], + ]; + if (mergeBase) tags.push(["merge-base", mergeBase]); + return tags; +} + +export function projectPullRequestMergedTags( + project: Project, + pullRequest: ProjectPullRequest, + mergeCommit: string, +): string[][] { + return [ + ["e", pullRequest.id, "", "root"], + ["a", project.repoAddress], + ...uniquePubkeys([project.owner, pullRequest.author]).map((pubkey) => [ + "p", + pubkey, + ]), + ["merge-commit", mergeCommit], + ["r", mergeCommit], + ]; +} + +async function publishProjectPullRequest( + project: Project, + input: CreateProjectPullRequestInput, +) { + const title = input.title.trim(); + if (!title) throw new Error("Pull request title cannot be empty."); + if (title.length > 256) { + throw new Error("Pull request title must be 256 characters or fewer."); + } + if (project.cloneUrls.length === 0) { + throw new Error("This project has no clone URL."); + } + if (input.branch === project.defaultBranch) { + throw new Error("Choose a branch other than the default branch."); + } + + const event = await signRelayEvent({ + kind: KIND_GIT_PULL_REQUEST, + content: input.body.trim(), + tags: projectPullRequestTags(project, { ...input, title }), + }); + await relayClient.publishEvent( + event, + "Timed out creating pull request.", + "Failed to create pull request.", + ); + return event.id; +} + +export async function publishProjectPullRequestUpdate({ + commit, + mergeBase, + project, + pullRequest, +}: { + commit: string; + mergeBase: string | null; + project: Project; + pullRequest: ProjectPullRequest; +}): Promise { + if (pullRequest.commit?.toLowerCase() === commit.toLowerCase()) return false; + const event = await signRelayEvent({ + kind: KIND_GIT_PR_UPDATE, + content: "", + createdAt: Math.max( + Math.floor(Date.now() / 1_000), + ...pullRequest.updates.map((update) => update.createdAt + 1), + ), + tags: projectPullRequestUpdateTags(project, pullRequest, commit, mergeBase), + }); + await relayClient.publishEvent( + event, + "Timed out updating pull request.", + "The branch was pushed, but the pull request update could not be published.", + ); + return true; +} + +export async function publishProjectPullRequestMerged( + project: Project, + pullRequest: ProjectPullRequest, + mergeCommit: string, +) { + const event = await signRelayEvent({ + kind: KIND_GIT_STATUS_MERGED, + content: "", + createdAt: nextProjectPullRequestStatusCreatedAt( + pullRequest, + Math.floor(Date.now() / 1_000), + ), + tags: projectPullRequestMergedTags(project, pullRequest, mergeCommit), + }); + await relayClient.publishEvent( + event, + "Repository merged, but publishing its pull request status timed out.", + "Repository merged, but its pull request status could not be published.", + ); +} + +export function useCreateProjectPullRequestMutation( + project: Project | null | undefined, +) { + const invalidate = useProjectPullRequestWriteInvalidation(project); + return useMutation({ + mutationFn: (input: CreateProjectPullRequestInput) => { + if (!project) throw new Error("No project selected."); + return publishProjectPullRequest(project, input); + }, + onSuccess: invalidate, + }); +} + +export function useUpdateProjectPullRequestMutation( + project: Project | null | undefined, + pullRequest: ProjectPullRequest | null, +) { + const invalidate = useProjectPullRequestWriteInvalidation(project); + return useMutation({ + mutationFn: async ({ + commit, + mergeBase, + }: { + commit: string; + mergeBase: string | null; + }) => { + if (!project) throw new Error("No project selected."); + if (!pullRequest) + throw new Error("No open pull request for this branch."); + return publishProjectPullRequestUpdate({ + commit, + mergeBase, + project, + pullRequest, + }); + }, + onSuccess: invalidate, + }); +} + +export function useMergeProjectPullRequestMutation( + project: Project | null | undefined, +) { + const invalidate = useProjectPullRequestWriteInvalidation(project); + return useMutation({ + mutationFn: async ({ + pullRequest, + }: { + pullRequest: ProjectPullRequest; + }) => { + if (!project?.cloneUrls[0]) throw new Error("No project selected."); + if (!pullRequest.branchName || !pullRequest.commit) { + throw new Error("Pull request branch information is incomplete."); + } + const result = await mergeProjectPullRequest({ + targetCloneUrl: project.cloneUrls[0], + sourceCloneUrl: pullRequest.cloneUrls[0] ?? project.cloneUrls[0], + targetOwner: project.owner, + targetBranch: project.defaultBranch, + sourceBranch: pullRequest.branchName, + expectedCommit: pullRequest.commit, + }); + let statusPublicationError: string | null = null; + try { + await publishProjectPullRequestMerged( + project, + pullRequest, + result.mergeCommit, + ); + } catch (error) { + statusPublicationError = + error instanceof Error + ? error.message + : "Pull request status could not be published."; + } + return { ...result, statusPublicationError }; + }, + onSuccess: invalidate, + }); +} + +export function usePublishProjectPullRequestMergedMutation( + project: Project | null | undefined, +) { + const invalidate = useProjectPullRequestWriteInvalidation(project); + return useMutation({ + mutationFn: ({ + mergeCommit, + pullRequest, + }: { + mergeCommit: string; + pullRequest: ProjectPullRequest; + }) => { + if (!project) throw new Error("No project selected."); + return publishProjectPullRequestMerged(project, pullRequest, mergeCommit); + }, + onSuccess: invalidate, + }); +} diff --git a/desktop/src/features/projects/pullRequestReviews.ts b/desktop/src/features/projects/pullRequestReviews.ts index e4eec54116..ba11bcd6f0 100644 --- a/desktop/src/features/projects/pullRequestReviews.ts +++ b/desktop/src/features/projects/pullRequestReviews.ts @@ -138,7 +138,9 @@ async function approveProjectPullRequest({ ); } -function usePullRequestWriteInvalidation(project: Project | null | undefined) { +export function useProjectPullRequestWriteInvalidation( + project: Project | null | undefined, +) { const queryClient = useQueryClient(); return React.useCallback(() => { void queryClient.invalidateQueries({ @@ -156,7 +158,7 @@ function usePullRequestWriteInvalidation(project: Project | null | undefined) { export function useUpdateProjectPullRequestStatusMutation( project: Project | null | undefined, ) { - const invalidate = usePullRequestWriteInvalidation(project); + const invalidate = useProjectPullRequestWriteInvalidation(project); return useMutation({ mutationFn: ({ @@ -176,7 +178,7 @@ export function useUpdateProjectPullRequestStatusMutation( export function useRequestProjectPullRequestReviewMutation( project: Project | null | undefined, ) { - const invalidate = usePullRequestWriteInvalidation(project); + const invalidate = useProjectPullRequestWriteInvalidation(project); return useMutation({ mutationFn: ({ @@ -203,7 +205,7 @@ export function useRequestProjectPullRequestReviewMutation( export function useApproveProjectPullRequestMutation( project: Project | null | undefined, ) { - const invalidate = usePullRequestWriteInvalidation(project); + const invalidate = useProjectPullRequestWriteInvalidation(project); return useMutation({ mutationFn: ({ pullRequest }: { pullRequest: ProjectPullRequest }) => { diff --git a/desktop/src/features/projects/repoSyncHooks.ts b/desktop/src/features/projects/repoSyncHooks.ts index 6cfe6d9be5..69613a76c4 100644 --- a/desktop/src/features/projects/repoSyncHooks.ts +++ b/desktop/src/features/projects/repoSyncHooks.ts @@ -1,11 +1,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { + cloneProjectRepository, getProjectRepoSyncStatus, pullProjectLocalRepository, pushProjectLocalRepository, } from "@/shared/api/projectGit"; -import type { Project } from "@/features/projects/hooks"; +import { getIdentity } from "@/shared/api/tauriIdentity"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import type { Project, ProjectPullRequest } from "@/features/projects/hooks"; +import { publishProjectPullRequestUpdate } from "./pullRequestMutations"; /** Local-vs-remote git sync status for a project checkout (ahead/behind * counts, push/pull availability). Polls gently — each check runs a @@ -33,7 +37,8 @@ export function useProjectRepoSyncStatusQuery( reposDir, projectDtag: project.dtag, cloneUrl: project.cloneUrls[0], - defaultBranch: selectedBranch, + branchName: selectedBranch, + baseBranch: project.defaultBranch, }); }, staleTime: 10_000, @@ -48,19 +53,59 @@ export function usePushProjectLocalRepositoryMutation( project: Project | null | undefined, reposDir?: string | null, branchName?: string | null, + pullRequest?: ProjectPullRequest | null, ) { const queryClient = useQueryClient(); const selectedBranch = branchName ?? project?.defaultBranch ?? null; return useMutation({ - mutationFn: () => { + mutationFn: async () => { if (!project?.cloneUrls[0]) throw new Error("No project selected."); - return pushProjectLocalRepository({ + const result = await pushProjectLocalRepository({ reposDir, projectDtag: project.dtag, cloneUrl: project.cloneUrls[0], - defaultBranch: selectedBranch, + branchName: selectedBranch, + baseBranch: project.defaultBranch, }); + let pullRequestUpdate: + | { status: "skipped" | "unchanged" | "updated" } + | { status: "failed"; error: string } = { status: "skipped" }; + if ( + pullRequest && + (pullRequest.status === "Open" || pullRequest.status === "Draft") + ) { + try { + const identity = await getIdentity(); + const viewer = normalizePubkey(identity.pubkey); + if ( + viewer !== normalizePubkey(project.owner) && + viewer !== normalizePubkey(pullRequest.author) + ) { + throw new Error( + "Only the pull request author or repository owner can publish its update.", + ); + } + const updated = await publishProjectPullRequestUpdate({ + commit: result.commit, + mergeBase: result.mergeBase, + project, + pullRequest, + }); + pullRequestUpdate = { + status: updated ? "updated" : "unchanged", + }; + } catch (error) { + pullRequestUpdate = { + status: "failed", + error: + error instanceof Error + ? error.message + : "The pull request update could not be published.", + }; + } + } + return { ...result, pullRequestUpdate }; }, onSuccess: () => { void queryClient.invalidateQueries({ @@ -71,6 +116,38 @@ export function usePushProjectLocalRepositoryMutation( }); } +/** Clones a project into the workspace repositories directory. */ +export function useCloneProjectRepositoryMutation( + project: Project | null | undefined, + reposDir?: string | null, +) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => { + if (!project?.cloneUrls[0]) throw new Error("No project selected."); + return cloneProjectRepository({ + reposDir, + projectDtag: project.dtag, + cloneUrl: project.cloneUrls[0], + defaultBranch: project.defaultBranch, + }); + }, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["project", project?.id ?? "none", "local-repo-snapshot"], + }), + queryClient.invalidateQueries({ + queryKey: ["project", project?.id ?? "none", "repo-sync-status"], + }), + queryClient.invalidateQueries({ + queryKey: ["projects", "local-repositories"], + }), + ]); + }, + }); +} + /** Fast-forwards the local checkout to the remote branch head. */ export function usePullProjectLocalRepositoryMutation( project: Project | null | undefined, @@ -87,7 +164,7 @@ export function usePullProjectLocalRepositoryMutation( reposDir, projectDtag: project.dtag, cloneUrl: project.cloneUrls[0], - defaultBranch: selectedBranch, + branchName: selectedBranch, }); }, onSuccess: () => { diff --git a/desktop/src/features/projects/ui/CreatePullRequestDialog.tsx b/desktop/src/features/projects/ui/CreatePullRequestDialog.tsx new file mode 100644 index 0000000000..a66790ce4a --- /dev/null +++ b/desktop/src/features/projects/ui/CreatePullRequestDialog.tsx @@ -0,0 +1,169 @@ +import * as React from "react"; + +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; +import { Dialog } from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; +import { Textarea } from "@/shared/ui/textarea"; + +const FIELD_SHELL_CLASS = + "rounded-xl border border-input bg-muted/40 transition-colors hover:border-muted-foreground/40 focus-within:border-muted-foreground/50"; +const FIELD_CONTROL_CLASS = + "border-0 bg-transparent shadow-none outline-none ring-0 placeholder:text-muted-foreground/55 focus-visible:ring-0"; + +export type CreatePullRequestDialogInput = { + title: string; + body: string; +}; + +export function CreatePullRequestDialog({ + commit, + isCreating, + onCreate, + onOpenChange, + open, + sourceBranch, + targetBranch, +}: { + commit: string; + isCreating: boolean; + onCreate: (input: CreatePullRequestDialogInput) => Promise; + onOpenChange: (open: boolean) => void; + open: boolean; + sourceBranch: string; + targetBranch: string; +}) { + const [title, setTitle] = React.useState(""); + const [body, setBody] = React.useState(""); + const [errorMessage, setErrorMessage] = React.useState(null); + const titleInputRef = React.useRef(null); + + React.useEffect(() => { + if (!open) return; + setTitle(""); + setBody(""); + setErrorMessage(null); + const timerId = globalThis.setTimeout( + () => titleInputRef.current?.focus(), + 50, + ); + return () => globalThis.clearTimeout(timerId); + }, [open]); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + const trimmedTitle = title.trim(); + if (!trimmedTitle) return; + setErrorMessage(null); + try { + await onCreate({ title: trimmedTitle, body: body.trim() }); + onOpenChange(false); + } catch (error) { + setErrorMessage( + error instanceof Error + ? error.message + : "Failed to create pull request.", + ); + } + } + + return ( + { + if (!nextOpen && isCreating) return; + onOpenChange(nextOpen); + }} + open={open} + > + + + + } + footerClassName="border-t-0 pt-0" + headerClassName="pb-2" + title="Open a pull request" + > +
void handleSubmit(event)} + > +
+ +
+ { + setTitle(event.target.value); + setErrorMessage(null); + }} + placeholder="Describe the change" + ref={titleInputRef} + value={title} + /> +
+
+
+ +
+