diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 091ecce1fa..8d2dc79a3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -241,8 +241,8 @@ jobs: with: path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} - - name: Desktop build - run: just desktop-build + - name: Desktop E2E build + run: pnpm -C desktop build:e2e - name: Desktop smoke e2e run: cd desktop && pnpm exec playwright test --project=smoke --shard=${{ matrix.shard }}/4 - name: Summarize flaky tests @@ -393,8 +393,8 @@ jobs: with: path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} - - name: Desktop build - run: just desktop-build + - name: Desktop E2E build + run: pnpm -C desktop build:e2e - name: Wait for integration services run: | wait_healthy() { diff --git a/Justfile b/Justfile index b8bf80559d..2f5ea0e89f 100644 --- a/Justfile +++ b/Justfile @@ -248,7 +248,7 @@ desktop-e2e-integration: _ensure-migrations # Run only the e2e specs changed vs origin/main (both projects) before pushing desktop-e2e-pre-push: _ensure-migrations git fetch origin main - cd {{desktop_dir}} && pnpm build && pnpm exec playwright test --only-changed=origin/main + cd {{desktop_dir}} && pnpm build:e2e && pnpm exec playwright test --only-changed=origin/main # Run all checks suitable for CI / pre-push (no infra needed) ci: check test-unit desktop-test desktop-build desktop-tauri-check desktop-tauri-test web-build mobile-test @@ -340,7 +340,7 @@ mesh-e2e-confidence: desktop-screenshot *ARGS: #!/usr/bin/env bash set -euo pipefail - just desktop-build + pnpm -C {{desktop_dir}} build:e2e cd {{desktop_dir}} if ! curl -sf http://127.0.0.1:4173/ >/dev/null 2>&1; then python3 -m http.server 4173 -d dist >/dev/null 2>&1 & diff --git a/desktop/package.json b/desktop/package.json index e0b8017d27..60d01d0a67 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", + "build:e2e": "tsc && vite build --mode e2e", "typecheck": "tsc --noEmit", "check:file-sizes": "node ./scripts/check-file-sizes.mjs", "check:px-text": "node ./scripts/check-px-text.mjs", @@ -16,9 +17,9 @@ "test": "node --import ./test-loader.mjs --experimental-strip-types --test 'src/**/*.test.mjs'", "preview": "vite preview", "tauri": "tauri", - "test:e2e": "pnpm build && playwright test", - "test:e2e:smoke": "pnpm build && playwright test --project=smoke", - "test:e2e:integration": "pnpm build && playwright test --project=integration", + "test:e2e": "pnpm build:e2e && playwright test", + "test:e2e:smoke": "pnpm build:e2e && playwright test --project=smoke", + "test:e2e:integration": "pnpm build:e2e && playwright test --project=integration", "test:e2e:report": "playwright show-report", "tauri:build": "tauri build" }, diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index eaa608c339..3de7c68454 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -37,6 +37,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; @@ -45,6 +46,7 @@ mod social; mod team_snapshot; mod teams; mod updater; +mod window_chrome; mod window_vibrancy; mod workflows; mod workspace; @@ -84,6 +86,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::*; @@ -91,6 +94,7 @@ pub use social::*; pub use team_snapshot::*; pub use teams::*; pub use updater::*; +pub use window_chrome::*; pub use window_vibrancy::*; pub use workflows::*; pub use workspace::*; 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..6f3aa226bc 100644 --- a/desktop/src-tauri/src/commands/project_git_exec.rs +++ b/desktop/src-tauri/src/commands/project_git_exec.rs @@ -5,7 +5,7 @@ //! variables so nothing key-related ever touches disk or global git config. use crate::{app_state::AppState, managed_agents::resolve_command}; -use nostr::ToBech32; +use nostr::{Keys, ToBech32}; use std::io::Read; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; @@ -14,7 +14,35 @@ use url::Url; /// Wall-clock cap for a single git invocation. Remote operations talk to /// relay-supplied clone URLs, so a slow or adversarial remote must not pin /// `spawn_blocking` threads indefinitely. -const GIT_TIMEOUT: Duration = Duration::from_secs(60); +const LOCAL_GIT_TIMEOUT: Duration = Duration::from_secs(60); +const REMOTE_GIT_TIMEOUT: Duration = Duration::from_secs(300); + +fn git_subcommand<'a>(args: &'a [&str]) -> Option<&'a str> { + let mut index = 0; + while let Some(argument) = args.get(index).copied() { + match argument { + "-c" | "--config" | "-C" | "--git-dir" | "--work-tree" => index += 2, + "--no-pager" | "--paginate" | "--end-of-options" => index += 1, + argument + if argument.starts_with("--config=") + || argument.starts_with("--git-dir=") + || argument.starts_with("--work-tree=") => + { + index += 1; + } + argument if argument.starts_with('-') => index += 1, + subcommand => return Some(subcommand), + } + } + None +} + +fn git_needs_credentials(args: &[&str]) -> bool { + matches!( + git_subcommand(args), + Some("clone" | "fetch" | "push" | "pull" | "ls-remote" | "merge") + ) +} pub(crate) struct GitAuthConfig { git_path: std::path::PathBuf, @@ -41,7 +69,13 @@ pub(crate) fn run_git( if let Some(cwd) = cwd { command.current_dir(cwd); } - configure_git_auth(&mut command, auth); + let needs_credentials = git_needs_credentials(args); + let timeout = if needs_credentials { + REMOTE_GIT_TIMEOUT + } else { + LOCAL_GIT_TIMEOUT + }; + configure_git_auth(&mut command, auth, needs_credentials); command.stdin(Stdio::null()); command.stdout(Stdio::piped()); command.stderr(Stdio::piped()); @@ -62,12 +96,12 @@ pub(crate) fn run_git( match child.try_wait() { Ok(Some(status)) => break status, Ok(None) => { - if started.elapsed() > GIT_TIMEOUT { + if started.elapsed() > timeout { let _ = child.kill(); let _ = child.wait(); let _ = stdout_thread.join(); let _ = stderr_thread.join(); - return Err(format!("git timed out after {}s", GIT_TIMEOUT.as_secs())); + return Err(format!("git timed out after {}s", timeout.as_secs())); } std::thread::sleep(Duration::from_millis(50)); } @@ -92,9 +126,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 +151,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); @@ -120,14 +178,17 @@ fn configure_git_auth(command: &mut Command, auth: &GitAuthConfig) { } pub(crate) fn build_git_auth_config(state: &AppState) -> Result { + let keys = state.signing_keys()?; + build_git_auth_config_for_keys(&keys) +} + +pub(crate) fn build_git_auth_config_for_keys(keys: &Keys) -> Result { let git_path = resolve_command("git").ok_or_else(|| "git was not found on PATH".to_string())?; let credential_helper = resolve_command("git-credential-nostr"); - let nsec = { - let keys = state.keys.lock().map_err(|error| error.to_string())?; - keys.secret_key() - .to_bech32() - .map_err(|error| format!("encode identity key: {error}"))? - }; + let nsec = keys + .secret_key() + .to_bech32() + .map_err(|error| format!("encode identity key: {error}"))?; Ok(GitAuthConfig { git_path, credential_helper, @@ -171,21 +232,95 @@ 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, git_needs_credentials, git_subcommand, validate_clone_url, + validate_clone_url_against_relay, + }; + + #[test] + fn git_subcommand_skips_global_config_options() { + assert_eq!( + git_subcommand(&[ + "-c", + "user.name=Buzz User", + "-c", + "user.email=user@example.com", + "merge", + "HEAD", + ]), + Some("merge") + ); + assert_eq!( + git_subcommand(&["--config=credential.useHttpPath=true", "fetch", "origin"]), + Some("fetch") + ); + } + + #[test] + fn remote_and_promisor_operations_receive_credentials() { + assert!(git_needs_credentials(&["fetch", "origin"])); + assert!(git_needs_credentials(&[ + "-c", + "user.name=Buzz User", + "merge", + "HEAD" + ])); + assert!(!git_needs_credentials(&["rev-parse", "HEAD"])); + } #[test] fn clean_branch_accepts_plain_and_prefixed_names() { @@ -220,5 +355,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..8763946e4c --- /dev/null +++ b/desktop/src-tauri/src/commands/project_git_workflow.rs @@ -0,0 +1,696 @@ +//! 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, build_git_auth_config_for_keys, 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 crate::managed_agents::{load_managed_agents, spawn_key_refusal}; +use crate::relay::submit_signed_event_with_keys; +use nostr::{Event, EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, 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, + pub status_event: String, + pub status_publication_error: Option, +} + +struct ProjectRepoMergeGitResult { + message: String, + merge_commit: String, +} + +/// Validated repository and pull-request metadata for a native merge. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectPullRequestMergeInput { + target_clone_url: String, + source_clone_url: String, + target_owner: String, + repo_address: String, + pull_request_id: String, + pull_request_author: String, + status_created_at: u64, + target_branch: String, + source_branch: String, + expected_commit: String, +} + +/// Repository-scoped metadata for an agent-signed review request. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectPullRequestReviewRequestInput { + target_owner: String, + repo_address: String, + pull_request_id: String, + reviewers: Vec, + reviewer_label: String, +} + +/// A previously signed merged-status event that needs publishing again. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectPullRequestMergedStatusInput { + target_owner: String, + status_event: String, +} + +fn normalize_commit(value: &str) -> Option { + clean_commit(Some(value.trim().to_ascii_lowercase())) +} + +fn normalize_event_id(value: &str) -> Option { + let value = value.trim().to_ascii_lowercase(); + (value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit())).then_some(value) +} + +struct ProjectOwnerIdentity { + keys: Keys, + auth_tag: Option, +} + +fn project_owner_identity( + app: &AppHandle, + state: &AppState, + target_owner: &str, +) -> Result { + let viewer_keys = state.signing_keys()?; + if viewer_keys.public_key().to_hex() == target_owner { + return Ok(ProjectOwnerIdentity { + keys: viewer_keys, + auth_tag: None, + }); + } + + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let records = load_managed_agents(app)?; + let record = records + .iter() + .find(|record| record.pubkey.eq_ignore_ascii_case(target_owner)) + .ok_or_else(|| { + "Only the repository owner or the owner of its managed agent can merge pull requests." + .to_string() + })?; + if let Some(error) = spawn_key_refusal(record) { + return Err(error); + } + let keys = Keys::parse(&record.private_key_nsec) + .map_err(|error| format!("managed agent signing key is invalid: {error}"))?; + if keys.public_key().to_hex() != target_owner { + return Err("Managed agent key does not match the repository owner.".to_string()); + } + Ok(ProjectOwnerIdentity { + keys, + auth_tag: record.auth_tag.clone(), + }) +} + +fn validate_repo_address(repo_address: &str, owner: &str) -> Result<(), String> { + let prefix = format!("30617:{owner}:"); + if repo_address.strip_prefix(&prefix).is_none_or(str::is_empty) { + return Err("Repository address does not match the repository owner.".to_string()); + } + Ok(()) +} + +fn validate_merge_status_metadata( + repo_address: &str, + owner: &str, + pull_request_id: &str, + pull_request_author: &str, +) -> Result<(String, String), String> { + validate_repo_address(repo_address, owner)?; + let pull_request_id = normalize_event_id(pull_request_id) + .ok_or_else(|| "Invalid pull request event ID.".to_string())?; + let pull_request_author = normalize_event_id(pull_request_author) + .ok_or_else(|| "Invalid pull request author.".to_string())?; + Ok((pull_request_id, pull_request_author)) +} + +fn build_merged_status_event( + keys: &Keys, + repo_address: &str, + pull_request_id: &str, + pull_request_author: &str, + merge_commit: &str, + created_at: u64, +) -> Result { + let owner = keys.public_key().to_hex(); + let (pull_request_id, pull_request_author) = + validate_merge_status_metadata(repo_address, &owner, pull_request_id, pull_request_author)?; + let merge_commit = + normalize_commit(merge_commit).ok_or_else(|| "Invalid merge commit.".to_string())?; + let created_at = created_at.max(Timestamp::now().as_secs()); + + let mut raw_tags = vec![ + vec!["e", pull_request_id.as_str(), "", "root"], + vec!["a", repo_address], + vec!["p", owner.as_str()], + ]; + if pull_request_author != owner { + raw_tags.push(vec!["p", pull_request_author.as_str()]); + } + raw_tags.extend([ + vec!["merge-commit", merge_commit.as_str()], + vec!["r", merge_commit.as_str()], + ]); + let tags = raw_tags + .into_iter() + .map(Tag::parse) + .collect::, _>>() + .map_err(|error| format!("build merged status tags: {error}"))?; + EventBuilder::new(Kind::Custom(1631), "") + .tags(tags) + .custom_created_at(Timestamp::from(created_at)) + .sign_with_keys(keys) + .map(|event| event.as_json()) + .map_err(|error| format!("sign merged pull request status: {error}")) +} + +fn build_review_request_event( + keys: &Keys, + repo_address: &str, + pull_request_id: &str, + reviewers: &[String], + reviewer_label: &str, +) -> Result { + let owner = keys.public_key().to_hex(); + validate_repo_address(repo_address, &owner)?; + let pull_request_id = normalize_event_id(pull_request_id) + .ok_or_else(|| "Invalid pull request event ID.".to_string())?; + if reviewers.is_empty() || reviewers.len() > 50 { + return Err("Select between 1 and 50 reviewers.".to_string()); + } + let mut reviewers = reviewers + .iter() + .map(|reviewer| { + normalize_event_id(reviewer).ok_or_else(|| "Invalid reviewer pubkey.".to_string()) + }) + .collect::, _>>()?; + reviewers.sort(); + reviewers.dedup(); + let reviewer_label = reviewer_label.trim(); + if reviewer_label.is_empty() || reviewer_label.chars().count() > 128 { + return Err("Reviewer label must be between 1 and 128 characters.".to_string()); + } + + let mut raw_tags = vec![ + vec![ + "e".to_string(), + pull_request_id, + String::new(), + "root".to_string(), + ], + vec!["a".to_string(), repo_address.to_string()], + ]; + raw_tags.extend( + reviewers + .into_iter() + .map(|reviewer| vec!["p".to_string(), reviewer]), + ); + raw_tags.push(vec!["t".to_string(), "review-request".to_string()]); + let tags = raw_tags + .into_iter() + .map(Tag::parse) + .collect::, _>>() + .map_err(|error| format!("build review request tags: {error}"))?; + EventBuilder::new( + Kind::TextNote, + format!("Requested a review from {reviewer_label}"), + ) + .tags(tags) + .sign_with_keys(keys) + .map(|event| event.as_json()) + .map_err(|error| format!("sign pull request review request: {error}")) +} + +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 sign_project_pull_request_review_request( + input: ProjectPullRequestReviewRequestInput, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let target_owner = input.target_owner.trim().to_ascii_lowercase(); + if normalize_event_id(&target_owner).is_none() { + return Err("Invalid target repository owner.".to_string()); + } + let identity = project_owner_identity(&app, &state, &target_owner)?; + let event = Event::from_json(build_review_request_event( + &identity.keys, + &input.repo_address, + &input.pull_request_id, + &input.reviewers, + &input.reviewer_label, + )?) + .map_err(|error| format!("parse signed review request: {error}"))?; + submit_signed_event_with_keys(&event, &state, &identity.keys, identity.auth_tag.as_deref()) + .await?; + Ok(()) +} + +#[tauri::command] +pub async fn publish_project_pull_request_merged_status( + input: ProjectPullRequestMergedStatusInput, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let target_owner = input.target_owner.trim().to_ascii_lowercase(); + if normalize_event_id(&target_owner).is_none() { + return Err("Invalid target repository owner.".to_string()); + } + let event = Event::from_json(input.status_event) + .map_err(|error| format!("parse merged status event: {error}"))?; + if event.kind != Kind::Custom(1631) + || event.pubkey.to_hex() != target_owner + || event.verify().is_err() + { + return Err("Invalid merged pull request status event.".to_string()); + } + let identity = project_owner_identity(&app, &state, &target_owner)?; + submit_signed_event_with_keys(&event, &state, &identity.keys, identity.auth_tag.as_deref()) + .await?; + Ok(()) +} + +#[tauri::command] +pub async fn merge_project_pull_request( + input: ProjectPullRequestMergeInput, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let ProjectPullRequestMergeInput { + target_clone_url, + source_clone_url, + target_owner, + repo_address, + pull_request_id, + pull_request_author, + status_created_at, + target_branch, + source_branch, + expected_commit, + } = input; + 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 owner_identity = project_owner_identity(&app, &state, &target_owner)?; + let merger_pubkey = owner_identity.keys.public_key().to_hex(); + 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 (pull_request_id, pull_request_author) = validate_merge_status_metadata( + &repo_address, + &merger_pubkey, + &pull_request_id, + &pull_request_author, + )?; + 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 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, + }) + }) + .await + .map_err(|error| format!("pull request merge task failed: {error}"))??; + let status_event = build_merged_status_event( + &owner_identity.keys, + &repo_address, + &pull_request_id, + &pull_request_author, + &git_result.merge_commit, + status_created_at, + )?; + let signed_status = Event::from_json(&status_event) + .map_err(|error| format!("parse signed merged status: {error}"))?; + let status_publication_error = submit_signed_event_with_keys( + &signed_status, + &state, + &owner_identity.keys, + owner_identity.auth_tag.as_deref(), + ) + .await + .err(); + Ok(ProjectRepoMergeResult { + message: git_result.message, + merge_commit: git_result.merge_commit, + status_event, + status_publication_error, + }) +} + +#[cfg(test)] +mod tests { + use super::{ + build_merged_status_event, build_review_request_event, normalize_commit, same_repository, + validate_merge_status_metadata, + }; + use nostr::{Event, JsonUtil, Keys, Timestamp}; + + #[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" + )); + } + + #[test] + fn merged_status_is_signed_by_repository_owner() { + let keys = Keys::generate(); + let owner = keys.public_key().to_hex(); + let pull_request_id = "d".repeat(64); + let pull_request_author = "b".repeat(64); + let merge_commit = "e".repeat(40); + let repo_address = format!("30617:{owner}:buzz"); + let before = Timestamp::now().as_secs(); + let event = Event::from_json( + build_merged_status_event( + &keys, + &repo_address, + &pull_request_id, + &pull_request_author, + &merge_commit, + 123, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(event.pubkey, keys.public_key()); + assert_eq!(event.kind.as_u16(), 1631); + assert!(event.created_at.as_secs() >= before); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["merge-commit", merge_commit.as_str()])); + assert!(event.verify().is_ok()); + } + + #[test] + fn merged_status_preserves_a_newer_requested_timestamp() { + let keys = Keys::generate(); + let owner = keys.public_key().to_hex(); + let requested = Timestamp::now().as_secs() + 10; + let event = Event::from_json( + build_merged_status_event( + &keys, + &format!("30617:{owner}:buzz"), + &"d".repeat(64), + &"b".repeat(64), + &"e".repeat(40), + requested, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(event.created_at.as_secs(), requested); + } + + #[test] + fn merge_status_metadata_is_rejected_before_git_work() { + let owner = "a".repeat(64); + assert!(validate_merge_status_metadata( + &format!("30617:{}:buzz", "b".repeat(64)), + &owner, + &"d".repeat(64), + &"e".repeat(64), + ) + .is_err()); + assert!(validate_merge_status_metadata( + &format!("30617:{owner}:buzz"), + &owner, + "not-an-event-id", + &"e".repeat(64), + ) + .is_err()); + assert!(validate_merge_status_metadata( + &format!("30617:{owner}:buzz"), + &owner, + &"d".repeat(64), + "not-an-author", + ) + .is_err()); + } + + #[test] + fn review_request_is_signed_by_repository_owner() { + let keys = Keys::generate(); + let owner = keys.public_key().to_hex(); + let reviewer = "b".repeat(64); + let repo_address = format!("30617:{owner}:buzz"); + let event = Event::from_json( + build_review_request_event( + &keys, + &repo_address, + &"d".repeat(64), + std::slice::from_ref(&reviewer), + "Bob", + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(event.pubkey, keys.public_key()); + assert_eq!(event.kind, nostr::Kind::TextNote); + assert_eq!(event.content, "Requested a review from Bob"); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["p", reviewer.as_str()])); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["t", "review-request"])); + assert!(event.verify().is_ok()); + } +} 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/commands/window_chrome.rs b/desktop/src-tauri/src/commands/window_chrome.rs new file mode 100644 index 0000000000..5dba9dd72e --- /dev/null +++ b/desktop/src-tauri/src/commands/window_chrome.rs @@ -0,0 +1,101 @@ +/// Performs the platform's default sidebar alignment haptic when available. +#[tauri::command] +pub fn perform_sidebar_default_haptic() { + #[cfg(target_os = "macos")] + { + use objc2_app_kit::{ + NSHapticFeedbackManager, NSHapticFeedbackPattern, NSHapticFeedbackPerformanceTime, + NSHapticFeedbackPerformer, + }; + + NSHapticFeedbackManager::defaultPerformer().performFeedbackPattern_performanceTime( + NSHapticFeedbackPattern::Alignment, + NSHapticFeedbackPerformanceTime::Now, + ); + } +} + +/// Performs the window action matching the macOS "double-click a window's +/// title bar to" preference (`AppleActionOnDoubleClick`). +/// +/// macOS values are `Minimize`, `Maximize` (default when unset), `Fill`, or +/// `None`. +/// The desktop app uses a web-based title-bar drag region, so the frontend +/// forwards double-clicks here and suppresses Tauri's injected drag-region +/// handler, whose default macOS path hardcodes maximize. +/// +/// For `Fill`, resize to the current monitor work area instead of using +/// Tauri's maximize path, which maps to macOS zoom for titled, resizable +/// windows. +/// +/// On non-macOS platforms this always toggles maximize (the historical +/// behavior). +#[tauri::command] +pub fn title_bar_double_click(window: tauri::Window) { + #[cfg(target_os = "macos")] + { + let action = { + let output = std::process::Command::new("defaults") + .args(["read", "-g", "AppleActionOnDoubleClick"]) + .output(); + match output { + Ok(output) if output.status.success() => { + String::from_utf8_lossy(&output.stdout).trim().to_string() + } + _ => "Maximize".to_string(), + } + }; + + match action.as_str() { + "None" => {} + "Minimize" => { + let _ = window.minimize(); + } + "Fill" => { + fill_window(&window); + } + // "Maximize" or any unexpected value. + _ => { + toggle_maximize(&window); + } + } + } + + #[cfg(not(target_os = "macos"))] + { + toggle_maximize(&window); + } +} + +/// Fills the current display work area, excluding system UI like the menu bar +/// and Dock. +#[cfg(target_os = "macos")] +fn fill_window(window: &tauri::Window) { + match window.current_monitor() { + Ok(Some(monitor)) => { + if window.is_maximized().unwrap_or(false) { + let _ = window.unmaximize(); + } + + let work_area = monitor.work_area(); + let _ = window.set_position(work_area.position); + let _ = window.set_size(work_area.size); + } + _ => { + let _ = window.maximize(); + } + } +} + +/// Toggles the window between maximized and its previous size, matching the +/// historical double-click behavior. +fn toggle_maximize(window: &tauri::Window) { + match window.is_maximized() { + Ok(true) => { + let _ = window.unmaximize(); + } + _ => { + let _ = window.maximize(); + } + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index e2dcac24ff..7ff0965960 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -63,106 +63,6 @@ use tauri_plugin_window_state::StateFlags; #[cfg(target_os = "macos")] const INITIAL_RENDER_READY_EVENT: &str = "initial-render-ready"; -#[tauri::command] -fn perform_sidebar_default_haptic() { - #[cfg(target_os = "macos")] - { - use objc2_app_kit::{ - NSHapticFeedbackManager, NSHapticFeedbackPattern, NSHapticFeedbackPerformanceTime, - NSHapticFeedbackPerformer, - }; - - NSHapticFeedbackManager::defaultPerformer().performFeedbackPattern_performanceTime( - NSHapticFeedbackPattern::Alignment, - NSHapticFeedbackPerformanceTime::Now, - ); - } -} - -/// Performs the window action matching the macOS "double-click a window's -/// title bar to" preference (`AppleActionOnDoubleClick`). -/// -/// macOS values are `Minimize`, `Maximize` (default when unset), `Fill`, or -/// `None`. -/// The desktop app uses a web-based title-bar drag region, so the frontend -/// forwards double-clicks here and suppresses Tauri's injected drag-region -/// handler, whose default macOS path hardcodes maximize. -/// -/// For `Fill`, resize to the current monitor work area instead of using -/// Tauri's maximize path, which maps to macOS zoom for titled, resizable -/// windows. -/// -/// On non-macOS platforms this always toggles maximize (the historical -/// behavior). -#[tauri::command] -fn title_bar_double_click(window: tauri::Window) { - #[cfg(target_os = "macos")] - { - let action = { - let output = std::process::Command::new("defaults") - .args(["read", "-g", "AppleActionOnDoubleClick"]) - .output(); - match output { - Ok(output) if output.status.success() => { - String::from_utf8_lossy(&output.stdout).trim().to_string() - } - _ => "Maximize".to_string(), - } - }; - - match action.as_str() { - "None" => {} - "Minimize" => { - let _ = window.minimize(); - } - "Fill" => { - fill_window(&window); - } - // "Maximize" or any unexpected value. - _ => { - toggle_maximize(&window); - } - } - } - - #[cfg(not(target_os = "macos"))] - { - toggle_maximize(&window); - } -} - -/// Fills the current display work area, excluding system UI like the menu bar -/// and Dock. -#[cfg(target_os = "macos")] -fn fill_window(window: &tauri::Window) { - match window.current_monitor() { - Ok(Some(monitor)) => { - if window.is_maximized().unwrap_or(false) { - let _ = window.unmaximize(); - } - - let work_area = monitor.work_area(); - let _ = window.set_position(work_area.position); - let _ = window.set_size(work_area.size); - } - _ => { - let _ = window.maximize(); - } - } -} - -/// Toggles the window between maximized and its previous size, matching the -/// historical double-click behavior. -fn toggle_maximize(window: &tauri::Window) { - match window.is_maximized() { - Ok(true) => { - let _ = window.unmaximize(); - } - _ => { - let _ = window.maximize(); - } - } -} fn reveal_initial_window(window: &tauri::Window) { if let Err(error) = window.show() { @@ -772,8 +672,12 @@ 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, + sign_project_pull_request_review_request, + publish_project_pull_request_merged_status, + merge_project_pull_request, open_project_terminal, search_users, get_presence, diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 646c19913a..0c253ed0ae 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -570,10 +570,23 @@ pub async fn submit_event_with_keys( keys: &Keys, auth_tag: Option<&str>, ) -> Result { - let url = format!("{}/events", relay_api_base_url_with_override(state)); let event = builder .sign_with_keys(keys) .map_err(|e| format!("failed to sign event: {e}"))?; + submit_signed_event_with_keys(&event, state, keys, auth_tag).await +} + +/// POST an already-signed event using the same explicit identity for NIP-98. +pub async fn submit_signed_event_with_keys( + event: &nostr::Event, + state: &AppState, + keys: &Keys, + auth_tag: Option<&str>, +) -> Result { + if event.pubkey != keys.public_key() { + return Err("signed event does not match the publishing identity".to_string()); + } + let url = format!("{}/events", relay_api_base_url_with_override(state)); let body_bytes = event.as_json().into_bytes(); let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 5b65584e8c..39c13527a7 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -14,6 +14,7 @@ import { useChannelBrowserDialog } from "@/app/useChannelBrowserDialog"; import { useMarkAsReadShortcuts } from "@/app/useMarkAsReadShortcuts"; import { useSettingsShortcuts } from "@/app/useSettingsShortcuts"; import { useAppShellDesktopNotifications } from "@/app/useAppShellDesktopNotifications"; +import { useAppShellLifecycleEffects } from "@/app/useAppShellLifecycleEffects"; import { useThreadActivityFeedItems } from "@/app/useThreadActivityFeedItems"; import { useTauriWindowDrag } from "@/app/useTauriWindowDrag"; import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts"; @@ -33,7 +34,6 @@ import { useHomeFeedNotifications, useHomeFeedNotificationState, } from "@/features/notifications/hooks"; -import { setDesktopAppBadge } from "@/features/notifications/lib/desktop"; import { PreventSleepProvider } from "@/features/agents/usePreventSleep"; import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent"; import { useAgentsDataRefresh } from "@/features/agents/lib/useAgentsDataRefresh"; @@ -567,68 +567,11 @@ export function AppShell() { [openSearchHit], ); - // Prevent webview file:/// navigation on file drop outside the composer. - // Scoped to file drags only (text drag-and-drop into inputs still works). - // Composer's onDrop fires first (React synthetic before window bubble). - React.useEffect(() => { - function preventNavigation(e: DragEvent) { - if (e.dataTransfer?.types.includes("Files")) { - e.preventDefault(); - } - } - window.addEventListener("dragover", preventNavigation); - window.addEventListener("drop", preventNavigation); - return () => { - window.removeEventListener("dragover", preventNavigation); - window.removeEventListener("drop", preventNavigation); - }; - }, []); - - React.useEffect(() => { - let isCancelled = false; - - const startPreconnect = () => { - if (isCancelled) { - return; - } - - void relayClient.preconnect().catch((error) => { - if (!isCancelled) { - console.error("Failed to preconnect to relay", error); - } - }); - }; - - if ("requestIdleCallback" in window) { - const idleId = window.requestIdleCallback(startPreconnect, { - timeout: 1_500, - }); - return () => { - isCancelled = true; - window.cancelIdleCallback(idleId); - }; - } - - const timeoutId = globalThis.setTimeout(startPreconnect, 250); - return () => { - isCancelled = true; - globalThis.clearTimeout(timeoutId); - }; - }, []); - - React.useEffect(() => { - const count = - unreadChannelNotificationCount + homeBadgeCountExcludingHighPriority; - void setDesktopAppBadge( - count - ? { kind: "count", count } - : { kind: unreadChannelIds.size ? "dot" : "none" }, - ); - }, [ + useAppShellLifecycleEffects({ homeBadgeCountExcludingHighPriority, unreadChannelIds, unreadChannelNotificationCount, - ]); + }); // Dispatch `buzz://message` deep links into the router. useMessageDeepLinks(); diff --git a/desktop/src/app/useAppShellLifecycleEffects.ts b/desktop/src/app/useAppShellLifecycleEffects.ts new file mode 100644 index 0000000000..cc0447cc32 --- /dev/null +++ b/desktop/src/app/useAppShellLifecycleEffects.ts @@ -0,0 +1,79 @@ +import * as React from "react"; + +import { setDesktopAppBadge } from "@/features/notifications/lib/desktop"; +import { relayClient } from "@/shared/api/relayClient"; + +type AppShellLifecycleEffectsOptions = { + homeBadgeCountExcludingHighPriority: number; + unreadChannelIds: ReadonlySet; + unreadChannelNotificationCount: number; +}; + +export function useAppShellLifecycleEffects({ + homeBadgeCountExcludingHighPriority, + unreadChannelIds, + unreadChannelNotificationCount, +}: AppShellLifecycleEffectsOptions) { + // Prevent webview file:/// navigation on file drop outside the composer. + // Scoped to file drags only (text drag-and-drop into inputs still works). + // Composer's onDrop fires first (React synthetic before window bubble). + React.useEffect(() => { + function preventNavigation(e: DragEvent) { + if (e.dataTransfer?.types.includes("Files")) { + e.preventDefault(); + } + } + window.addEventListener("dragover", preventNavigation); + window.addEventListener("drop", preventNavigation); + return () => { + window.removeEventListener("dragover", preventNavigation); + window.removeEventListener("drop", preventNavigation); + }; + }, []); + + React.useEffect(() => { + let isCancelled = false; + + const startPreconnect = () => { + if (isCancelled) { + return; + } + + void relayClient.preconnect().catch((error) => { + if (!isCancelled) { + console.error("Failed to preconnect to relay", error); + } + }); + }; + + if ("requestIdleCallback" in window) { + const idleId = window.requestIdleCallback(startPreconnect, { + timeout: 1_500, + }); + return () => { + isCancelled = true; + window.cancelIdleCallback(idleId); + }; + } + + const timeoutId = globalThis.setTimeout(startPreconnect, 250); + return () => { + isCancelled = true; + globalThis.clearTimeout(timeoutId); + }; + }, []); + + React.useEffect(() => { + const count = + unreadChannelNotificationCount + homeBadgeCountExcludingHighPriority; + void setDesktopAppBadge( + count + ? { kind: "count", count } + : { kind: unreadChannelIds.size ? "dot" : "none" }, + ); + }, [ + homeBadgeCountExcludingHighPriority, + unreadChannelIds, + unreadChannelNotificationCount, + ]); +} 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/issueMutations.ts b/desktop/src/features/projects/issueMutations.ts new file mode 100644 index 0000000000..d112850070 --- /dev/null +++ b/desktop/src/features/projects/issueMutations.ts @@ -0,0 +1,58 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { relayClient } from "@/shared/api/relayClient"; +import { signRelayEvent } from "@/shared/api/tauri"; +import { KIND_GIT_ISSUE } from "@/shared/constants/kinds"; +import type { Project } from "./hooks"; +import { buildGitIssueTags } from "./projectIssues.mjs"; + +type CreateProjectIssueInput = { + title: string; + body: string; +}; + +export async function publishProjectIssue( + project: Project, + input: CreateProjectIssueInput, +) { + const event = await signRelayEvent({ + kind: KIND_GIT_ISSUE, + content: input.body.trim(), + tags: buildGitIssueTags({ + repoAddress: project.repoAddress, + repoOwner: project.owner, + title: input.title, + }), + }); + await relayClient.publishEvent( + event, + "Timed out creating issue.", + "Failed to create issue.", + ); + return event.id; +} + +export function useCreateProjectIssueMutation( + project: Project | null | undefined, +) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: CreateProjectIssueInput) => { + if (!project) throw new Error("No project selected."); + return publishProjectIssue(project, input); + }, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["project", project?.id ?? "none", "issues"], + }), + queryClient.invalidateQueries({ + queryKey: ["projects", "issues"], + }), + queryClient.invalidateQueries({ + queryKey: ["projects", "activity-summaries"], + }), + ]); + }, + }); +} diff --git a/desktop/src/features/projects/lib/projectsViewHelpers.ts b/desktop/src/features/projects/lib/projectsViewHelpers.ts index 188b7a8781..fefb75b4f8 100644 --- a/desktop/src/features/projects/lib/projectsViewHelpers.ts +++ b/desktop/src/features/projects/lib/projectsViewHelpers.ts @@ -6,6 +6,8 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { normalizePubkey } from "@/shared/lib/pubkey"; export type ProjectsViewMode = "grid" | "list"; +export type ProjectsRepositoryScope = "all" | "mine" | "local"; +export type ProjectsWorkItemScope = "all" | "mine"; export type ProjectsFilter = | "all" | "mine" @@ -19,6 +21,10 @@ export type ProjectsSort = "updated" | "created" | "name"; const PROJECTS_VIEW_MODE_STORAGE_KEY = "buzz.projects.viewMode"; const PROJECTS_FILTER_STORAGE_KEY = "buzz.projects.filter"; +const PROJECTS_REPOSITORY_SCOPE_STORAGE_KEY = "buzz.projects.repositoryScope"; +const PROJECTS_PULL_REQUEST_SCOPE_STORAGE_KEY = + "buzz.projects.pullRequestScope"; +const PROJECTS_ISSUE_SCOPE_STORAGE_KEY = "buzz.projects.issueScope"; const PROJECTS_SORT_STORAGE_KEY = "buzz.projects.sort"; export function readStoredViewMode(): ProjectsViewMode | null { @@ -65,6 +71,66 @@ export function writeStoredFilter(filter: ProjectsFilter) { } } +export function readStoredRepositoryScope(): ProjectsRepositoryScope { + try { + const value = globalThis.localStorage?.getItem( + PROJECTS_REPOSITORY_SCOPE_STORAGE_KEY, + ); + if (value === "mine" || value === "local") return value; + const legacyFilter = globalThis.localStorage?.getItem( + PROJECTS_FILTER_STORAGE_KEY, + ); + return legacyFilter === "mine" || legacyFilter === "local" + ? legacyFilter + : "all"; + } catch { + return "all"; + } +} + +export function writeStoredRepositoryScope(scope: ProjectsRepositoryScope) { + try { + globalThis.localStorage?.setItem( + PROJECTS_REPOSITORY_SCOPE_STORAGE_KEY, + scope, + ); + } catch { + // Persistence is best-effort; the in-memory filter still works. + } +} + +function readStoredWorkItemScope(key: string): ProjectsWorkItemScope { + try { + return globalThis.localStorage?.getItem(key) === "mine" ? "mine" : "all"; + } catch { + return "all"; + } +} + +function writeStoredWorkItemScope(key: string, scope: ProjectsWorkItemScope) { + try { + globalThis.localStorage?.setItem(key, scope); + } catch { + // Persistence is best-effort; the in-memory filter still works. + } +} + +export function readStoredPullRequestScope(): ProjectsWorkItemScope { + return readStoredWorkItemScope(PROJECTS_PULL_REQUEST_SCOPE_STORAGE_KEY); +} + +export function writeStoredPullRequestScope(scope: ProjectsWorkItemScope) { + writeStoredWorkItemScope(PROJECTS_PULL_REQUEST_SCOPE_STORAGE_KEY, scope); +} + +export function readStoredIssueScope(): ProjectsWorkItemScope { + return readStoredWorkItemScope(PROJECTS_ISSUE_SCOPE_STORAGE_KEY); +} + +export function writeStoredIssueScope(scope: ProjectsWorkItemScope) { + writeStoredWorkItemScope(PROJECTS_ISSUE_SCOPE_STORAGE_KEY, scope); +} + export function readStoredSort(): ProjectsSort { try { const value = globalThis.localStorage?.getItem(PROJECTS_SORT_STORAGE_KEY); @@ -176,14 +242,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/projectIssues.test.mjs b/desktop/src/features/projects/projectIssues.test.mjs index b8065675c6..b6fa6a901f 100644 --- a/desktop/src/features/projects/projectIssues.test.mjs +++ b/desktop/src/features/projects/projectIssues.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + buildGitIssueTags, eventToProjectIssue, getAllTags, getTag, @@ -97,3 +98,18 @@ test("tag helpers drop malformed value-less tags", () => { assert.equal(issue.status, PROJECT_ISSUE_STATUS.BACKLOG); assert.equal(issue.title, "Something is broken"); }); + +test("builds repository-scoped issue creation tags", () => { + assert.deepEqual( + buildGitIssueTags({ + repoAddress: REPO_ADDRESS, + repoOwner: OWNER, + title: " Fix the broken workflow ", + }), + [ + ["a", REPO_ADDRESS], + ["p", OWNER], + ["subject", "Fix the broken workflow"], + ], + ); +}); diff --git a/desktop/src/features/projects/projectPullRequests.d.mts b/desktop/src/features/projects/projectPullRequests.d.mts index a426b34ef4..de701bf91d 100644 --- a/desktop/src/features/projects/projectPullRequests.d.mts +++ b/desktop/src/features/projects/projectPullRequests.d.mts @@ -45,6 +45,7 @@ export type ProjectPullRequest = { statusEventId: string | null; statusCreatedAt: number | null; branchName: string | null; + targetBranch: string | null; initialCommit: string | null; commit: string | null; cloneUrls: string[]; diff --git a/desktop/src/features/projects/projectPullRequests.mjs b/desktop/src/features/projects/projectPullRequests.mjs index 67beccccb3..721f5db4b3 100644 --- a/desktop/src/features/projects/projectPullRequests.mjs +++ b/desktop/src/features/projects/projectPullRequests.mjs @@ -125,12 +125,19 @@ function reviewersForPullRequest(pullRequest, comments) { return [...reviewers]; } -/** Latest approval comment per author, oldest first. */ -function approvalsForPullRequest(comments) { +/** Latest trusted approval per author, oldest first. */ +function approvalsForPullRequest(pullRequest, comments, reviewers) { + const author = pullRequest.pubkey.toLowerCase(); + const trustedApprovers = new Set(reviewers); + for (const actor of allowedActorsForRoot(pullRequest)) { + if (actor !== author) trustedApprovers.add(actor); + } + const byAuthor = new Map(); for (const comment of comments) { if (!comment.isApproval) continue; const key = comment.author.toLowerCase(); + if (!trustedApprovers.has(key)) continue; const existing = byAuthor.get(key); if (!existing || comment.createdAt > existing.createdAt) { byAuthor.set(key, comment); @@ -157,7 +164,7 @@ export function eventToProjectPullRequest( eventToPullRequestComment, ); const reviewers = reviewersForPullRequest(pullRequest, comments); - const approvals = approvalsForPullRequest(comments); + const approvals = approvalsForPullRequest(pullRequest, comments, reviewers); const title = getTag(pullRequest, "subject") || pullRequest.content.split("\n")[0] || @@ -180,6 +187,7 @@ export function eventToProjectPullRequest( statusEventId: latestStatus?.id ?? null, statusCreatedAt: latestStatus?.created_at ?? null, branchName: getTag(pullRequest, "branch-name") ?? null, + targetBranch: getTag(pullRequest, "target-branch") ?? null, initialCommit, commit: latestCommit, cloneUrls: getCloneUrls(latestUpdate ?? pullRequest), diff --git a/desktop/src/features/projects/projectPullRequests.test.mjs b/desktop/src/features/projects/projectPullRequests.test.mjs index 73b347b53b..4379e904d3 100644 --- a/desktop/src/features/projects/projectPullRequests.test.mjs +++ b/desktop/src/features/projects/projectPullRequests.test.mjs @@ -22,12 +22,21 @@ function pullRequestEvent(overrides = {}) { ["a", REPO_ADDRESS], ["subject", "Add feature"], ["c", "1111111111111111111111111111111111111111"], + ["branch-name", "feature/demo"], + ["target-branch", "release"], ["clone", `https://relay.example/git/${OWNER}/demo`], ], ...overrides, }; } +test("reads source and target branches from the pull request", () => { + const pullRequest = eventToProjectPullRequest(pullRequestEvent()); + + assert.equal(pullRequest.branchName, "feature/demo"); + assert.equal(pullRequest.targetBranch, "release"); +}); + function updateEvent({ pubkey, createdAt, commit, cloneUrl }) { return { id: `update-${pubkey.slice(0, 8)}-${createdAt}`, @@ -247,6 +256,8 @@ test("reviewers come from root p tags plus trusted review requests", () => { test("approvals keep the latest per author and flag comments", () => { const reviewer = "d".repeat(64); + const event = pullRequestEvent(); + event.tags.push(["p", reviewer]); const firstApproval = commentEvent({ pubkey: reviewer, createdAt: 200, @@ -266,7 +277,7 @@ test("approvals keep the latest per author and flag comments", () => { }); const pullRequest = eventToProjectPullRequest( - pullRequestEvent(), + event, [], [firstApproval, plainComment, secondApproval], ); @@ -284,6 +295,41 @@ test("approvals keep the latest per author and flag comments", () => { ); }); +test("approvals only count requested reviewers and the repository owner", () => { + const reviewer = "d".repeat(64); + const event = pullRequestEvent(); + event.tags.push(["p", reviewer]); + const comments = [ + commentEvent({ + pubkey: reviewer, + createdAt: 200, + labels: ["approval"], + }), + commentEvent({ + pubkey: OWNER, + createdAt: 210, + labels: ["approval"], + }), + commentEvent({ + pubkey: ATTACKER, + createdAt: 220, + labels: ["approval"], + }), + commentEvent({ + pubkey: AUTHOR, + createdAt: 230, + labels: ["approval"], + }), + ]; + + const pullRequest = eventToProjectPullRequest(event, [], comments); + + assert.deepEqual( + pullRequest.approvals.map((approval) => approval.author), + [reviewer, OWNER], + ); +}); + test("survives malformed value-less tags", () => { const event = pullRequestEvent({ tags: [ diff --git a/desktop/src/features/projects/pullRequestMutations.test.mjs b/desktop/src/features/projects/pullRequestMutations.test.mjs new file mode 100644 index 0000000000..989eb87975 --- /dev/null +++ b/desktop/src/features/projects/pullRequestMutations.test.mjs @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + canPublishProjectPullRequestUpdate, + 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`], +}; +const pullRequest = { + author: AUTHOR, +}; + +test("only the repository owner or PR author can publish an update", () => { + assert.equal( + canPublishProjectPullRequestUpdate(OWNER, project, pullRequest), + true, + ); + assert.equal( + canPublishProjectPullRequestUpdate(AUTHOR, project, pullRequest), + true, + ); + assert.equal( + canPublishProjectPullRequestUpdate(REVIEWER, project, pullRequest), + false, + ); +}); + +test("projectPullRequestTags builds a NIP-34 kind 1618 tag set", () => { + const tags = projectPullRequestTags(project, { + title: "Add Projects workflow", + body: "", + branch: "projects-workflow", + targetBranch: "main", + 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"], + ["target-branch", "main"], + ["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..4eae6464bf --- /dev/null +++ b/desktop/src/features/projects/pullRequestMutations.ts @@ -0,0 +1,271 @@ +import { useMutation } from "@tanstack/react-query"; + +import { + mergeProjectPullRequest, + publishProjectPullRequestMergedStatus, +} from "@/shared/api/projectGit"; +import { relayClient } from "@/shared/api/relayClient"; +import { signRelayEvent } from "@/shared/api/tauri"; +import { getIdentity } from "@/shared/api/tauriIdentity"; +import { + KIND_GIT_PR_UPDATE, + KIND_GIT_PULL_REQUEST, +} 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; + targetBranch: 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], + ["target-branch", input.targetBranch], + ]; + 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], + ]; +} + +/** Whether the active identity may publish a trusted update for this PR. */ +export function canPublishProjectPullRequestUpdate( + viewerPubkey: string, + project: Project, + pullRequest: ProjectPullRequest, +) { + const viewer = normalizePubkey(viewerPubkey); + return ( + viewer === normalizePubkey(project.owner) || + viewer === normalizePubkey(pullRequest.author) + ); +} + +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 === input.targetBranch) { + throw new Error("The base and compare branches must be different."); + } + + 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 identity = await getIdentity(); + if ( + !canPublishProjectPullRequestUpdate(identity.pubkey, project, pullRequest) + ) { + throw new Error( + "Only the pull request author or repository owner can publish its update.", + ); + } + 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, + statusEvent: string, +) { + await publishProjectPullRequestMergedStatus({ + targetOwner: project.owner, + statusEvent, + }); +} + +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, + repoAddress: project.repoAddress, + pullRequestId: pullRequest.id, + pullRequestAuthor: pullRequest.author, + statusCreatedAt: nextProjectPullRequestStatusCreatedAt( + pullRequest, + Math.floor(Date.now() / 1_000), + ), + targetBranch: pullRequest.targetBranch ?? project.defaultBranch, + sourceBranch: pullRequest.branchName, + expectedCommit: pullRequest.commit, + }); + return result; + }, + onSuccess: invalidate, + }); +} + +export function usePublishProjectPullRequestMergedMutation( + project: Project | null | undefined, +) { + const invalidate = useProjectPullRequestWriteInvalidation(project); + return useMutation({ + mutationFn: ({ statusEvent }: { statusEvent: string }) => { + if (!project) throw new Error("No project selected."); + return publishProjectPullRequestMerged(project, statusEvent); + }, + onSuccess: invalidate, + }); +} diff --git a/desktop/src/features/projects/pullRequestReviews.ts b/desktop/src/features/projects/pullRequestReviews.ts index e4eec54116..7606dbefa4 100644 --- a/desktop/src/features/projects/pullRequestReviews.ts +++ b/desktop/src/features/projects/pullRequestReviews.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import * as React from "react"; +import { signProjectPullRequestReviewRequest } from "@/shared/api/projectGit"; import { relayClient } from "@/shared/api/relayClient"; import { signRelayEvent } from "@/shared/api/tauri"; import { @@ -79,11 +80,13 @@ async function requestProjectPullRequestReview({ pullRequest, reviewers, reviewerLabel, + signAsManagedOwner, }: { project: Project; pullRequest: ProjectPullRequest; reviewers: string[]; reviewerLabel: string; + signAsManagedOwner: boolean; }): Promise { if (reviewers.length === 0) { throw new Error("Select at least one reviewer."); @@ -91,6 +94,16 @@ async function requestProjectPullRequestReview({ const reviewerPubkeys = [ ...new Set(reviewers.map((pubkey) => pubkey.toLowerCase())), ]; + if (signAsManagedOwner) { + await signProjectPullRequestReviewRequest({ + targetOwner: project.owner, + repoAddress: project.repoAddress, + pullRequestId: pullRequest.id, + reviewers: reviewerPubkeys, + reviewerLabel, + }); + return; + } const event = await signRelayEvent({ kind: KIND_TEXT_NOTE, content: `Requested a review from ${reviewerLabel}`, @@ -138,7 +151,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 +171,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,17 +191,19 @@ export function useUpdateProjectPullRequestStatusMutation( export function useRequestProjectPullRequestReviewMutation( project: Project | null | undefined, ) { - const invalidate = usePullRequestWriteInvalidation(project); + const invalidate = useProjectPullRequestWriteInvalidation(project); return useMutation({ mutationFn: ({ pullRequest, reviewers, reviewerLabel, + signAsManagedOwner, }: { pullRequest: ProjectPullRequest; reviewers: string[]; reviewerLabel: string; + signAsManagedOwner: boolean; }) => { if (!project) throw new Error("No project selected."); return requestProjectPullRequestReview({ @@ -194,6 +211,7 @@ export function useRequestProjectPullRequestReviewMutation( pullRequest, reviewers, reviewerLabel, + signAsManagedOwner, }); }, onSuccess: invalidate, @@ -203,7 +221,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..457ccca175 100644 --- a/desktop/src/features/projects/repoSyncHooks.ts +++ b/desktop/src/features/projects/repoSyncHooks.ts @@ -1,11 +1,13 @@ 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 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 @@ -15,8 +17,10 @@ export function useProjectRepoSyncStatusQuery( project: Project | null | undefined, reposDir?: string | null, branchName?: string | null, + baseBranch?: string | null, ) { const selectedBranch = branchName ?? project?.defaultBranch ?? null; + const selectedBaseBranch = baseBranch ?? project?.defaultBranch ?? null; return useQuery({ enabled: Boolean(project?.cloneUrls[0]), @@ -26,6 +30,7 @@ export function useProjectRepoSyncStatusQuery( "repo-sync-status", reposDir ?? "default", selectedBranch ?? "default", + selectedBaseBranch ?? "default", ], queryFn: () => { if (!project?.cloneUrls[0]) throw new Error("No project selected."); @@ -33,7 +38,8 @@ export function useProjectRepoSyncStatusQuery( reposDir, projectDtag: project.dtag, cloneUrl: project.cloneUrls[0], - defaultBranch: selectedBranch, + branchName: selectedBranch, + baseBranch: selectedBaseBranch, }); }, staleTime: 10_000, @@ -48,19 +54,49 @@ 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 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 +107,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 +155,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/CreateIssueDialog.tsx b/desktop/src/features/projects/ui/CreateIssueDialog.tsx new file mode 100644 index 0000000000..6a3d679f57 --- /dev/null +++ b/desktop/src/features/projects/ui/CreateIssueDialog.tsx @@ -0,0 +1,34 @@ +import { + CreateProjectWorkItemDialog, + type CreateProjectWorkItemDialogInput, +} from "./CreateProjectWorkItemDialog"; + +export type CreateIssueDialogInput = CreateProjectWorkItemDialogInput; + +export function CreateIssueDialog({ + isCreating, + onCreate, + onOpenChange, + open, + projectName, +}: { + isCreating: boolean; + onCreate: (input: CreateIssueDialogInput) => Promise; + onOpenChange: (open: boolean) => void; + open: boolean; + projectName: string; +}) { + return ( + + ); +} diff --git a/desktop/src/features/projects/ui/CreateProjectIssueDialog.tsx b/desktop/src/features/projects/ui/CreateProjectIssueDialog.tsx new file mode 100644 index 0000000000..af28693bb4 --- /dev/null +++ b/desktop/src/features/projects/ui/CreateProjectIssueDialog.tsx @@ -0,0 +1,81 @@ +import * as React from "react"; +import { toast } from "sonner"; + +import type { Project } from "@/features/projects/hooks"; +import { useCreateProjectIssueMutation } from "@/features/projects/issueMutations"; +import { + CreateProjectWorkItemDialog, + type CreateProjectWorkItemDialogInput, +} from "./CreateProjectWorkItemDialog"; + +export function CreateProjectIssueDialog({ + initialProjectId, + onCreated, + onOpenChange, + open, + projects, +}: { + initialProjectId?: string; + onCreated: (project: Project, issueId: string) => void | Promise; + onOpenChange: (open: boolean) => void; + open: boolean; + projects: Project[]; +}) { + const initialProject = + projects.find((project) => project.id === initialProjectId) ?? projects[0]; + const [projectId, setProjectId] = React.useState(initialProject?.id ?? ""); + const project = + projects.find((candidate) => candidate.id === projectId) ?? initialProject; + const createMutation = useCreateProjectIssueMutation(project); + + React.useEffect(() => { + if (!open) return; + const nextProject = + projects.find((candidate) => candidate.id === initialProjectId) ?? + projects[0]; + setProjectId(nextProject?.id ?? ""); + }, [initialProjectId, open, projects]); + + async function handleCreate(input: CreateProjectWorkItemDialogInput) { + if (!project) throw new Error("Choose a repository."); + const issueId = await createMutation.mutateAsync(input); + toast.success("Issue created."); + await onCreated(project, issueId); + } + + return ( + + + + ); +} diff --git a/desktop/src/features/projects/ui/CreateProjectWorkItemDialog.tsx b/desktop/src/features/projects/ui/CreateProjectWorkItemDialog.tsx new file mode 100644 index 0000000000..73819c9617 --- /dev/null +++ b/desktop/src/features/projects/ui/CreateProjectWorkItemDialog.tsx @@ -0,0 +1,189 @@ +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 CreateProjectWorkItemDialogInput = { + title: string; + body: string; +}; + +export function CreateProjectWorkItemDialog({ + bodyPlaceholder, + children, + description, + isCreating, + itemName, + onCreate, + onOpenChange, + open, + submitDisabled = false, + title, + titlePlaceholder, +}: { + bodyPlaceholder: string; + children?: React.ReactNode; + description: string; + isCreating: boolean; + itemName: "issue" | "pull-request"; + onCreate: (input: CreateProjectWorkItemDialogInput) => Promise; + onOpenChange: (open: boolean) => void; + open: boolean; + submitDisabled?: boolean; + title: string; + titlePlaceholder: string; +}) { + const [workItemTitle, setWorkItemTitle] = React.useState(""); + const [body, setBody] = React.useState(""); + const [errorMessage, setErrorMessage] = React.useState(null); + const titleInputRef = React.useRef(null); + const submitInFlightRef = React.useRef(false); + const testIdPrefix = `create-${itemName}`; + const itemLabel = itemName === "issue" ? "issue" : "pull request"; + + React.useEffect(() => { + if (!open) return; + setWorkItemTitle(""); + setBody(""); + setErrorMessage(null); + const timerId = globalThis.setTimeout( + () => titleInputRef.current?.focus(), + 50, + ); + return () => globalThis.clearTimeout(timerId); + }, [open]); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (isCreating || submitDisabled || submitInFlightRef.current) return; + const trimmedTitle = workItemTitle.trim(); + if (!trimmedTitle) return; + submitInFlightRef.current = true; + setErrorMessage(null); + try { + await onCreate({ title: trimmedTitle, body: body.trim() }); + onOpenChange(false); + } catch (error) { + setErrorMessage( + error instanceof Error + ? error.message + : `Failed to create ${itemLabel}.`, + ); + } finally { + submitInFlightRef.current = false; + } + } + + return ( + { + if (!nextOpen && isCreating) return; + onOpenChange(nextOpen); + }} + open={open} + > + + + + } + footerClassName="border-t-0 pt-0" + headerClassName="pb-2" + title={title} + > +
void handleSubmit(event)} + > + {children} +
+ +
+ { + setWorkItemTitle(event.target.value); + setErrorMessage(null); + }} + placeholder={titlePlaceholder} + ref={titleInputRef} + value={workItemTitle} + /> +
+
+
+ +
+