diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 64edf68ee2..ddc0330d9f 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -31,8 +31,8 @@ use uuid::Uuid; use crate::acp::{ extract_model_config_options, extract_model_state, model_in_catalog, - resolve_model_switch_method, AcpClient, AcpError, McpServer, ModelSwitchMethod, StopReason, - SystemPromptTransport, + resolve_model_switch_method, AcpClient, AcpError, EnvVar, McpServer, ModelSwitchMethod, + StopReason, SystemPromptTransport, }; use crate::config::{compose_session_title, DedupMode, PermissionMode}; use crate::observer; @@ -867,13 +867,13 @@ const UNKNOWN_CHANNEL_NAME: &str = "unknown"; async fn resolve_new_session_channel_context( channel_info: &ChannelInfoResolver, channel_id: Uuid, -) -> (bool, Option) { +) -> (bool, Option, Option) { let Some(info) = channel_info.resolve(channel_id).await else { - return (true, None); + return (true, None, None); }; let is_dm = info.channel_type == "dm"; let title_channel = (!is_dm && info.name != UNKNOWN_CHANNEL_NAME).then_some(info.name); - (is_dm, title_channel) + (is_dm, title_channel, Some(info.channel_type)) } /// Create a new ACP session via `session_new_full()`, populate model capabilities @@ -888,6 +888,8 @@ async fn create_session_and_apply_model( agent_core: Option<&str>, agent_canvas: Option<&str>, channel_name: Option<&str>, + channel_id: Option, + channel_type: Option<&str>, ) -> Result { // Build base_prompt + system_prompt + agent core + canvas metadata into a // single prompt. Standard protocol-v2 agents receive it in `session/new`; @@ -911,12 +913,18 @@ async fn create_session_and_apply_model( .session_title .as_deref() .map(|agent_name| compose_session_title(agent_name, channel_name)); + let mcp_servers = mcp_servers_with_git_origin( + &ctx.mcp_servers, + channel_id, + channel_type, + ctx.session_title.as_deref(), + ); let resp = agent .acp .session_new_full( &ctx.cwd, - ctx.mcp_servers.clone(), + mcp_servers, session_new_system_prompt( is_goose, agent.protocol_version, @@ -1019,6 +1027,34 @@ async fn create_session_and_apply_model( Ok(resp.session_id) } +fn mcp_servers_with_git_origin( + servers: &[McpServer], + channel_id: Option, + channel_type: Option<&str>, + agent_name: Option<&str>, +) -> Vec { + let mut servers = servers.to_vec(); + let origin = match (channel_id, channel_type) { + (Some(channel_id), Some("stream")) => Some(EnvVar { + name: "BUZZ_GIT_ORIGIN_CHANNEL_ID".into(), + value: channel_id.to_string(), + }), + (Some(_), _) => agent_name + .filter(|name| !name.trim().is_empty()) + .map(|name| EnvVar { + name: "BUZZ_GIT_ORIGIN_AGENT_NAME".into(), + value: name.trim().to_string(), + }), + (None, _) => None, + }; + if let Some(origin) = origin { + for server in &mut servers { + server.env.push(origin.clone()); + } + } + servers +} + /// Send the appropriate ACP model-switch request with a timeout. /// /// On timeout or error, logs a warning and returns — the caller proceeds @@ -1519,14 +1555,15 @@ pub async fn run_prompt_task( // Channel name for the session title, from the same single resolve the // canvas DM check uses — see `resolve_new_session_channel_context`. let mut title_channel: Option = None; + let mut origin_channel_type: Option = None; if let PromptSource::Channel(cid) = &source { let is_new_channel_session = !agent.state.sessions.contains_key(cid); let needs_canvas = is_new_channel_session && !agent.state.canvas_sections.contains_key(cid); - let needs_title = is_new_channel_session && ctx.session_title.is_some(); - if needs_canvas || needs_title { - let (is_dm, resolved_channel) = + if is_new_channel_session { + let (is_dm, resolved_channel, resolved_channel_type) = resolve_new_session_channel_context(&ctx.channel_info, *cid).await; title_channel = resolved_channel; + origin_channel_type = resolved_channel_type; // A confirmed DM never receives a canvas section; an undeterminable // channel type fails closed as a DM for the same reason. if needs_canvas && !is_dm { @@ -1571,6 +1608,8 @@ pub async fn run_prompt_task( agent_core.as_deref(), agent_canvas.as_deref(), title_channel.as_deref(), + Some(*cid), + origin_channel_type.as_deref(), ) .await { @@ -1618,7 +1657,9 @@ pub async fn run_prompt_task( if let Some(sid) = &agent.state.heartbeat_session { (sid.clone(), false) } else { - match create_session_and_apply_model(&mut agent, &ctx, None, None, None).await { + match create_session_and_apply_model(&mut agent, &ctx, None, None, None, None, None) + .await + { Ok(sid) => { tracing::info!( target: "pool::session", @@ -3989,6 +4030,50 @@ mod tests { use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use serde_json::json; + fn test_mcp_server() -> McpServer { + McpServer { + name: "dev".into(), + command: "buzz-dev-mcp".into(), + args: vec![], + env: vec![], + } + } + + #[test] + fn public_session_forwards_channel_origin_to_mcp() { + let channel_id = Uuid::new_v4(); + let servers = mcp_servers_with_git_origin( + &[test_mcp_server()], + Some(channel_id), + Some("stream"), + None, + ); + assert!(servers[0].env.iter().any(|entry| { + entry.name == "BUZZ_GIT_ORIGIN_CHANNEL_ID" && entry.value == channel_id.to_string() + })); + assert!(!servers[0] + .env + .iter() + .any(|entry| entry.name == "BUZZ_GIT_ORIGIN_AGENT_NAME")); + } + + #[test] + fn private_session_forwards_agent_name_without_channel_id() { + let servers = mcp_servers_with_git_origin( + &[test_mcp_server()], + Some(Uuid::new_v4()), + Some("dm"), + Some("Builder"), + ); + assert!(servers[0].env.iter().any(|entry| { + entry.name == "BUZZ_GIT_ORIGIN_AGENT_NAME" && entry.value == "Builder" + })); + assert!(!servers[0] + .env + .iter() + .any(|entry| entry.name == "BUZZ_GIT_ORIGIN_CHANNEL_ID")); + } + // These pin the initial_message dispatch path (run_prompt_task, ~line 855): // a legacy agent WITH a base_prompt must get [Base] prepended to the user // message. This is the exact regression that shipped in the round-2 bug. @@ -6833,12 +6918,14 @@ mod tests { let response = channel_metadata_response(id, &[["name", "buzz-dev"], ["t", "stream"]]); let (resolver, requests, server) = counting_resolver(response).await; - let (is_dm, title_channel) = resolve_new_session_channel_context(&resolver, id).await; + let (is_dm, title_channel, channel_type) = + resolve_new_session_channel_context(&resolver, id).await; assert!(!is_dm, "a stream channel is not a DM"); assert_eq!(title_channel.as_deref(), Some("buzz-dev")); + assert_eq!(channel_type.as_deref(), Some("stream")); assert_eq!(requests.load(Ordering::SeqCst), 1); - let (_, again) = resolve_new_session_channel_context(&resolver, id).await; + let (_, again, _) = resolve_new_session_channel_context(&resolver, id).await; assert_eq!(again.as_deref(), Some("buzz-dev")); assert_eq!( requests.load(Ordering::SeqCst), @@ -6856,8 +6943,10 @@ mod tests { let response = channel_metadata_response(id, &[["name", "DM"], ["t", "dm"]]); let (resolver, _requests, server) = counting_resolver(response).await; - let (is_dm, title_channel) = resolve_new_session_channel_context(&resolver, id).await; + let (is_dm, title_channel, channel_type) = + resolve_new_session_channel_context(&resolver, id).await; assert!(is_dm); + assert_eq!(channel_type.as_deref(), Some("dm")); assert_eq!( title_channel, None, "a DM name must never reach the session title" @@ -6874,7 +6963,7 @@ mod tests { let response = channel_metadata_response(id, &[["t", "stream"]]); let (resolver, _requests, server) = counting_resolver(response).await; - let (is_dm, title_channel) = resolve_new_session_channel_context(&resolver, id).await; + let (is_dm, title_channel, _) = resolve_new_session_channel_context(&resolver, id).await; assert!(!is_dm, "a nameless stream channel is still not a DM"); assert_eq!( title_channel, None, @@ -6894,10 +6983,11 @@ mod tests { let (resolver, requests, server) = counting_resolver(json!([])).await; - let (is_dm, title_channel) = + let (is_dm, title_channel, channel_type) = resolve_new_session_channel_context(&resolver, Uuid::new_v4()).await; assert!(is_dm, "an undeterminable channel type must fail closed"); assert_eq!(title_channel, None, "unresolved channels get a bare title"); + assert_eq!(channel_type, None); assert_eq!( requests.load(Ordering::SeqCst), 2, diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json index 28fe6ce90e..beffc29440 100644 --- a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json @@ -22,6 +22,7 @@ "owner_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "policy_env": { "BUZZ_ACP_AGENTS": "10", + "BUZZ_ACP_DISPLAY_NAME": "worker", "BUZZ_ACP_LAZY_POOL": "true", "BUZZ_ACP_MODEL": "gpt-5", "BUZZ_ACP_RELAY_OBSERVER": "true", diff --git a/crates/buzz-cli/src/commands/issues.rs b/crates/buzz-cli/src/commands/issues.rs index 3d7d92a1b4..d531e53eb6 100644 --- a/crates/buzz-cli/src/commands/issues.rs +++ b/crates/buzz-cli/src/commands/issues.rs @@ -1,4 +1,5 @@ use crate::client::BuzzClient; +use crate::commands::with_git_provenance; use crate::error::CliError; use crate::validate::{read_or_stdin, sdk_err, validate_hex64, validate_repo_id}; use buzz_sdk::{GitIssueMeta, GitRepoCoord, GitStatusMeta}; @@ -26,7 +27,9 @@ pub async fn cmd_create_issue( id: repo_id.to_string(), }; - let builder = buzz_sdk::build_git_issue(&repo, subject, &body, &meta).map_err(sdk_err)?; + let builder = with_git_provenance( + buzz_sdk::build_git_issue(&repo, subject, &body, &meta).map_err(sdk_err)?, + )?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -137,7 +140,8 @@ pub async fn cmd_issue_status( applied_as_commits: vec![], }; - let builder = buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?; + let builder = + with_git_provenance(buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?)?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 1ccc37a702..ad2c36e200 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -21,6 +21,55 @@ pub mod users; pub mod workflows; use crate::{client::normalize_write_response, error::CliError}; +use nostr::{EventBuilder, Tag}; + +const GIT_ORIGIN_CHANNEL_ENV: &str = "BUZZ_GIT_ORIGIN_CHANNEL_ID"; +const GIT_ORIGIN_AGENT_ENV: &str = "BUZZ_GIT_ORIGIN_AGENT_NAME"; + +/// Add trusted, session-scoped provenance supplied by the ACP harness. +/// +/// Public channels use the standard NIP-29 `h` tag. Private conversations +/// intentionally omit their channel coordinate and retain only the agent's +/// display name. +pub(crate) fn with_git_provenance(builder: EventBuilder) -> Result { + apply_git_provenance( + builder, + std::env::var(GIT_ORIGIN_CHANNEL_ENV).ok().as_deref(), + std::env::var(GIT_ORIGIN_AGENT_ENV).ok().as_deref(), + ) +} + +fn apply_git_provenance( + builder: EventBuilder, + channel_id: Option<&str>, + agent_name: Option<&str>, +) -> Result { + if let Some(channel_id) = channel_id { + let channel_id = channel_id.trim(); + uuid::Uuid::parse_str(channel_id) + .map_err(|_| CliError::Other("invalid git origin channel ID".into()))?; + let origin_tag = Tag::parse(["h", channel_id]) + .map_err(|error| CliError::Other(format!("invalid git origin tag: {error}")))?; + return Ok(builder.tag(origin_tag)); + } + + if let Some(agent_name) = agent_name { + let agent_name = agent_name.trim(); + if agent_name.is_empty() + || agent_name.len() > 256 + || agent_name.chars().any(char::is_control) + { + return Err(CliError::Other( + "invalid private-conversation agent name".into(), + )); + } + let origin_tag = Tag::parse(["buzz-origin-agent", agent_name]) + .map_err(|error| CliError::Other(format!("invalid git origin tag: {error}")))?; + return Ok(builder.tag(origin_tag)); + } + + Ok(builder) +} /// Parse a relay write-response JSON blob, mapping a duplicate (dominated) /// write to [`CliError::Conflict`] with the caller-supplied message. @@ -46,3 +95,47 @@ pub fn parse_write_response(raw: &str, conflict_msg: &str) -> Result, agent_name: Option<&str>) -> nostr::Event { + apply_git_provenance( + EventBuilder::new(Kind::Custom(1621), "issue"), + channel_id, + agent_name, + ) + .expect("apply provenance") + .sign_with_keys(&Keys::generate()) + .expect("sign event") + } + + #[test] + fn public_channel_origin_uses_h_tag_and_suppresses_agent_name() { + let channel_id = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + let event = event_with_origin(Some(channel_id), Some("Builder")); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["h", channel_id])); + assert!(!event + .tags + .iter() + .any(|tag| tag.as_slice().first().map(String::as_str) == Some("buzz-origin-agent"))); + } + + #[test] + fn private_origin_exposes_only_agent_name() { + let event = event_with_origin(None, Some("Builder")); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["buzz-origin-agent", "Builder"])); + assert!(!event + .tags + .iter() + .any(|tag| tag.as_slice().first().map(String::as_str) == Some("h"))); + } +} diff --git a/crates/buzz-cli/src/commands/patches.rs b/crates/buzz-cli/src/commands/patches.rs index 13f1714d06..413934a3c1 100644 --- a/crates/buzz-cli/src/commands/patches.rs +++ b/crates/buzz-cli/src/commands/patches.rs @@ -1,4 +1,5 @@ use crate::client::BuzzClient; +use crate::commands::with_git_provenance; use crate::error::CliError; use crate::validate::{ read_file_or_stdin, read_or_stdin, sdk_err, validate_hex64, validate_repo_id, @@ -47,7 +48,8 @@ pub async fn cmd_send_patch( id: repo_id.to_string(), }; - let builder = buzz_sdk::build_git_patch(&repo, &content, &meta).map_err(sdk_err)?; + let builder = + with_git_provenance(buzz_sdk::build_git_patch(&repo, &content, &meta).map_err(sdk_err)?)?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -180,7 +182,8 @@ pub async fn cmd_patch_status( applied_as_commits: applied_as_commit.to_vec(), }; - let builder = buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?; + let builder = + with_git_provenance(buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?)?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); diff --git a/crates/buzz-cli/src/commands/pr.rs b/crates/buzz-cli/src/commands/pr.rs index 4272c2bfd8..2a689e75a5 100644 --- a/crates/buzz-cli/src/commands/pr.rs +++ b/crates/buzz-cli/src/commands/pr.rs @@ -1,4 +1,5 @@ use crate::client::BuzzClient; +use crate::commands::with_git_provenance; use crate::error::CliError; use crate::validate::{ read_file_or_stdin, read_or_stdin, sdk_err, validate_hex64, validate_repo_id, @@ -55,7 +56,9 @@ pub async fn cmd_open_pr( revision_of: revision_of.map(str::to_string), }; - let builder = buzz_sdk::build_git_pull_request(&repo, &content, &meta).map_err(sdk_err)?; + let builder = with_git_provenance( + buzz_sdk::build_git_pull_request(&repo, &content, &meta).map_err(sdk_err)?, + )?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -97,7 +100,9 @@ pub async fn cmd_update_pr( merge_base: merge_base.map(str::to_string), }; - let builder = buzz_sdk::build_git_pr_update(&repo, &content, &meta).map_err(sdk_err)?; + let builder = with_git_provenance( + buzz_sdk::build_git_pr_update(&repo, &content, &meta).map_err(sdk_err)?, + )?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -206,7 +211,8 @@ pub async fn cmd_pr_status( applied_as_commits: vec![], }; - let builder = buzz_sdk::build_git_status(status, &content, &meta).map_err(sdk_err)?; + let builder = + with_git_provenance(buzz_sdk::build_git_status(status, &content, &meta).map_err(sdk_err)?)?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 231691118f..f3c2936fe9 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -105,6 +105,7 @@ export default defineConfig({ "**/send-channel-binding.spec.ts", "**/project-commit-detail.spec.ts", "**/project-inbox.spec.ts", + "**/project-issue-comments.spec.ts", "**/project-pr-review.spec.ts", "**/persona-model-combobox-screenshots.spec.ts", "**/drafts-screenshots.spec.ts", diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index e06f176216..b90bf49b3b 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -40,7 +40,9 @@ pub(super) fn build_launch_block( effective_model: Option<&str>, owner_pubkey: &str, ) -> serde_json::Value { - use crate::managed_agents::{known_acp_runtime, resolve_session_title, SESSION_TITLE_ENV_VAR}; + use crate::managed_agents::{ + known_acp_runtime, resolve_session_title, DISPLAY_NAME_ENV_VAR, SESSION_TITLE_ENV_VAR, + }; let runtime = known_acp_runtime(&descriptor.command); let mut policy_env = BTreeMap::new(); @@ -73,7 +75,8 @@ pub(super) fn build_launch_block( policy_env.insert("BUZZ_ACP_MAX_TURN_DURATION".into(), value.to_string()); } if let Some(value) = resolve_session_title(record.display_name.as_deref(), &record.name) { - policy_env.insert(SESSION_TITLE_ENV_VAR.into(), value); + policy_env.insert(SESSION_TITLE_ENV_VAR.into(), value.clone()); + policy_env.insert(DISPLAY_NAME_ENV_VAR.into(), value); } if let Some(value) = crate::managed_agents::spawn_snapshot::effective_team_instructions(record, teams) @@ -250,6 +253,7 @@ mod tests { "Coordinate" ); assert_eq!(launch["policy_env"]["BUZZ_ACP_SESSION_TITLE"], "Agent Name"); + assert_eq!(launch["policy_env"]["BUZZ_ACP_DISPLAY_NAME"], "Agent Name"); assert_eq!(launch["policy_env"]["BUZZ_ACP_SYSTEM_PROMPT"], "prompt"); assert_eq!(launch["policy_env"]["BUZZ_ACP_MODEL"], "model"); assert_eq!(launch["policy_env"]["BUZZ_ACP_IDLE_TIMEOUT"], "17"); diff --git a/desktop/src-tauri/src/commands/project_git_exec.rs b/desktop/src-tauri/src/commands/project_git_exec.rs index e4a8ad7b41..c616d39db1 100644 --- a/desktop/src-tauri/src/commands/project_git_exec.rs +++ b/desktop/src-tauri/src/commands/project_git_exec.rs @@ -203,6 +203,22 @@ pub(crate) fn build_git_auth_config(state: &AppState) -> Result Result { + if validate_github_clone_url(clone_url).is_ok() { + return Ok(GitAuthConfig { + git_path: resolve_command("git") + .ok_or_else(|| "git was not found on PATH".to_string())?, + credential_helper: None, + nsec: String::new(), + allow_file_transport: false, + }); + } + build_git_auth_config(state) +} + 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"); @@ -288,6 +304,56 @@ pub(crate) fn validate_clone_url(clone_url: &str) -> Result<(), String> { Ok(()) } +fn validate_github_clone_url(clone_url: &str) -> Result<(), String> { + let parsed = Url::parse(clone_url).map_err(|error| format!("invalid clone URL: {error}"))?; + if parsed.scheme() != "https" + || parsed.host_str() != Some("github.com") + || parsed.port().is_some() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err("GitHub clone URL must use public https://github.com/owner/repository".into()); + } + let segments = parsed + .path_segments() + .map(|segments| { + segments + .filter(|segment| !segment.is_empty()) + .collect::>() + }) + .unwrap_or_default(); + let valid_segment = |segment: &&str| { + !segment.starts_with('-') + && !segment.contains("..") + && segment.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') + }) + }; + if segments.len() != 2 || !segments.iter().all(valid_segment) { + return Err("GitHub clone URL must name one owner and repository".into()); + } + Ok(()) +} + +pub(crate) fn validate_local_clone_url(clone_url: &str) -> Result<(), String> { + if validate_clone_url(clone_url).is_ok() || validate_github_clone_url(clone_url).is_ok() { + return Ok(()); + } + Err("clone URL must point at a Buzz repository or public GitHub repository".into()) +} + +pub(crate) fn validate_local_clone_url_for_workspace( + clone_url: &str, + state: &AppState, +) -> Result<(), String> { + if validate_github_clone_url(clone_url).is_ok() { + return Ok(()); + } + validate_workspace_clone_url(clone_url, state) +} + pub(crate) fn clone_url_owner(clone_url: &str) -> Option { let parsed = Url::parse(clone_url).ok()?; let segments = parsed @@ -329,6 +395,7 @@ mod tests { use super::{ clean_branch, clean_target_ref, credential_helper_config_value, git_needs_credentials, git_subcommand, validate_clone_url, validate_clone_url_against_relay, + validate_local_clone_url, }; #[test] @@ -441,4 +508,15 @@ mod tests { ) .is_err()); } + + #[test] + fn local_clone_url_allows_only_public_github_https_urls() { + assert!(validate_local_clone_url("https://github.com/block/buzz").is_ok()); + assert!(validate_local_clone_url("https://github.com/block/buzz.git").is_ok()); + assert!(validate_local_clone_url("http://github.com/block/buzz").is_err()); + assert!(validate_local_clone_url("https://github.com/block/buzz/issues").is_err()); + assert!(validate_local_clone_url("https://user@github.com/block/buzz").is_err()); + assert!(validate_local_clone_url("https://github.com.evil.test/block/buzz").is_err()); + assert!(validate_local_clone_url("https://gitlab.com/block/buzz").is_err()); + } } diff --git a/desktop/src-tauri/src/commands/project_git_workflow.rs b/desktop/src-tauri/src/commands/project_git_workflow.rs index 624bbf4dfc..9e06852762 100644 --- a/desktop/src-tauri/src/commands/project_git_workflow.rs +++ b/desktop/src-tauri/src/commands/project_git_workflow.rs @@ -3,8 +3,9 @@ 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, + build_git_auth_config_for_keys, build_git_clone_auth_config, clone_url_owner, run_git, + validate_local_clone_url, validate_local_clone_url_for_workspace, validate_workspace_clone_url, + GitAuthConfig, }; use super::project_repo_paths::{ canonical_repos_roots, canonicalize_repos_root, default_repos_root_candidates, @@ -353,7 +354,7 @@ pub(crate) fn clone_project_repository_blocking( default_branch: Option<&str>, auth: &GitAuthConfig, ) -> Result { - validate_clone_url(clone_url)?; + validate_local_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 { @@ -411,8 +412,8 @@ pub async fn clone_project_repository( default_branch: Option, state: State<'_, AppState>, ) -> Result { - validate_workspace_clone_url(&clone_url, &state)?; - let auth = build_git_auth_config(&state)?; + validate_local_clone_url_for_workspace(&clone_url, &state)?; + let auth = build_git_clone_auth_config(&clone_url, &state)?; tauri::async_runtime::spawn_blocking(move || { clone_project_repository_blocking( repos_dir.as_deref(), diff --git a/desktop/src-tauri/src/commands/project_terminal.rs b/desktop/src-tauri/src/commands/project_terminal.rs index 31dbc74c6d..c583dd0db5 100644 --- a/desktop/src-tauri/src/commands/project_terminal.rs +++ b/desktop/src-tauri/src/commands/project_terminal.rs @@ -9,7 +9,10 @@ use crate::app_state::AppState; use super::project_git::{first_output_line, normalize_branch_option}; use super::project_git_diff::clean_commit; -use super::project_git_exec::{build_git_auth_config, run_git, validate_workspace_clone_url}; +use super::project_git_exec::{ + build_git_auth_config, build_git_clone_auth_config, run_git, + validate_local_clone_url_for_workspace, validate_workspace_clone_url, +}; use super::project_git_workflow::clone_project_repository_blocking; use super::project_repo_paths::find_local_repo_dir; @@ -99,9 +102,8 @@ fn launch_terminal_at(path: &std::path::Path) -> Result<(), String> { } /// 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, -/// then opens the terminal at the fresh checkout. +/// local checkout yet, clones the repository from `clone_url` into the repos +/// dir first, then opens the terminal at the fresh checkout. #[tauri::command] pub async fn open_project_terminal( repos_dir: Option, @@ -111,11 +113,16 @@ pub async fn open_project_terminal( state: State<'_, AppState>, ) -> Result { if let Some(clone_url) = clone_url.as_deref() { - validate_workspace_clone_url(clone_url, &state)?; + validate_local_clone_url_for_workspace(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); + // Public GitHub clones stay anonymous; Buzz remotes use the workspace + // identity. Keep the result outside the blocking task so it borrows no + // Tauri state. + let auth = if let Some(clone_url) = clone_url.as_deref() { + build_git_clone_auth_config(clone_url, &state) + } else { + build_git_auth_config(&state) + }; 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 diff --git a/desktop/src-tauri/src/managed_agents/env_vars.rs b/desktop/src-tauri/src/managed_agents/env_vars.rs index 07705ee998..9956f19b29 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -77,6 +77,9 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ "BUZZ_ACP_RESPOND_TO", "BUZZ_ACP_RESPOND_TO_ALLOWLIST", "BUZZ_ACP_AGENT_OWNER", + // Stable agent identity used for git attribution and private-conversation + // provenance must come from the managed-agent record, not user overrides. + "BUZZ_ACP_DISPLAY_NAME", // Remote lifetime/presence policy: user env must not disable the // desktop/provider-owned bounds while the saved record still promises them. "BUZZ_ACP_EXIT_AFTER_INACTIVITY", diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 4041a4fd94..9fa9e0cce6 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -22,7 +22,8 @@ pub(crate) use path::should_use_inherited; mod metadata; pub(crate) use metadata::{ - resolve_session_title, runtime_metadata_env_vars, SESSION_TITLE_ENV_VAR, + apply_agent_display_env, resolve_session_title, runtime_metadata_env_vars, + DISPLAY_NAME_ENV_VAR, SESSION_TITLE_ENV_VAR, }; mod stop; @@ -763,11 +764,10 @@ pub fn spawn_agent_child( // is display metadata only. The spawn-config snapshot records the same // resolve, so a rename raises the restart badge instead of leaving the // process stale. - if let Some(title) = resolve_session_title(record.display_name.as_deref(), &record.name) { - command.env(SESSION_TITLE_ENV_VAR, title); - } else { - command.env_remove(SESSION_TITLE_ENV_VAR); - } + apply_agent_display_env( + &mut command, + resolve_session_title(record.display_name.as_deref(), &record.name), + ); build_buzz_agent_provider_defaults(&mut command); if let Some(meta) = runtime_meta { for (key, value) in runtime_metadata_env_vars( diff --git a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs index 26d210e5c6..5aef424ea6 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs @@ -27,6 +27,23 @@ pub(crate) fn runtime_metadata_env_vars<'a>( /// Env var carrying the session title to the harness. Shared with /// `spawn_snapshot` so the restart badge records the same key the spawn writes. pub(crate) const SESSION_TITLE_ENV_VAR: &str = "BUZZ_ACP_SESSION_TITLE"; +/// Stable agent display name forwarded to the ACP tool surface for git +/// attribution and private-conversation provenance. +pub(crate) const DISPLAY_NAME_ENV_VAR: &str = "BUZZ_ACP_DISPLAY_NAME"; + +/// Apply the shared stable agent name to both session display metadata and +/// git attribution, clearing both keys when no usable name is available. +pub(crate) fn apply_agent_display_env(command: &mut std::process::Command, title: Option) { + if let Some(title) = title { + command + .env(SESSION_TITLE_ENV_VAR, &title) + .env(DISPLAY_NAME_ENV_VAR, title); + } else { + command + .env_remove(SESSION_TITLE_ENV_VAR) + .env_remove(DISPLAY_NAME_ENV_VAR); + } +} /// Resolve the session title for an agent: its `display_name` when it has one, /// otherwise its unique `name` handle. `None` when both are blank, so the diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 5afd0e7e4b..4c7382a306 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -109,6 +109,7 @@ export function useAppNavigation() { commitHash?: string; pullRequestId?: string; issueId?: string; + repositoryId?: string; }, ) => commitNavigation( @@ -125,6 +126,9 @@ export function useAppNavigation() { ? { pullRequestId: behavior.pullRequestId } : {}), ...(behavior?.issueId ? { issueId: behavior.issueId } : {}), + ...(behavior?.repositoryId + ? { repositoryId: behavior.repositoryId } + : {}), }, }, behavior, diff --git a/desktop/src/app/routes/projects.$projectId.tsx b/desktop/src/app/routes/projects.$projectId.tsx index 3ce58efa8c..4954428748 100644 --- a/desktop/src/app/routes/projects.$projectId.tsx +++ b/desktop/src/app/routes/projects.$projectId.tsx @@ -19,13 +19,16 @@ export const Route = createFileRoute("/projects/$projectId")({ ? search.pullRequestId : undefined, issueId: typeof search.issueId === "string" ? search.issueId : undefined, + repositoryId: + typeof search.repositoryId === "string" ? search.repositoryId : undefined, }), }); function ProjectDetailRouteComponent() { usePreviewFeatureWarning("projects"); const { projectId } = Route.useParams(); - const { commitHash, pullRequestId, issueId } = Route.useSearch(); + const { commitHash, pullRequestId, issueId, repositoryId } = + Route.useSearch(); return ( }> @@ -34,6 +37,7 @@ function ProjectDetailRouteComponent() { issueId={issueId} projectId={projectId} pullRequestId={pullRequestId} + repositoryId={repositoryId} /> ); diff --git a/desktop/src/features/home/lib/projectInbox.test.mjs b/desktop/src/features/home/lib/projectInbox.test.mjs index 040f54e847..e82fccb79e 100644 --- a/desktop/src/features/home/lib/projectInbox.test.mjs +++ b/desktop/src/features/home/lib/projectInbox.test.mjs @@ -34,13 +34,21 @@ function feedItem(overrides = {}) { }; } -const project = { +const repository = { id: "buzz", + dtag: "buzz", name: "Buzz", owner: OWNER, repoAddress: REPO_ADDRESS, }; +const project = { + id: "buzz-project", + name: "Buzz", + owner: OWNER, + repositories: [repository], +}; + const pullRequest = { id: PR_ID, author: OWNER, @@ -109,11 +117,11 @@ test("resolves the canonical project root from status and comment events", () => test("matches a selected inbox event to its canonical pull request or issue", () => { const workItems = { pullRequests: { - items: [{ project, pullRequest }], + items: [{ project, repository, pullRequest }], failedSections: [], }, issues: { - items: [{ project, issue }], + items: [{ project, repository, issue }], failedSections: [], }, }; @@ -121,6 +129,7 @@ test("matches a selected inbox event to its canonical pull request or issue", () assert.deepEqual(resolveProjectInboxWorkItem(feedItem(), workItems), { type: "pull-request", project, + repository, pullRequest, }); assert.deepEqual( @@ -139,6 +148,7 @@ test("matches a selected inbox event to its canonical pull request or issue", () { type: "issue", project, + repository, issue, }, ); diff --git a/desktop/src/features/home/lib/projectInbox.ts b/desktop/src/features/home/lib/projectInbox.ts index a22b355214..80602241da 100644 --- a/desktop/src/features/home/lib/projectInbox.ts +++ b/desktop/src/features/home/lib/projectInbox.ts @@ -2,6 +2,7 @@ import type { Project, ProjectIssue, ProjectPullRequest, + Repository, } from "@/features/projects/hooks"; import type { ProjectsWorkItemsResult } from "@/features/projects/projectWorkItems"; import type { FeedItem } from "@/shared/api/types"; @@ -31,11 +32,13 @@ export type ProjectInboxWorkItem = | { type: "pull-request"; project: Project; + repository: Repository; pullRequest: ProjectPullRequest; } | { type: "issue"; project: Project; + repository: Repository; issue: ProjectIssue; }; @@ -82,8 +85,8 @@ export function resolveProjectInboxWorkItem( } const pullRequestEntry = workItems.pullRequests.items.find( - ({ project, pullRequest }) => - project.repoAddress === reference.repoAddress && + ({ repository, pullRequest }) => + repository.repoAddress === reference.repoAddress && pullRequest.id === reference.rootId, ); if (pullRequestEntry) { @@ -91,8 +94,8 @@ export function resolveProjectInboxWorkItem( } const issueEntry = workItems.issues.items.find( - ({ issue, project }) => - project.repoAddress === reference.repoAddress && + ({ issue, repository }) => + repository.repoAddress === reference.repoAddress && issue.id === reference.rootId, ); return issueEntry ? { type: "issue", ...issueEntry } : null; diff --git a/desktop/src/features/home/ui/ProjectInboxDetail.tsx b/desktop/src/features/home/ui/ProjectInboxDetail.tsx index c3d17e687e..19dda5889c 100644 --- a/desktop/src/features/home/ui/ProjectInboxDetail.tsx +++ b/desktop/src/features/home/ui/ProjectInboxDetail.tsx @@ -120,7 +120,10 @@ export function ProjectInboxDetail({ workItem.type === "pull-request" ? { pullRequestId: workItem.pullRequest.id } : { issueId: workItem.issue.id }; - void goProject(workItem.project.id, workItemId); + void goProject(workItem.project.id, { + ...workItemId, + repositoryId: workItem.repository.id, + }); }} profiles={profiles} workItem={workItem} diff --git a/desktop/src/features/home/ui/ProjectInboxDetailPane.tsx b/desktop/src/features/home/ui/ProjectInboxDetailPane.tsx index 5b80c13cb9..d31ac95274 100644 --- a/desktop/src/features/home/ui/ProjectInboxDetailPane.tsx +++ b/desktop/src/features/home/ui/ProjectInboxDetailPane.tsx @@ -61,13 +61,13 @@ export function ProjectInboxDetailPane({ if (workItem.type !== "pull-request") { throw new Error("Merge recovery is only available for pull requests."); } - const targetCloneUrl = workItem.project.cloneUrls[0]; + const targetCloneUrl = workItem.repository.cloneUrls[0]; if (!targetCloneUrl) { - throw new Error("This project has no clone URL."); + throw new Error("This repository has no clone URL."); } return openProjectMergeRecoveryTerminal({ ...input, - projectDtag: workItem.project.dtag, + projectDtag: workItem.repository.dtag, reposDir: activeCommunity?.reposDir, targetCloneUrl, }); @@ -151,13 +151,13 @@ export function ProjectInboxDetailPane({ mode="conversation" onOpenTerminal={handleOpenMergeRecoveryTerminal} profiles={profiles} - project={workItem.project} + project={workItem.repository} pullRequest={workItem.pullRequest} /> @@ -166,7 +166,7 @@ export function ProjectInboxDetailPane({ )} diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index d3e0e34dc6..06295cc615 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -60,6 +60,8 @@ type UserProfilePopoverProps = { triggerAriaLabel?: string; /** Set false when the trigger is inside another interactive control. */ enableProfilePanel?: boolean; + /** Set false when a smaller, context-specific hover treatment is provided. */ + enableHoverPopover?: boolean; /** When set to "bot", a BotIdenticon badge renders next to the display name. */ role?: string; /** Value used to generate the BotIdenticon glyph (typically the author name). */ @@ -174,6 +176,7 @@ export function UserProfilePopover({ triggerElement = "div", triggerAriaLabel, enableProfilePanel = true, + enableHoverPopover = true, role, botIdenticonValue, }: UserProfilePopoverProps) { @@ -298,11 +301,14 @@ export function UserProfilePopover({ }, []); const handleTriggerMouseEnter = React.useCallback(() => { + if (!enableHoverPopover) { + return; + } clearHoverTimer(); hoverTimerRef.current = setTimeout(() => { setOpen(true); }, HOVER_OPEN_DELAY_MS); - }, [clearHoverTimer]); + }, [clearHoverTimer, enableHoverPopover]); const handleMouseLeave = React.useCallback(() => { clearHoverTimer(); diff --git a/desktop/src/features/projects/branchMutations.ts b/desktop/src/features/projects/branchMutations.ts index 55874dc776..647e6fac2b 100644 --- a/desktop/src/features/projects/branchMutations.ts +++ b/desktop/src/features/projects/branchMutations.ts @@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import * as React from "react"; import { toast } from "sonner"; -import type { Project } from "@/features/projects/hooks"; +import type { Repository as Project } from "@/features/projects/hooks"; import { createProjectRemoteBranch, deleteProjectRemoteBranch, diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index a51191d479..f6e5d2f1b0 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -23,7 +23,6 @@ import { KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, - KIND_REPO_ANNOUNCEMENT, KIND_REPO_STATE, KIND_TEXT_NOTE, } from "@/shared/constants/kinds"; @@ -39,10 +38,11 @@ import type { RelayEvent, } from "@/shared/api/types"; import { summarizeProjectActivityEvents } from "./projectActivity.mjs"; -import { resolveProjectDefaultBranch } from "./lib/projectBranches"; -import { effectiveCloneUrls } from "./lib/projectCloneUrl"; import type { ProjectIssue } from "./projectIssues.mjs"; -import { projectIssueEventsToIssues } from "./projectIssues.mjs"; +import { + nextProjectIssueCommentCreatedAt, + projectIssueEventsToIssues, +} from "./projectIssues.mjs"; import type { ProjectPullRequest, ProjectPullRequestCommentAnchor, @@ -55,33 +55,29 @@ import { projectPullRequestEventsToPullRequests, } from "./projectPullRequests.mjs"; import { fetchProjectsWorkItems } from "./projectWorkItems"; +import { + eventToRepository, + type Project, + type Repository, +} from "./projectModels"; +import { + buildProjectsFromFetcher, + fetchProjectEventsExhaustively, +} from "./projectEnumeration"; +import { projectMatchesRouteId } from "./projectRoutes"; export type { + Project, ProjectIssue, ProjectPullRequest, ProjectPullRequestCommentAnchor, + Repository, }; export type ProjectPullRequestCommentDecision = "request-changes"; const HIDDEN_PROJECT_CARDS_KEY = "buzz.projects.hidden-cards.v1"; -export type Project = { - id: string; - dtag: string; - name: string; - description: string; - cloneUrls: string[]; - webUrl: string | null; - owner: string; - contributors: string[]; - createdAt: number; - projectChannelId: string | null; - status: string; - defaultBranch: string; - repoAddress: string; -}; - export type RepoState = { branches: Array<{ name: string; commit: string }>; tags: Array<{ name: string; commit: string }>; @@ -120,34 +116,16 @@ export type { export type ProjectPullRequestListItem = { project: Project; + repository: Repository; pullRequest: ProjectPullRequest; }; export type ProjectIssueListItem = { project: Project; + repository: Repository; issue: ProjectIssue; }; -function getTag(event: RelayEvent, name: string): string | undefined { - const value = event.tags.find((t) => t[0] === name)?.[1]; - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -function getAllTags(event: RelayEvent, name: string): string[] { - return event.tags - .filter((t) => t[0] === name && typeof t[1] === "string" && t[1].length > 0) - .map((t) => t[1]); -} - -function getCloneUrls(event: RelayEvent): string[] { - const tag = event.tags.find((t) => t[0] === "clone"); - return tag ? tag.slice(1) : []; -} - -function projectCoordinate(project: Pick): string { - return `${KIND_REPO_ANNOUNCEMENT}:${project.owner}:${project.dtag}`; -} - function readHiddenProjectCards(): string[] { if (typeof window === "undefined") { return []; @@ -165,21 +143,6 @@ function readHiddenProjectCards(): string[] { } } -function isHiddenLocally(project: Project): boolean { - return readHiddenProjectCards().includes(projectCoordinate(project)); -} - -function isDeletedByA(project: Project, deletionEvents: RelayEvent[]): boolean { - const coordinate = projectCoordinate(project); - // NIP-09: a deletion is only valid when signed by the author of the - // referenced event — otherwise anyone could hide someone else's project. - return deletionEvents.some( - (event) => - event.pubkey.toLowerCase() === project.owner.toLowerCase() && - event.tags.some((tag) => tag[0] === "a" && tag[1] === coordinate), - ); -} - /** * Converts a kind:30617 repo announcement into a `Project`. * @@ -191,136 +154,27 @@ function isDeletedByA(project: Project, deletionEvents: RelayEvent[]): boolean { export function eventToProject( event: RelayEvent, relayOrigin?: string | null, -): Project { - const d = getTag(event, "d") ?? event.id; - const name = getTag(event, "name") || d; - const description = getTag(event, "description") || event.content || ""; - const cloneUrls = effectiveCloneUrls( - getCloneUrls(event), - relayOrigin, - event.pubkey, - d, - ); - const webUrl = getTag(event, "web") ?? null; - const setupUsers = getAllTags(event, "auth"); - const contributors = [...new Set([...getAllTags(event, "p"), ...setupUsers])]; - // `h`/`project-channel`, `status`, and `default-branch` are NOT part of - // NIP-34 — they are read-side tolerance for extension tags no code writes - // today (the write path that emitted them was removed). If a write path is - // reintroduced it must go through the buzz-sdk repo-announcement builder; - // the canonical NIP-34 source for the default branch is the kind:30618 - // state event's HEAD ref, not a 30617 tag. - const projectChannelId = - getTag(event, "h") ?? getTag(event, "project-channel") ?? null; - - return { - id: `${event.pubkey}:${d}`, - dtag: d, - name, - description, - cloneUrls, - webUrl, - owner: event.pubkey, - contributors, - createdAt: event.created_at, - projectChannelId, - status: getTag(event, "status") ?? "active", - defaultBranch: getTag(event, "default-branch") ?? "main", - repoAddress: projectCoordinate({ owner: event.pubkey, dtag: d }), - }; -} - -function dedup(events: RelayEvent[]): RelayEvent[] { - const best = new Map(); - - for (const e of events) { - const d = getTag(e, "d") ?? ""; - const key = `${e.pubkey}:${e.kind}:${d}`; - const prev = best.get(key); - - if (!prev || e.created_at > prev.created_at) { - best.set(key, e); - } - } - - return [...best.values()]; -} - -export async function fetchProjects(): Promise { - const [events, deletionEvents] = await Promise.all([ - relayClient.fetchEvents({ - kinds: [KIND_REPO_ANNOUNCEMENT], - limit: 200, - }), - relayClient.fetchEvents({ - kinds: [KIND_DELETION], - limit: 500, - }), - ]); - - return dedup(events) - .map((event) => eventToProject(event, getCachedRelayOrigin())) - .filter( - (project) => - !isHiddenLocally(project) && !isDeletedByA(project, deletionEvents), - ) - .sort((a, b) => b.createdAt - a.createdAt); -} - -/** - * Splits a project route ID into its owner pubkey and dtag. The canonical - * form is `:` (matching `Project.id`) — NIP-34 repo - * identity is the full `30617::` coordinate, and two owners can - * both publish the same dtag (forks). Bare-dtag IDs from legacy links are - * still resolved, ambiguously, to whichever owner the relay returns first. - */ -function parseProjectRouteId(projectId: string): { - owner: string | null; - dtag: string; -} { - const owner = projectId.slice(0, 64); - if (projectId[64] === ":" && /^[0-9a-fA-F]{64}$/.test(owner)) { - return { owner: owner.toLowerCase(), dtag: projectId.slice(65) }; +): Repository { + const repository = eventToRepository(event, relayOrigin); + if (!repository) { + throw new Error("Invalid repository announcement."); } - return { owner: null, dtag: projectId }; -} - -async function fetchProject(projectId: string): Promise { - const { owner, dtag } = parseProjectRouteId(projectId); - const events = await relayClient.fetchEvents({ - kinds: [KIND_REPO_ANNOUNCEMENT], - ...(owner ? { authors: [owner] } : {}), - "#d": [dtag], - limit: 10, + return repository; +} + +export async function fetchProjects( + fetchExhaustively: ( + kinds: number[], + ) => Promise = fetchProjectEventsExhaustively, +): Promise { + // Delegates to `buildProjectsFromFetcher` in `projectEnumeration.ts`, which + // is the pure, Tauri-free core of this operation. That helper's javadoc + // explains the fail-closed tombstone contract and the NIP-OA owner-deletion + // relay-side-suppression decision. + return buildProjectsFromFetcher(fetchExhaustively, { + relayOrigin: getCachedRelayOrigin(), + hiddenAddresses: new Set(readHiddenProjectCards()), }); - - const deduped = dedup(events).filter( - (event) => !owner || event.pubkey.toLowerCase() === owner, - ); - const project = - deduped.length > 0 - ? eventToProject(deduped[0], getCachedRelayOrigin()) - : null; - if (!project) { - return null; - } - - const deletionEvents = await relayClient.fetchEvents({ - kinds: [KIND_DELETION], - authors: [project.owner], - "#a": [project.repoAddress], - limit: 10, - }); - - if (isDeletedByA(project, deletionEvents)) return null; - const repoState = await fetchRepoState(project); - return { - ...project, - defaultBranch: resolveProjectDefaultBranch( - project.defaultBranch, - repoState, - ), - }; } function eventToRepoState(event: RelayEvent): RepoState { @@ -349,7 +203,7 @@ function eventToRepoState(event: RelayEvent): RepoState { }; } -async function fetchRepoState(project: Project): Promise { +async function fetchRepoState(project: Repository): Promise { const relaySelf = await getRelaySelf(); const trustedAuthors = [ ...new Set( @@ -368,7 +222,9 @@ async function fetchRepoState(project: Project): Promise { return events.length > 0 ? eventToRepoState(events[0]) : null; } -async function fetchProjectIssues(project: Project): Promise { +async function fetchProjectIssues( + project: Repository, +): Promise { const [issueEvents, statusEvents, commentEvents] = await Promise.all([ relayClient.fetchEvents({ kinds: [KIND_GIT_ISSUE], @@ -396,7 +252,7 @@ async function fetchProjectIssues(project: Project): Promise { } async function fetchProjectPullRequests( - project: Project, + project: Repository, ): Promise { const [pullRequestEvents, updateEvents, commentEvents, statusEvents] = await Promise.all([ @@ -454,7 +310,7 @@ async function createProjectPullRequestComment({ decision?: ProjectPullRequestCommentDecision; mediaTags?: string[][]; mentionPubkeys?: string[]; - project: Project; + project: Repository; pullRequest: ProjectPullRequest; }): Promise { const body = content.trim(); @@ -531,7 +387,7 @@ async function createProjectIssueComment({ mediaTags?: string[][]; mentionPubkeys?: string[]; issue: ProjectIssue; - project: Project; + project: Repository; }): Promise { const body = content.trim(); if (!body) { @@ -550,10 +406,16 @@ async function createProjectIssueComment({ ...[...recipients].map((recipient) => ["p", recipient]), ...(mediaTags ?? []), ]; + const identity = await getIdentity(); const event = await signRelayEvent({ kind: KIND_TEXT_NOTE, content: body, + createdAt: nextProjectIssueCommentCreatedAt( + issue, + Math.floor(Date.now() / 1_000), + identity.pubkey, + ), tags, }); @@ -565,7 +427,7 @@ async function createProjectIssueComment({ } async function fetchProjectRepoSnapshot( - project: Project, + project: Repository, branchName?: string | null, pullRequest?: ProjectPullRequest | null, tag?: { name: string; commit: string } | null, @@ -587,7 +449,7 @@ async function fetchProjectRepoSnapshot( } async function fetchProjectRepoDiff( - project: Project, + project: Repository, branchName?: string | null, pullRequest?: ProjectPullRequest | null, ): Promise { @@ -604,7 +466,7 @@ async function fetchProjectRepoDiff( } async function fetchProjectLocalRepoDiff( - project: Project, + project: Repository, reposDir?: string | null, branchName?: string | null, pullRequest?: ProjectPullRequest | null, @@ -625,7 +487,7 @@ async function fetchProjectLocalRepoDiff( } async function fetchProjectLocalRepoSnapshot( - project: Project, + project: Repository, reposDir?: string | null, branchName?: string | null, ): Promise { @@ -638,11 +500,11 @@ async function fetchProjectLocalRepoSnapshot( }); } -async function fetchProjectActivitySummaries( - projects: Project[], +/** Loads commit, pull-request, and issue activity keyed by repository address. */ +export async function fetchRepositoryActivitySummaries( + repositories: Repository[], ): Promise> { - if (projects.length === 0) return {}; - + if (repositories.length === 0) return {}; const events = await relayClient.fetchEvents({ kinds: [ KIND_GIT_ISSUE, @@ -654,26 +516,100 @@ async function fetchProjectActivitySummaries( KIND_GIT_PULL_REQUEST, KIND_GIT_PR_UPDATE, ], - "#a": projects.map((project) => project.repoAddress), + "#a": repositories.map((repository) => repository.repoAddress), limit: 1_000, }); - return summarizeProjectActivityEvents(events, projects) as Record< + return summarizeProjectActivityEvents(events, repositories) as Record< string, ProjectActivitySummary >; } +async function fetchProjectActivitySummaries( + projects: Project[], +): Promise> { + if (projects.length === 0) return {}; + + const repositories = [ + ...new Map( + projects + .flatMap((project) => project.repositories) + .map((repository) => [repository.repoAddress, repository]), + ).values(), + ]; + const summariesByRepository = + await fetchRepositoryActivitySummaries(repositories); + return Object.fromEntries( + projects.map((project) => { + const summaries = project.repositories.map( + (repository) => summariesByRepository[repository.repoAddress], + ); + const latestCommit = + summaries + .map((summary) => summary?.latestCommit) + .filter( + ( + commit, + ): commit is NonNullable => + Boolean(commit), + ) + .sort((left, right) => right.createdAt - left.createdAt)[0] ?? null; + const activityByDay: Record = {}; + for (const summary of summaries) { + for (const [day, count] of Object.entries( + summary?.activityByDay ?? {}, + )) { + activityByDay[day] = (activityByDay[day] ?? 0) + count; + } + } + return [ + project.id, + { + repoAddress: project.projectAddress, + issueCount: summaries.reduce( + (count, summary) => count + (summary?.issueCount ?? 0), + 0, + ), + prCount: summaries.reduce( + (count, summary) => count + (summary?.prCount ?? 0), + 0, + ), + commitCount: summaries.reduce( + (count, summary) => count + (summary?.commitCount ?? 0), + 0, + ), + activityCount: summaries.reduce( + (count, summary) => count + (summary?.activityCount ?? 0), + 0, + ), + updatedAt: Math.max( + 0, + ...summaries.map((summary) => summary?.updatedAt ?? 0), + ), + participantPubkeys: [ + ...new Set( + summaries.flatMap((summary) => summary?.participantPubkeys ?? []), + ), + ], + latestCommit, + activityByDay, + } satisfies ProjectActivitySummary, + ]; + }), + ); +} + async function deleteProject(project: Project): Promise { const identity = await getIdentity(); if (identity.pubkey.toLowerCase() !== project.owner.toLowerCase()) { - throw new Error("Only branch owners can delete branches."); + throw new Error("Only the project owner can delete this project."); } const event = await signRelayEvent({ kind: KIND_DELETION, content: `Delete project ${project.name}`, - tags: [["a", project.repoAddress]], + tags: [["a", project.projectAddress]], }); await relayClient.publishEvent( @@ -688,20 +624,23 @@ export const projectsQueryKey = ["projects"] as const; export function useProjectsQuery() { return useQuery({ queryKey: projectsQueryKey, - queryFn: fetchProjects, + queryFn: () => fetchProjects(), staleTime: 60_000, }); } export function useProjectQuery(projectId: string) { return useQuery({ - queryKey: ["project", projectId], - queryFn: () => fetchProject(projectId), + queryKey: projectsQueryKey, + queryFn: () => fetchProjects(), + select: (projects) => + projects.find((project) => projectMatchesRouteId(project, projectId)) ?? + null, staleTime: 60_000, }); } -export function useRepoStateQuery(project: Project | null | undefined) { +export function useRepoStateQuery(project: Repository | null | undefined) { return useQuery({ enabled: Boolean(project), queryKey: ["project", project?.id ?? "none", "repo-state"], @@ -714,15 +653,16 @@ export function useRepoStateQuery(project: Project | null | undefined) { } export function useProjectRepoSnapshotQuery( - project: Project | null | undefined, + project: Repository | null | undefined, branchName?: string | null, pullRequest?: ProjectPullRequest | null, tag?: { name: string; commit: string } | null, + enabled = true, ) { const selectedBranch = branchName ?? project?.defaultBranch ?? null; return useQuery({ - enabled: Boolean(project?.cloneUrls[0]), + enabled: Boolean(enabled && project?.cloneUrls[0]), queryKey: [ "project", project?.id ?? "none", @@ -748,7 +688,7 @@ export function useProjectRepoSnapshotQuery( } export function useProjectRepoDiffQuery( - project: Project | null | undefined, + project: Repository | null | undefined, branchName?: string | null, pullRequest?: ProjectPullRequest | null, enabled = true, @@ -775,7 +715,7 @@ export function useProjectRepoDiffQuery( } export function useProjectLocalRepoDiffQuery( - project: Project | null | undefined, + project: Repository | null | undefined, reposDir?: string | null, branchName?: string | null, pullRequest?: ProjectPullRequest | null, @@ -809,7 +749,7 @@ export function useProjectLocalRepoDiffQuery( } export function useProjectLocalRepoSnapshotQuery( - project: Project | null | undefined, + project: Repository | null | undefined, reposDir?: string | null, branchName?: string | null, ) { @@ -842,7 +782,7 @@ export function useProjectLocalRepositoriesQuery(reposDir?: string | null) { }); } -export function useProjectIssuesQuery(project: Project | null | undefined) { +export function useProjectIssuesQuery(project: Repository | null | undefined) { return useQuery({ enabled: Boolean(project), queryKey: ["project", project?.id ?? "none", "issues"], @@ -855,7 +795,7 @@ export function useProjectIssuesQuery(project: Project | null | undefined) { } export function useProjectPullRequestsQuery( - project: Project | null | undefined, + project: Repository | null | undefined, ) { return useQuery({ enabled: Boolean(project), @@ -879,7 +819,7 @@ export function useProjectsWorkItemsQuery(projects: Project[]) { } export function useCreateProjectIssueCommentMutation( - project: Project | null | undefined, + project: Repository | null | undefined, ) { const queryClient = useQueryClient(); @@ -919,7 +859,7 @@ export function useCreateProjectIssueCommentMutation( } export function useCreateProjectPullRequestCommentMutation( - project: Project | null | undefined, + project: Repository | null | undefined, ) { const queryClient = useQueryClient(); @@ -966,7 +906,12 @@ export function useCreateProjectPullRequestCommentMutation( export function useProjectActivitySummariesQuery(projects: Project[]) { const repoAddresses = React.useMemo( - () => projects.map((project) => project.repoAddress).sort(), + () => + projects + .flatMap((project) => + project.repositories.map((repository) => repository.repoAddress), + ) + .sort(), [projects], ); @@ -987,11 +932,7 @@ export function useDeleteProjectMutation() { queryClient.setQueryData(projectsQueryKey, (current = []) => current.filter((item) => item.id !== project.id), ); - queryClient.setQueryData(["project", project.id], null); void queryClient.invalidateQueries({ queryKey: projectsQueryKey }); - void queryClient.invalidateQueries({ - queryKey: ["project", project.id], - }); }, }); } diff --git a/desktop/src/features/projects/issueMutations.ts b/desktop/src/features/projects/issueMutations.ts index 0d18e47226..57834f4401 100644 --- a/desktop/src/features/projects/issueMutations.ts +++ b/desktop/src/features/projects/issueMutations.ts @@ -3,7 +3,7 @@ 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 type { Repository as Project } from "./hooks"; import { buildGitIssueTags } from "./projectIssues.mjs"; type CreateProjectIssueInput = { diff --git a/desktop/src/features/projects/lib/projectCloneUrl.test.mjs b/desktop/src/features/projects/lib/projectCloneUrl.test.mjs index 6179c46a04..cc00d5f631 100644 --- a/desktop/src/features/projects/lib/projectCloneUrl.test.mjs +++ b/desktop/src/features/projects/lib/projectCloneUrl.test.mjs @@ -2,6 +2,10 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { deriveRelayCloneUrl, effectiveCloneUrls } from "./projectCloneUrl.ts"; +import { + projectRepoHost, + projectRepoHostForProject, +} from "./projectRepoHost.ts"; const OWNER = "a".repeat(64); const ORIGIN = "https://relay.example"; @@ -60,3 +64,43 @@ test("effectiveCloneUrls derives a default when none is advertised", () => { test("effectiveCloneUrls returns empty when no default can be derived", () => { assert.deepEqual(effectiveCloneUrls([], null, OWNER, "repo"), []); }); + +test("projectRepoHost recognizes a canonical repository on the relay", () => { + assert.deepEqual(projectRepoHost(`${ORIGIN}/git/${OWNER}/buzz`, ORIGIN), { + kind: "buzz", + }); +}); + +test("projectRepoHost identifies an external repository by host", () => { + assert.deepEqual( + projectRepoHost("https://github.com/block/buzz.git", ORIGIN), + { kind: "external", host: "github.com" }, + ); +}); + +test("projectRepoHost treats a non-repository relay path as external", () => { + assert.deepEqual(projectRepoHost(`${ORIGIN}/other/path`, ORIGIN), { + kind: "external", + host: "relay.example", + }); +}); + +test("projectRepoHost fails closed while either URL is unresolved", () => { + assert.deepEqual(projectRepoHost(null, ORIGIN), { kind: "unresolved" }); + assert.deepEqual(projectRepoHost(`${ORIGIN}/git/${OWNER}/buzz`, null), { + kind: "unresolved", + }); + assert.deepEqual(projectRepoHost("not a URL", ORIGIN), { + kind: "unresolved", + }); +}); + +test("projectRepoHostForProject recognizes an implicit relay repository", () => { + assert.deepEqual( + projectRepoHostForProject( + { cloneUrls: [], dtag: "buzz", owner: OWNER }, + ORIGIN, + ), + { kind: "buzz" }, + ); +}); diff --git a/desktop/src/features/projects/lib/projectGitError.test.mjs b/desktop/src/features/projects/lib/projectGitError.test.mjs new file mode 100644 index 0000000000..cc691bb0d7 --- /dev/null +++ b/desktop/src/features/projects/lib/projectGitError.test.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { projectCloneErrorPresentation } from "./projectGitError.ts"; + +test("explains unsupported authenticated GitHub clones without exposing git output", () => { + assert.deepEqual( + projectCloneErrorPresentation( + new Error( + "Cloning into '/Users/person/repos/app'... remote: repository requires SSH certificate authentication. fatal: requested URL returned error: 403", + ), + "https://github.com/example/app.git", + ), + { + title: "Repository access required", + description: + "This repository requires GitHub authentication. Buzz currently clones public GitHub repositories without credentials.", + }, + ); +}); + +test("presents missing and network failures clearly", () => { + assert.equal( + projectCloneErrorPresentation(new Error("Repository not found")).title, + "Repository not found", + ); + assert.equal( + projectCloneErrorPresentation(new Error("Could not resolve host")).title, + "Couldn’t reach the repository", + ); +}); + +test("uses a concise fallback", () => { + assert.deepEqual(projectCloneErrorPresentation(new Error("git failed")), { + title: "Couldn’t clone repository", + description: + "Try again. If the problem continues, contact the repository owner.", + }); +}); diff --git a/desktop/src/features/projects/lib/projectGitError.ts b/desktop/src/features/projects/lib/projectGitError.ts new file mode 100644 index 0000000000..b99933f1e7 --- /dev/null +++ b/desktop/src/features/projects/lib/projectGitError.ts @@ -0,0 +1,72 @@ +export type ProjectGitErrorPresentation = { + title: string; + description: string; +}; + +function errorText(error: unknown) { + if (error instanceof Error) return error.message.toLowerCase(); + return typeof error === "string" ? error.toLowerCase() : ""; +} + +function isGitHubUrl(cloneUrl: string | null | undefined) { + try { + return new URL(cloneUrl ?? "").hostname.toLowerCase() === "github.com"; + } catch { + return false; + } +} + +export function projectCloneErrorPresentation( + error: unknown, + cloneUrl?: string | null, +): ProjectGitErrorPresentation { + const message = errorText(error); + const github = isGitHubUrl(cloneUrl); + + if ( + /\b(?:401|403)\b|authenticat|authoriz|permission denied|access denied|ssh certificate/.test( + message, + ) + ) { + return { + title: "Repository access required", + description: github + ? "This repository requires GitHub authentication. Buzz currently clones public GitHub repositories without credentials." + : "Buzz could not authenticate with this repository. Check your access and try again.", + }; + } + if (/\b404\b|repository not found|repository does not exist/.test(message)) { + return { + title: "Repository not found", + description: + "Check that the repository link is correct and that the repository still exists.", + }; + } + if ( + /timed? out|could not resolve host|failed to connect|connection (?:refused|reset)|network is unreachable|offline/.test( + message, + ) + ) { + return { + title: "Couldn’t reach the repository", + description: "Check your connection and try cloning again.", + }; + } + if ( + /already exists and is not an empty directory|destination path .* exists/.test( + message, + ) + ) { + return { + title: "Local folder already exists", + description: + "Choose a different repositories directory or remove the existing checkout.", + }; + } + return { + title: "Couldn’t clone repository", + description: github + ? "Try again, or open the repository on GitHub for more information." + : "Try again. If the problem continues, contact the repository owner.", + }; +} diff --git a/desktop/src/features/projects/lib/projectLocalRepos.ts b/desktop/src/features/projects/lib/projectLocalRepos.ts index e89e323129..8380a1e19f 100644 --- a/desktop/src/features/projects/lib/projectLocalRepos.ts +++ b/desktop/src/features/projects/lib/projectLocalRepos.ts @@ -1,4 +1,4 @@ -import type { Project } from "@/features/projects/hooks"; +import type { Project, Repository } from "@/features/projects/hooks"; function localRepoNameCandidate(value: string | null | undefined) { const trimmed = value?.trim().replace(/\.git$/i, "") ?? ""; @@ -26,7 +26,7 @@ function cloneUrlRepoName(cloneUrl: string | undefined) { } } -function localRepoCandidates(project: Project) { +function localRepoCandidates(project: Repository) { return [ localRepoNameCandidate(project.dtag), cloneUrlRepoName(project.cloneUrls[0]), @@ -39,7 +39,16 @@ export function hasLocalCheckout( project: Project, localRepoNames: Set, ) { - return localRepoCandidates(project).some((candidate) => + return project.repositories.some((repository) => + hasLocalRepositoryCheckout(repository, localRepoNames), + ); +} + +export function hasLocalRepositoryCheckout( + repository: Repository, + localRepoNames: Set, +) { + return localRepoCandidates(repository).some((candidate) => localRepoNames.has(candidate), ); } diff --git a/desktop/src/features/projects/lib/projectRepoAvailability.test.mjs b/desktop/src/features/projects/lib/projectRepoAvailability.test.mjs new file mode 100644 index 0000000000..b1a40ca9ba --- /dev/null +++ b/desktop/src/features/projects/lib/projectRepoAvailability.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { projectRepoUnavailableReason } from "./projectRepoAvailability.ts"; + +test("classifies a missing repository", () => { + assert.equal( + projectRepoUnavailableReason(new Error("remote: Repository not found")), + "missing", + ); + assert.equal(projectRepoUnavailableReason(null), "missing"); +}); + +test("classifies authentication failures before generic availability errors", () => { + assert.equal( + projectRepoUnavailableReason( + new Error("The requested URL returned error: 403"), + ), + "authentication", + ); + assert.equal( + projectRepoUnavailableReason(new Error("Authentication failed")), + "authentication", + ); +}); + +test("classifies branch and network failures", () => { + assert.equal( + projectRepoUnavailableReason( + new Error("Remote branch main not found in upstream origin"), + ), + "ref", + ); + assert.equal( + projectRepoUnavailableReason(new Error("Could not resolve host: relay")), + "network", + ); + assert.equal( + projectRepoUnavailableReason(new Error("git timed out after 300s")), + "network", + ); +}); + +test("keeps unmatched failures generic", () => { + assert.equal( + projectRepoUnavailableReason(new Error("git exited with status 128")), + "unknown", + ); +}); diff --git a/desktop/src/features/projects/lib/projectRepoAvailability.ts b/desktop/src/features/projects/lib/projectRepoAvailability.ts new file mode 100644 index 0000000000..803548d3dd --- /dev/null +++ b/desktop/src/features/projects/lib/projectRepoAvailability.ts @@ -0,0 +1,48 @@ +export type ProjectRepoUnavailableReason = + | "missing" + | "authentication" + | "network" + | "ref" + | "unknown"; + +export function projectRepoUnavailableReason( + error: unknown, +): ProjectRepoUnavailableReason { + const message = + error instanceof Error + ? error.message.toLowerCase() + : typeof error === "string" + ? error.toLowerCase() + : ""; + + if (!message) return "missing"; + if ( + /\b(?:401|403)\b|authenticat|authoriz|permission denied|access denied/.test( + message, + ) + ) { + return "authentication"; + } + if ( + /\b404\b|repository not found|repository does not exist|not found on the relay/.test( + message, + ) + ) { + return "missing"; + } + if ( + /remote branch .* not found|could not resolve the requested repository ref|couldn't find remote ref/.test( + message, + ) + ) { + return "ref"; + } + if ( + /timed? out|could not resolve host|failed to connect|connection (?:refused|reset)|network is unreachable|offline/.test( + message, + ) + ) { + return "network"; + } + return "unknown"; +} diff --git a/desktop/src/features/projects/lib/projectRepoHost.ts b/desktop/src/features/projects/lib/projectRepoHost.ts new file mode 100644 index 0000000000..07e27f922c --- /dev/null +++ b/desktop/src/features/projects/lib/projectRepoHost.ts @@ -0,0 +1,78 @@ +import { effectiveCloneUrls } from "./projectCloneUrl"; + +export type ProjectRepoHost = + | { kind: "buzz" } + | { kind: "external"; host: string } + | { kind: "unresolved" }; + +/** + * Classifies the canonical git remote using the same origin and path boundary + * enforced by the Tauri git commands. This is presentation/query gating only; + * Rust remains the security boundary for clone operations. + */ +export function projectRepoHost( + cloneUrl: string | null | undefined, + relayOrigin: string | null | undefined, +): ProjectRepoHost { + if (!cloneUrl || !relayOrigin) return { kind: "unresolved" }; + + try { + const clone = new URL(cloneUrl); + const relay = new URL(relayOrigin); + const isBuzzPath = /^\/git\/[0-9a-f]{64}\/[^/]+\/?$/i.test(clone.pathname); + + if (clone.origin === relay.origin && isBuzzPath) { + return { kind: "buzz" }; + } + + return { kind: "external", host: clone.host }; + } catch { + return { kind: "unresolved" }; + } +} + +type RepositoryHostInput = { + cloneUrls: string[]; + dtag: string; + owner: string; + repoAddress?: string; +}; + +export function projectRepoHostForRepository( + repository: RepositoryHostInput | null | undefined, + relayOrigin: string | null | undefined, +): ProjectRepoHost { + if (!repository) return { kind: "unresolved" }; + const cloneUrl = effectiveCloneUrls( + repository.cloneUrls, + relayOrigin, + repository.owner, + repository.dtag, + )[0]; + return projectRepoHost(cloneUrl, relayOrigin); +} + +export function projectRepoHostForProject( + project: + | RepositoryHostInput + | { + primaryRepositoryAddress: string | null; + repositories: RepositoryHostInput[]; + } + | null + | undefined, + relayOrigin: string | null | undefined, +): ProjectRepoHost { + if (!project) return { kind: "unresolved" }; + if (!("repositories" in project)) { + return projectRepoHostForRepository(project, relayOrigin); + } + + const repository = + project.repositories.find( + (candidate) => candidate.repoAddress === project.primaryRepositoryAddress, + ) ?? + project.repositories[0] ?? + null; + return projectRepoHostForRepository(repository, relayOrigin); +} diff --git a/desktop/src/features/projects/lib/projectsViewHelpers.ts b/desktop/src/features/projects/lib/projectsViewHelpers.ts index 0328271441..6084c7275f 100644 --- a/desktop/src/features/projects/lib/projectsViewHelpers.ts +++ b/desktop/src/features/projects/lib/projectsViewHelpers.ts @@ -2,16 +2,23 @@ import type { Project, ProjectActivitySummary, } from "@/features/projects/hooks"; +import { selectProjectRepository } from "@/features/projects/projectModels"; 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 ProjectsRepositoryScope = + | "all" + | "mine" + | "local" + | "buzz" + | "linked"; export type ProjectsWorkItemScope = "all" | "mine"; export type ProjectsFilter = | "all" | "mine" | "local" + | "projects" | "repositories" | "prs" | "issues" @@ -51,6 +58,7 @@ export function readStoredFilter(): ProjectsFilter { const value = globalThis.localStorage?.getItem(PROJECTS_FILTER_STORAGE_KEY); return value === "mine" || value === "local" || + value === "projects" || value === "repositories" || value === "prs" || value === "issues" || @@ -76,7 +84,14 @@ export function readStoredRepositoryScope(): ProjectsRepositoryScope { const value = globalThis.localStorage?.getItem( PROJECTS_REPOSITORY_SCOPE_STORAGE_KEY, ); - if (value === "mine" || value === "local") return value; + if ( + value === "mine" || + value === "local" || + value === "buzz" || + value === "linked" + ) { + return value; + } const legacyFilter = globalThis.localStorage?.getItem( PROJECTS_FILTER_STORAGE_KEY, ); @@ -244,7 +259,10 @@ export function projectPeople( ...new Set( [ project.owner, - ...project.contributors, + ...project.repositories.flatMap((repository) => [ + repository.owner, + ...repository.contributors, + ]), ...(summary?.participantPubkeys ?? []), ].map(normalizePubkey), ), @@ -269,7 +287,7 @@ export function normalizeRepositoryUrl(url: string) { } export function getClonePathLabel(project: Project) { - const cloneUrl = project.cloneUrls[0]; + const cloneUrl = selectProjectRepository(project, null)?.cloneUrls[0]; if (!cloneUrl) return "Clone path pending"; try { @@ -281,9 +299,7 @@ export function getClonePathLabel(project: Project) { } function repositoryIdentityKey(project: Project) { - const cloneUrl = project.cloneUrls[0]; - if (cloneUrl) return normalizeRepositoryUrl(cloneUrl); - return (project.name || project.dtag).trim().toLowerCase(); + return project.id; } export function uniqueRepositories(projects: Project[]) { @@ -325,8 +341,12 @@ export function isProjectMine( const normalizedCurrentPubkey = normalizePubkey(currentPubkey); return ( normalizePubkey(project.owner) === normalizedCurrentPubkey || - project.contributors.some( - (pubkey) => normalizePubkey(pubkey) === normalizedCurrentPubkey, + project.repositories.some( + (repository) => + normalizePubkey(repository.owner) === normalizedCurrentPubkey || + repository.contributors.some( + (pubkey) => normalizePubkey(pubkey) === normalizedCurrentPubkey, + ), ) ); } diff --git a/desktop/src/features/projects/projectActivity.d.mts b/desktop/src/features/projects/projectActivity.d.mts index 7277e7bf79..ec09ba9095 100644 --- a/desktop/src/features/projects/projectActivity.d.mts +++ b/desktop/src/features/projects/projectActivity.d.mts @@ -1,7 +1,7 @@ -import type { ProjectActivitySummary, Project } from "./hooks"; +import type { ProjectActivitySummary, Repository } from "./hooks"; import type { RelayEvent } from "@/shared/api/types"; export function summarizeProjectActivityEvents( events: RelayEvent[], - projects: Project[], + projects: Repository[], ): Record; diff --git a/desktop/src/features/projects/projectCreation.test.mjs b/desktop/src/features/projects/projectCreation.test.mjs new file mode 100644 index 0000000000..ed6e9328cd --- /dev/null +++ b/desktop/src/features/projects/projectCreation.test.mjs @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildInitialProjectEventTemplates, + isUnsupportedProjectKindError, +} from "./projectCreation.ts"; + +const OWNER = "a".repeat(64); +const CHANNEL = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + +test("buildInitialProjectEventTemplates emits a NIP-MP project", () => { + const templates = buildInitialProjectEventTemplates({ + accessChannelId: CHANNEL, + cloneUrl: "https://relay.example/git/owner/sprout.git", + description: "A multi-repository workspace", + name: "Sprout", + ownerPubkey: OWNER, + webUrl: "https://example.com/sprout", + }); + + assert.equal(templates.dtag, "sprout"); + assert.equal(templates.project.kind, 30621); + assert.equal(templates.repository.kind, 30617); + assert.deepEqual(templates.project.tags, [ + ["d", "sprout"], + ["name", "Sprout"], + ["buzz-channel", CHANNEL], + ["description", "A multi-repository workspace"], + ["a", `30617:${OWNER}:sprout`], + ]); + assert.equal(templates.project.content, ""); + assert.deepEqual(templates.repository.tags, [ + ["d", "sprout"], + ["name", "Sprout"], + ["buzz-channel", CHANNEL], + ["description", "A multi-repository workspace"], + ["clone", "https://relay.example/git/owner/sprout.git"], + ["web", "https://example.com/sprout"], + ]); +}); + +test("buildInitialProjectEventTemplates rejects names without an identifier", () => { + assert.throws( + () => + buildInitialProjectEventTemplates({ + accessChannelId: CHANNEL, + name: "!!!", + ownerPubkey: OWNER, + }), + /letters or numbers/, + ); +}); + +test("buildInitialProjectEventTemplates enforces the description tag byte limit", () => { + assert.doesNotThrow(() => + buildInitialProjectEventTemplates({ + accessChannelId: CHANNEL, + description: "🙂".repeat(512), + name: "Sprout", + ownerPubkey: OWNER, + }), + ); + assert.throws( + () => + buildInitialProjectEventTemplates({ + accessChannelId: CHANNEL, + description: "🙂".repeat(513), + name: "Sprout", + ownerPubkey: OWNER, + }), + /2,048 bytes/, + ); +}); + +test("isUnsupportedProjectKindError recognizes relay kind compatibility failures", () => { + assert.equal( + isUnsupportedProjectKindError( + new Error("restricted: unknown event kind 30621"), + ), + true, + ); + assert.equal( + isUnsupportedProjectKindError(new Error("mock project event rejection")), + false, + ); +}); diff --git a/desktop/src/features/projects/projectCreation.ts b/desktop/src/features/projects/projectCreation.ts new file mode 100644 index 0000000000..a42cf7bfd7 --- /dev/null +++ b/desktop/src/features/projects/projectCreation.ts @@ -0,0 +1,113 @@ +import { + KIND_PROJECT_ANNOUNCEMENT, + KIND_REPO_ANNOUNCEMENT, +} from "@/shared/constants/kinds"; +import { isValidProjectChannelId } from "./projectModels"; + +export type ProjectEventTemplate = { + kind: number; + content: string; + tags: string[][]; +}; + +export type InitialProjectEventTemplates = { + dtag: string; + project: ProjectEventTemplate; + repository: ProjectEventTemplate; + repositoryAddress: string; +}; + +export function isUnsupportedProjectKindError(error: unknown): boolean { + return ( + error instanceof Error && + /(?:unknown|unsupported) event kind/i.test(error.message) + ); +} + +function projectDtagFromName(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +export function buildInitialProjectEventTemplates({ + accessChannelId, + cloneUrl, + description, + name, + ownerPubkey, + webUrl, +}: { + accessChannelId: string; + cloneUrl?: string; + description?: string; + name: string; + ownerPubkey: string; + webUrl?: string; +}): InitialProjectEventTemplates { + const normalizedName = name.trim(); + if (!normalizedName) { + throw new Error("Project name is required."); + } + if (new TextEncoder().encode(normalizedName).byteLength > 256) { + throw new Error("Project name must not exceed 256 bytes."); + } + const dtag = projectDtagFromName(normalizedName); + if (!dtag) { + throw new Error("Project name must include letters or numbers."); + } + const normalizedOwner = ownerPubkey.trim().toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(normalizedOwner)) { + throw new Error("Project owner public key is invalid."); + } + + const normalizedDescription = description?.trim() ?? ""; + if (new TextEncoder().encode(normalizedDescription).byteLength > 2_048) { + throw new Error("Project description must not exceed 2,048 bytes."); + } + const repositoryTags: string[][] = [ + ["d", dtag], + ["name", normalizedName], + ]; + const projectTags: string[][] = [ + ["d", dtag], + ["name", normalizedName], + ]; + const normalizedAccessChannelId = accessChannelId.trim(); + if (!isValidProjectChannelId(normalizedAccessChannelId)) { + throw new Error("Repository access channel is invalid."); + } + repositoryTags.push(["buzz-channel", normalizedAccessChannelId]); + projectTags.push(["buzz-channel", normalizedAccessChannelId]); + if (normalizedDescription) { + repositoryTags.push(["description", normalizedDescription]); + projectTags.push(["description", normalizedDescription]); + } + const normalizedCloneUrl = cloneUrl?.trim(); + if (normalizedCloneUrl) { + repositoryTags.push(["clone", normalizedCloneUrl]); + } + const normalizedWebUrl = webUrl?.trim(); + if (normalizedWebUrl) { + repositoryTags.push(["web", normalizedWebUrl]); + } + + const repositoryAddress = `${KIND_REPO_ANNOUNCEMENT}:${normalizedOwner}:${dtag}`; + projectTags.push(["a", repositoryAddress]); + + return { + dtag, + project: { + kind: KIND_PROJECT_ANNOUNCEMENT, + content: "", + tags: projectTags, + }, + repository: { + kind: KIND_REPO_ANNOUNCEMENT, + content: normalizedDescription, + tags: repositoryTags, + }, + repositoryAddress, + }; +} diff --git a/desktop/src/features/projects/projectEnumeration.test.mjs b/desktop/src/features/projects/projectEnumeration.test.mjs new file mode 100644 index 0000000000..5d60bd4766 --- /dev/null +++ b/desktop/src/features/projects/projectEnumeration.test.mjs @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + enumerateProjectEvents, + buildProjectsFromFetcher, +} from "./projectEnumeration.ts"; + +function relayEvent(id, createdAt) { + return { + id: id.repeat(64), + kind: 30617, + pubkey: "a".repeat(64), + created_at: createdAt, + content: "", + tags: [["d", id]], + }; +} + +function fetcherFor(events) { + return async ({ limit, since, until }) => + events + .filter( + (event) => + (since === undefined || event.created_at >= since) && + (until === undefined || event.created_at <= until), + ) + .sort( + (left, right) => + right.created_at - left.created_at || left.id.localeCompare(right.id), + ) + .slice(0, limit); +} + +test("enumerateProjectEvents drains a tied boundary second before advancing", async () => { + const events = [ + relayEvent("a", 1_000), + relayEvent("b", 900), + relayEvent("c", 900), + relayEvent("d", 800), + ]; + + const result = await enumerateProjectEvents(fetcherFor(events), [30617], 3); + + assert.deepEqual( + result.map((event) => event.id).sort(), + events.map((event) => event.id).sort(), + ); +}); + +test("enumerateProjectEvents refuses to present a truncated boundary as complete", async () => { + const events = [ + relayEvent("a", 1_000), + relayEvent("b", 1_000), + relayEvent("c", 1_000), + ]; + + await assert.rejects( + enumerateProjectEvents(fetcherFor(events), [30617], 2), + /cannot exhaustively enumerate/, + ); +}); + +// ── Tombstone fetch-failure gate ───────────────────────────────────────────── +// +// `buildProjectsFromFetcher` (and therefore `fetchProjects`) must throw rather +// than returning a project list when the kind:5 tombstone enumeration rejects. +// A silent empty-set substitution would resurrect every deleted head served by +// a history-retaining relay. + +test("buildProjectsFromFetcher throws when kind-5 tombstone enumeration rejects", async () => { + const OWNER = "a".repeat(64); + const DTAG = "relay"; + + const projectEvent = { + id: "p".repeat(64), + kind: 30621, + pubkey: OWNER, + created_at: 200, + content: "", + tags: [["d", DTAG]], + }; + const repoEvent = { + id: "r".repeat(64), + kind: 30617, + pubkey: OWNER, + created_at: 100, + content: "", + tags: [ + ["d", DTAG], + ["name", DTAG], + ], + }; + + // Fetcher: succeeds for project/repo kinds, rejects for kind:5 (tombstones). + const fetchExhaustively = async (kinds) => { + if (kinds.includes(5)) { + throw new Error("relay unavailable"); + } + if (kinds.includes(30621)) return [projectEvent]; + if (kinds.includes(30617)) return [repoEvent]; + return []; + }; + + await assert.rejects( + buildProjectsFromFetcher(fetchExhaustively), + /Could not fetch project deletion records/, + "tombstone fetch failure must propagate as a throw, not an empty deletion set", + ); +}); + +test("buildProjectsFromFetcher does not throw when tombstone enumeration succeeds with an empty result", async () => { + // Verify the happy path: no tombstones → returns projects without error. + const OWNER = "a".repeat(64); + + const fetchExhaustively = async (kinds) => { + if (kinds.includes(5)) return []; // no deletions + if (kinds.includes(30621)) return []; // no explicit projects + if (kinds.includes(30617)) + return [ + { + id: "r".repeat(64), + kind: 30617, + pubkey: OWNER, + created_at: 100, + content: "", + tags: [ + ["d", "relay"], + ["name", "relay"], + ], + }, + ]; + return []; + }; + + const projects = await buildProjectsFromFetcher(fetchExhaustively); + // Legacy project (unclaimed repo) should appear. + assert.ok(Array.isArray(projects), "must return a project array"); + assert.equal( + projects.some((p) => p.legacy), + true, + "unclaimed repo must appear as legacy project", + ); +}); diff --git a/desktop/src/features/projects/projectEnumeration.ts b/desktop/src/features/projects/projectEnumeration.ts new file mode 100644 index 0000000000..072b417a5b --- /dev/null +++ b/desktop/src/features/projects/projectEnumeration.ts @@ -0,0 +1,129 @@ +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; +import { + KIND_DELETION, + KIND_PROJECT_ANNOUNCEMENT, + KIND_REPO_ANNOUNCEMENT, +} from "@/shared/constants/kinds"; +import { buildProjectReadModels, type Project } from "./projectModels"; + +const PROJECT_ENUMERATION_PAGE_SIZE = 500; + +type ProjectEventFilter = { + kinds: number[]; + limit: number; + since?: number; + until?: number; +}; + +type FetchProjectEventPage = ( + filter: ProjectEventFilter, +) => Promise; + +/** + * Enumerates a NIP-01 websocket filter with the boundary-bucket drain required + * by NIP-MP. A bare `until` cursor cannot safely advance until every event in + * the oldest returned second has been retrieved. + */ +export async function enumerateProjectEvents( + fetchPage: FetchProjectEventPage, + kinds: number[], + pageSize: number, +): Promise { + if (!Number.isSafeInteger(pageSize) || pageSize <= 0) { + throw new Error( + "Project enumeration page size must be a positive integer.", + ); + } + + const eventsById = new Map(); + let until: number | undefined; + + for (;;) { + const page = await fetchPage({ + kinds, + limit: pageSize, + ...(until === undefined ? {} : { until }), + }); + for (const event of page) eventsById.set(event.id, event); + if (page.length < pageSize) return [...eventsById.values()]; + + const oldest = Math.min(...page.map((event) => event.created_at)); + const boundary = await fetchPage({ + kinds, + limit: pageSize, + since: oldest, + until: oldest, + }); + for (const event of boundary) eventsById.set(event.id, event); + if (boundary.length >= pageSize) { + // Invariant violation: the relay has more events sharing this exact + // second than the page limit. Enumeration is statically uncompletable + // at the current page size. Rather than present a silently truncated + // collection, we hard-error. If this surfaces in production, the fix is + // either a larger pageSize constant or a relay-side deduplication pass. + // TODO: add a telemetry event here so pathological relay states are + // diagnosable before they reach users. + throw new Error( + "The relay cannot exhaustively enumerate projects because too many events share one timestamp.", + ); + } + if (oldest <= 0) return [...eventsById.values()]; + until = oldest - 1; + } +} + +export function fetchProjectEventsExhaustively( + kinds: number[], + pageSize = PROJECT_ENUMERATION_PAGE_SIZE, +): Promise { + return enumerateProjectEvents( + (filter) => relayClient.fetchEvents(filter), + kinds, + pageSize, + ); +} + +/** + * Core fetch-and-build logic for `fetchProjects`, extracted for testability. + * + * Accepts an injectable `fetchExhaustively` so unit tests can stub individual + * kind enumerations (including injecting a rejection for kind:5 tombstones) without + * pulling in the Tauri relay client. + * + * Fail-closed: if the kind:5 tombstone enumeration rejects, throws rather than + * returning an empty deletion set that would resurrect every deleted head. + */ +export async function buildProjectsFromFetcher( + fetchExhaustively: (kinds: number[]) => Promise, + options: { + relayOrigin?: string | null; + hiddenAddresses?: ReadonlySet; + } = {}, +): Promise { + const [projectEvents, repositoryEvents, tombstoneResult] = await Promise.all([ + fetchExhaustively([KIND_PROJECT_ANNOUNCEMENT]), + fetchExhaustively([KIND_REPO_ANNOUNCEMENT]), + fetchExhaustively([KIND_DELETION]).then( + (events) => ({ ok: true as const, events }), + (error: unknown) => ({ + ok: false as const, + message: error instanceof Error ? error.message : "Unknown error", + }), + ), + ]); + + if (!tombstoneResult.ok) { + throw new Error( + `Could not fetch project deletion records: ${tombstoneResult.message} — refresh to retry.`, + ); + } + + return buildProjectReadModels({ + projectEvents, + repositoryEvents, + deletionEvents: tombstoneResult.events, + relayOrigin: options.relayOrigin ?? null, + hiddenAddresses: options.hiddenAddresses ?? new Set(), + }).sort((a, b) => b.createdAt - a.createdAt); +} diff --git a/desktop/src/features/projects/projectIssues.d.mts b/desktop/src/features/projects/projectIssues.d.mts index b31dc3c1ff..4b0420602c 100644 --- a/desktop/src/features/projects/projectIssues.d.mts +++ b/desktop/src/features/projects/projectIssues.d.mts @@ -24,6 +24,8 @@ export type ProjectIssue = { author: string; createdAt: number; repoAddress: string | null; + channelId: string | null; + originAgentName: string | null; labels: string[]; recipients: string[]; status: ProjectIssueStatus; @@ -54,6 +56,11 @@ export function projectIssueEventsToIssues( statusEvents?: RelayEvent[], commentEvents?: RelayEvent[], ): ProjectIssue[]; +export function nextProjectIssueCommentCreatedAt( + issue: ProjectIssue, + now: number, + author: string, +): number; export function buildGitIssueTags(input: { repoAddress: string; repoOwner: string; diff --git a/desktop/src/features/projects/projectIssues.mjs b/desktop/src/features/projects/projectIssues.mjs index 0655245866..331837ac5b 100644 --- a/desktop/src/features/projects/projectIssues.mjs +++ b/desktop/src/features/projects/projectIssues.mjs @@ -110,6 +110,8 @@ export function eventToProjectIssue( author: issue.pubkey, createdAt: issue.created_at, repoAddress: getTag(issue, "a") ?? null, + channelId: getTag(issue, "h") ?? null, + originAgentName: getTag(issue, "buzz-origin-agent") ?? null, labels: getAllTags(issue, "t"), recipients: getAllTags(issue, "p"), status: statusFromEvent(issue, latestStatus), @@ -134,6 +136,17 @@ export function projectIssueEventsToIssues( .sort((left, right) => right.updatedAt - left.updatedAt); } +/** Keep consecutive comments ordered across whole-second Nostr timestamps. */ +export function nextProjectIssueCommentCreatedAt(issue, now, author) { + const normalizedAuthor = author.toLowerCase(); + return Math.max( + now, + ...issue.comments + .filter((comment) => comment.author.toLowerCase() === normalizedAuthor) + .map((comment) => comment.createdAt + 1), + ); +} + export function buildGitIssueTags({ repoAddress, repoOwner, diff --git a/desktop/src/features/projects/projectIssues.test.mjs b/desktop/src/features/projects/projectIssues.test.mjs index 2d0fb5fb45..3275412149 100644 --- a/desktop/src/features/projects/projectIssues.test.mjs +++ b/desktop/src/features/projects/projectIssues.test.mjs @@ -6,6 +6,7 @@ import { eventToProjectIssue, getAllTags, getTag, + nextProjectIssueCommentCreatedAt, PROJECT_ISSUE_STATUS, } from "./projectIssues.mjs"; @@ -125,6 +126,31 @@ test("preserves root and comment tags for rich content rendering", () => { assert.deepEqual(issue.comments[0].tags, [comment.tags[1]]); }); +test("parses public and private-safe issue provenance", () => { + const channelId = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + const publicIssue = eventToProjectIssue( + issueEvent({ + tags: [ + ["a", REPO_ADDRESS], + ["h", channelId], + ], + }), + ); + const privateIssue = eventToProjectIssue( + issueEvent({ + tags: [ + ["a", REPO_ADDRESS], + ["buzz-origin-agent", "Builder"], + ], + }), + ); + + assert.equal(publicIssue.channelId, channelId); + assert.equal(publicIssue.originAgentName, null); + assert.equal(privateIssue.channelId, null); + assert.equal(privateIssue.originAgentName, "Builder"); +}); + test("builds repository-scoped issue creation tags", () => { assert.deepEqual( buildGitIssueTags({ @@ -139,3 +165,39 @@ test("builds repository-scoped issue creation tags", () => { ], ); }); + +test("orders consecutive issue comments across whole-second timestamps", () => { + const issue = eventToProjectIssue( + issueEvent(), + [], + [ + { + id: "comment-1", + kind: 1, + pubkey: AUTHOR, + created_at: 200, + content: "First", + tags: [["e", "e".repeat(64), "", "root"]], + }, + { + id: "comment-2", + kind: 1, + pubkey: AUTHOR, + created_at: 201, + content: "Second", + tags: [["e", "e".repeat(64), "", "root"]], + }, + { + id: "attacker-comment", + kind: 1, + pubkey: ATTACKER, + created_at: 10_000, + content: "Future", + tags: [["e", "e".repeat(64), "", "root"]], + }, + ], + ); + + assert.equal(nextProjectIssueCommentCreatedAt(issue, 200, AUTHOR), 202); + assert.equal(nextProjectIssueCommentCreatedAt(issue, 300, AUTHOR), 300); +}); diff --git a/desktop/src/features/projects/projectModels.test.mjs b/desktop/src/features/projects/projectModels.test.mjs new file mode 100644 index 0000000000..7be105584a --- /dev/null +++ b/desktop/src/features/projects/projectModels.test.mjs @@ -0,0 +1,704 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + addRepositoryToProject, + buildProjectReadModels, + eventToExplicitProject, + eventToRepository, + selectProjectRepository, +} from "./projectModels.ts"; +import { projectMatchesRouteId } from "./projectRoutes.ts"; + +const PROJECT_OWNER = "a".repeat(64); +const FRONTEND_OWNER = "b".repeat(64); +const BACKEND_OWNER = "c".repeat(64); +const RELAY_ORIGIN = "https://relay.example"; + +function repositoryEvent(owner, id, createdAt = 100) { + return { + id: `${id}-${createdAt}`, + kind: 30617, + pubkey: owner, + created_at: createdAt, + content: "", + tags: [ + ["d", id], + ["name", id], + ], + }; +} + +function projectEvent(repositoryTags, overrides = {}) { + return { + id: "project-event", + kind: 30621, + pubkey: PROJECT_OWNER, + created_at: 200, + content: "ignored by NIP-MP readers", + tags: [ + ["d", "sprout"], + ["name", "Sprout"], + ["description", "A multi-repository project"], + ["buzz-channel", "11111111-1111-4111-8111-111111111111"], + ...repositoryTags, + ], + ...overrides, + }; +} + +test("eventToRepository preserves repository-scoped identity and clone data", () => { + const repository = eventToRepository( + repositoryEvent(FRONTEND_OWNER, "frontend"), + RELAY_ORIGIN, + ); + + assert.equal(repository.id, `${FRONTEND_OWNER}:frontend`); + assert.equal(repository.repoAddress, `30617:${FRONTEND_OWNER}:frontend`); + assert.deepEqual(repository.cloneUrls, [ + `${RELAY_ORIGIN}/git/${FRONTEND_OWNER}/frontend`, + ]); +}); + +test("buildProjectReadModels resolves repositories with a deterministic selection fallback", () => { + const frontendAddress = `30617:${PROJECT_OWNER}:frontend`; + const backendAddress = `30617:${PROJECT_OWNER}:backend`; + const projects = buildProjectReadModels({ + projectEvents: [ + projectEvent([ + ["a", frontendAddress], + ["a", backendAddress, "wss://relay.example"], + ]), + ], + repositoryEvents: [ + repositoryEvent(PROJECT_OWNER, "frontend"), + repositoryEvent(PROJECT_OWNER, "backend"), + ], + relayOrigin: RELAY_ORIGIN, + }); + + assert.equal(projects.length, 1); + assert.equal(projects[0].id, `30621:${PROJECT_OWNER}:sprout`); + assert.equal(projects[0].projectAddress, `30621:${PROJECT_OWNER}:sprout`); + assert.equal(projects[0].primaryRepositoryAddress, backendAddress); + assert.deepEqual( + projects[0].repositories.map((repository) => repository.repoAddress), + [backendAddress, frontendAddress], + ); + assert.equal( + projects[0].repositoryRelayHints[backendAddress], + "wss://relay.example", + ); +}); + +test("buildProjectReadModels keeps unclaimed repositories as implicit projects", () => { + const frontendAddress = `30617:${PROJECT_OWNER}:frontend`; + const projects = buildProjectReadModels({ + projectEvents: [projectEvent([["a", frontendAddress]])], + repositoryEvents: [ + repositoryEvent(PROJECT_OWNER, "frontend"), + repositoryEvent(BACKEND_OWNER, "backend"), + ], + relayOrigin: RELAY_ORIGIN, + }); + + assert.equal(projects.length, 2); + assert.equal(projects[0].legacy, false); + assert.equal(projects[1].legacy, true); + assert.equal( + projects[1].primaryRepositoryAddress, + projects[1].projectAddress, + ); + assert.equal(projects[1].repositories[0].dtag, "backend"); +}); + +test("buildProjectReadModels does not let an unauthorized project hide a repository", () => { + const frontendAddress = `30617:${FRONTEND_OWNER}:frontend`; + const projects = buildProjectReadModels({ + projectEvents: [projectEvent([["a", frontendAddress]])], + repositoryEvents: [repositoryEvent(FRONTEND_OWNER, "frontend")], + relayOrigin: RELAY_ORIGIN, + }); + + assert.equal(projects.length, 2); + assert.equal(projects.filter((project) => project.legacy).length, 1); + assert.equal(projects.filter((project) => !project.legacy).length, 1); +}); + +test("project and repository routes stay distinct when coordinates share a d tag", () => { + const projects = buildProjectReadModels({ + projectEvents: [projectEvent([])], + repositoryEvents: [repositoryEvent(PROJECT_OWNER, "sprout")], + relayOrigin: RELAY_ORIGIN, + }); + const explicitProject = projects.find((project) => !project.legacy); + const implicitProject = projects.find((project) => project.legacy); + + assert.notEqual(explicitProject.id, implicitProject.id); + assert.equal( + projectMatchesRouteId(explicitProject, explicitProject.projectAddress), + true, + ); + assert.equal( + projectMatchesRouteId(explicitProject, implicitProject.projectAddress), + false, + ); +}); + +test("addRepositoryToProject promotes a legacy repository route to a project coordinate", () => { + const [legacyProject] = buildProjectReadModels({ + projectEvents: [], + repositoryEvents: [repositoryEvent(PROJECT_OWNER, "sprout")], + relayOrigin: RELAY_ORIGIN, + }); + const attachedRepository = eventToRepository( + repositoryEvent(PROJECT_OWNER, "mobile"), + RELAY_ORIGIN, + ); + const updated = addRepositoryToProject( + legacyProject, + attachedRepository, + 300, + ); + + assert.equal(updated.id, `30621:${PROJECT_OWNER}:sprout`); + assert.equal(updated.legacy, false); + assert.equal(updated.repositories.length, 2); +}); + +test("selectProjectRepository honors a request and falls back to primary", () => { + const frontendAddress = `30617:${PROJECT_OWNER}:frontend`; + const projects = buildProjectReadModels({ + projectEvents: [ + projectEvent([ + ["a", frontendAddress], + ["a", `30617:${PROJECT_OWNER}:backend`], + ]), + ], + repositoryEvents: [ + repositoryEvent(PROJECT_OWNER, "frontend"), + repositoryEvent(PROJECT_OWNER, "backend"), + ], + relayOrigin: RELAY_ORIGIN, + }); + + assert.equal( + selectProjectRepository(projects[0], `${PROJECT_OWNER}:backend`)?.dtag, + "backend", + ); + assert.equal( + selectProjectRepository(projects[0], "missing:repository")?.dtag, + "backend", + ); + assert.equal(selectProjectRepository(projects[0], null)?.dtag, "backend"); +}); + +function coordinateParts(coordinate) { + const first = coordinate.indexOf(":"); + const second = coordinate.indexOf(":", first + 1); + return { + kind: Number(coordinate.slice(0, first)), + owner: coordinate.slice(first + 1, second), + dtag: coordinate.slice(second + 1), + }; +} + +function sortedJson(values) { + return values + .map((value) => JSON.stringify(value)) + .sort() + .map((value) => JSON.parse(value)); +} + +test("buildProjectReadModels conforms to the shared NIP-MP fold fixtures", () => { + const fixture = JSON.parse( + readFileSync( + new URL( + "../../../../docs/nips/NIP-MP.fold-fixtures.json", + import.meta.url, + ), + "utf8", + ), + ); + + for (const [caseIndex, fixtureCase] of fixture.cases.entries()) { + let eventIndex = caseIndex * 100; + const hiddenAddresses = new Set(); + const repositoryEvents = fixtureCase.repositories.flatMap((repository) => { + if (repository.viewer_hidden) hiddenAddresses.add(repository.coordinate); + if (repository.state !== "live") return []; + const { dtag, owner } = coordinateParts(repository.coordinate); + return [ + { + ...repositoryEvent( + owner, + dtag, + repository.created_at ?? 1_000 - caseIndex, + ), + id: (++eventIndex).toString(16).padStart(64, "0"), + tags: [ + ["d", dtag], + ["name", dtag], + ...(repository.maintainers?.length + ? [["maintainers", ...repository.maintainers]] + : []), + ], + }, + ]; + }); + const projectEvents = fixtureCase.projects.flatMap((project) => { + if (project.viewer_hidden) hiddenAddresses.add(project.coordinate); + if (project.state !== "live") return []; + const { dtag, owner } = coordinateParts(project.coordinate); + return [ + { + id: (++eventIndex).toString(16).padStart(64, "0"), + kind: 30621, + pubkey: owner, + created_at: project.created_at ?? 900 - caseIndex, + content: "", + tags: [ + ["d", dtag], + ["name", dtag], + ...(project.visibility === "unlisted" + ? [["buzz-visibility", "unlisted"]] + : []), + ...project.members.map((member) => ["a", member]), + ], + }, + ]; + }); + + const projects = buildProjectReadModels({ + projectEvents, + repositoryEvents, + relayOrigin: RELAY_ORIGIN, + hiddenAddresses, + }); + const actualContainers = projects + .filter((project) => !project.legacy) + .map((project) => ({ + project: project.projectAddress, + members: project.repositoryAddresses.flatMap((coordinate) => { + if ( + project.repositories.some( + (repository) => repository.repoAddress === coordinate, + ) + ) { + return [{ coordinate, render: "resolved" }]; + } + return project.unavailableRepositoryAddresses?.includes(coordinate) + ? [{ coordinate, render: "unavailable" }] + : []; + }), + })); + const expectedContainers = fixtureCase.expect.containers.map( + (container) => ({ + project: container.project, + members: sortedJson(container.members), + }), + ); + + assert.deepEqual( + sortedJson( + actualContainers.map((container) => ({ + ...container, + members: sortedJson(container.members), + })), + ), + sortedJson(expectedContainers), + fixtureCase.name, + ); + assert.deepEqual( + projects + .filter((project) => project.legacy) + .map((project) => project.projectAddress) + .sort(), + [...fixtureCase.expect.implicit_cards].sort(), + fixtureCase.name, + ); + } +}); + +// ── NIP-MP ingest-fixture conformance (TypeScript parser) ────────────────── +// +// The shared NIP-MP.fixtures.json is the ingest oracle used by the relay +// validator and the Rust CLI builder. The TypeScript read model must agree +// on every accept/reject case so that relay and desktop never diverge about +// which signed project heads are valid. + +test("buildProjectReadModels conforms to the shared NIP-MP ingest fixtures", () => { + const fixtures = JSON.parse( + readFileSync( + new URL("../../../../docs/nips/NIP-MP.fixtures.json", import.meta.url), + "utf8", + ), + ); + + const SIGNER = "a".repeat(64); + + for (const fixtureCase of fixtures.cases) { + const event = { + id: "e".repeat(64), + kind: fixtureCase.template.kind, + pubkey: SIGNER, + created_at: 1_000, + content: fixtureCase.template.content, + tags: fixtureCase.template.tags, + }; + + // Use eventToExplicitProject directly — buildProjectReadModels only returns + // listed projects, so an unlisted-but-valid envelope (e.g. valid_unlisted) + // would be filtered out before we could observe it. The ingest oracle only + // cares whether the envelope parses successfully, not whether it reaches + // the rendered collection; eventToExplicitProject is the correct gate. + const parsed = eventToExplicitProject(event, new Map(), new Map()); + const accepted = parsed !== null; + + if (fixtureCase.expect === "accept") { + assert.equal( + accepted, + true, + `${fixtureCase.name}: expected accept, got reject`, + ); + } else { + assert.equal( + accepted, + false, + `${fixtureCase.name}: expected reject, got accept`, + ); + } + } +}); + +// ── NIP-09 tombstone deletion ─────────────────────────────────────────────── + +test("buildProjectReadModels applies kind:5 tombstones to project events", () => { + const owner = "a".repeat(64); + const projectAddress = `30621:${owner}:platform`; + const projectEvent = { + id: "p".repeat(64), + kind: 30621, + pubkey: owner, + created_at: 100, + content: "", + tags: [ + ["d", "platform"], + ["name", "Platform"], + ], + }; + const deletionEvent = { + id: "d".repeat(64), + kind: 5, + pubkey: owner, + created_at: 200, + content: "", + tags: [["a", projectAddress]], + }; + + const withDeletion = buildProjectReadModels({ + projectEvents: [projectEvent], + repositoryEvents: [], + deletionEvents: [deletionEvent], + }); + assert.equal( + withDeletion.filter((p) => !p.legacy).length, + 0, + "deleted project should not appear", + ); + + const withoutDeletion = buildProjectReadModels({ + projectEvents: [projectEvent], + repositoryEvents: [], + }); + assert.equal( + withoutDeletion.filter((p) => !p.legacy).length, + 1, + "project without deletion should appear", + ); +}); + +test("buildProjectReadModels applies kind:5 tombstones to repository events", () => { + const owner = "b".repeat(64); + const repoAddress = `30617:${owner}:relay`; + const repoEvent = { + id: "r".repeat(64), + kind: 30617, + pubkey: owner, + created_at: 100, + content: "", + tags: [ + ["d", "relay"], + ["name", "relay"], + ], + }; + const deletionEvent = { + id: "d".repeat(64), + kind: 5, + pubkey: owner, + created_at: 200, + content: "", + tags: [["a", repoAddress]], + }; + + const withDeletion = buildProjectReadModels({ + projectEvents: [], + repositoryEvents: [repoEvent], + deletionEvents: [deletionEvent], + }); + assert.equal(withDeletion.length, 0, "deleted repository should not appear"); +}); + +test("buildProjectReadModels ignores a tombstone that predates the live head", () => { + const owner = "a".repeat(64); + const projectAddress = `30621:${owner}:platform`; + const projectEvent = { + id: "p".repeat(64), + kind: 30621, + pubkey: owner, + created_at: 200, + content: "", + tags: [["d", "platform"]], + }; + // Deletion is at t=100, but the live head is at t=200 — should not apply. + const staleDeletion = { + id: "d".repeat(64), + kind: 5, + pubkey: owner, + created_at: 100, + content: "", + tags: [["a", projectAddress]], + }; + + const projects = buildProjectReadModels({ + projectEvents: [projectEvent], + repositoryEvents: [], + deletionEvents: [staleDeletion], + }); + assert.equal( + projects.filter((p) => !p.legacy).length, + 1, + "head newer than tombstone must survive", + ); +}); + +test("buildProjectReadModels rejects a tombstone signed by a different pubkey", () => { + const owner = "a".repeat(64); + const impostor = "b".repeat(64); + const projectAddress = `30621:${owner}:platform`; + const projectEvent = { + id: "p".repeat(64), + kind: 30621, + pubkey: owner, + created_at: 100, + content: "", + tags: [["d", "platform"]], + }; + const foreignDeletion = { + id: "d".repeat(64), + kind: 5, + pubkey: impostor, + created_at: 200, + content: "", + tags: [["a", projectAddress]], + }; + + const projects = buildProjectReadModels({ + projectEvents: [projectEvent], + repositoryEvents: [], + deletionEvents: [foreignDeletion], + }); + assert.equal( + projects.filter((p) => !p.legacy).length, + 1, + "a stranger's tombstone must not delete someone else's project", + ); +}); + +// ── Route contract ────────────────────────────────────────────────────────── + +test("projectMatchesRouteId resolves a 30617 coordinate to the containing project", () => { + const projectOwner = "a".repeat(64); + const repoOwner = "c".repeat(64); + const repoAddress = `30617:${repoOwner}:relay`; + const projects = buildProjectReadModels({ + projectEvents: [ + { + id: "project-event", + kind: 30621, + pubkey: projectOwner, + created_at: 200, + content: "", + tags: [ + ["d", "platform"], + ["a", repoAddress], + ], + }, + ], + repositoryEvents: [ + { + id: "repo-event", + kind: 30617, + pubkey: repoOwner, + created_at: 100, + content: "", + tags: [ + ["d", "relay"], + ["name", "relay"], + ["maintainers", projectOwner], + ], + }, + ], + }); + const explicitProject = projects.find((p) => !p.legacy); + assert.ok(explicitProject, "explicit project must be present"); + + // Entity link navigates with the 30617 coordinate (repo dtag ≠ project dtag). + assert.equal( + projectMatchesRouteId(explicitProject, repoAddress), + true, + "30617 route must resolve to the containing project", + ); + // Legacy project (implicit card for an unclaimed repo) must NOT match the + // explicit project's 30621 coordinate. + assert.equal( + projectMatchesRouteId(explicitProject, `30617:${projectOwner}:platform`), + false, + "unrelated 30617 address must not match", + ); +}); + +// ── 30617 route selects the correct repository (finding 4 regression gate) ── +// +// When `projectId` is a `30617::` coordinate (emitted by entity +// links in #4695), `selectProjectRepository` must resolve to the repository +// whose `id` is `:` — not to the project's primary repository. +// This test verifies the DI contract that `ProjectDetailScreen` uses to derive +// `routeRepositoryId` from `projectId`. + +test("selectProjectRepository resolves a non-primary repository when repositoryId is derived from a 30617 projectId", () => { + const OWNER = "a".repeat(64); + const OTHER = "b".repeat(64); + + const primaryRepo = { + id: `${OWNER}:buzz`, + dtag: "buzz", + name: "Buzz", + repoAddress: `30617:${OWNER}:buzz`, + owner: OWNER, + cloneUrls: [], + webUrl: null, + description: "", + contributors: [], + createdAt: 100, + status: "active", + defaultBranch: "main", + }; + const nonPrimaryRepo = { + id: `${OTHER}:relay-tools`, + dtag: "relay-tools", + name: "Relay Tools", + repoAddress: `30617:${OTHER}:relay-tools`, + owner: OTHER, + cloneUrls: [], + webUrl: null, + description: "", + contributors: [], + createdAt: 80, + status: "active", + defaultBranch: "main", + }; + + const project = { + id: `30621:${OWNER}:buzz`, + dtag: "buzz", + name: "Buzz", + description: "", + owner: OWNER, + createdAt: 100, + projectChannelId: null, + status: "active", + projectAddress: `30621:${OWNER}:buzz`, + primaryRepositoryAddress: primaryRepo.repoAddress, + repositoryAddresses: [primaryRepo.repoAddress, nonPrimaryRepo.repoAddress], + repositories: [primaryRepo, nonPrimaryRepo], + unavailableRepositoryAddresses: [], + visibility: "listed", + legacy: false, + }; + + // Without a repositoryId: falls back to primary. + assert.equal( + selectProjectRepository(project, undefined)?.id, + primaryRepo.id, + "no repositoryId must yield the primary repository", + ); + + // With a repositoryId derived from the 30617 coordinate (as ProjectDetailScreen does): + // "30617::".slice("30617:".length) === ":" === Repository.id + const routeRepositoryId = nonPrimaryRepo.repoAddress.slice("30617:".length); + assert.equal( + routeRepositoryId, + nonPrimaryRepo.id, + "routeRepositoryId derivation must equal Repository.id", + ); + assert.equal( + selectProjectRepository(project, routeRepositoryId)?.id, + nonPrimaryRepo.id, + "derived repositoryId from 30617 coordinate must resolve to non-primary repository", + ); + assert.notEqual( + selectProjectRepository(project, routeRepositoryId)?.id, + primaryRepo.id, + "non-primary linked repo must NOT fall back to primary", + ); +}); + +// ── Tombstone exhaustive enumeration gate (finding 3) ─────────────────────── +// +// Verifies that `buildProjectReadModels` applies ALL deletion events in the +// supplied array — not just the first 2000 — proving the exhaustive-enumeration +// path (via `fetchProjectEventsExhaustively` in `fetchProjects`) makes a +// difference. The prior code capped kind:5 at 2000; this test ensures deletion +// 2001+ is honoured when the exhaustive path is used. + +test("buildProjectReadModels applies deletion beyond the 2000-event boundary", () => { + const OWNER = "a".repeat(64); + + // The project we want to verify is deleted (it's beyond event 2000). + const targetDtag = "beyond-limit"; + const targetAddress = `30621:${OWNER}:${targetDtag}`; + + const projectEvent = { + id: "p".repeat(64), + kind: 30621, + pubkey: OWNER, + created_at: 100, + content: "", + tags: [["d", targetDtag]], + }; + + // Build 2001 deletion events; the target is the last one. + const deletionEvents = Array.from({ length: 2001 }, (_, i) => ({ + id: String(i).padStart(64, "0"), + kind: 5, + pubkey: OWNER, + created_at: 200, + content: "", + tags: [["a", i < 2000 ? `30621:${OWNER}:filler-${i}` : targetAddress]], + })); + + const projects = buildProjectReadModels({ + projectEvents: [projectEvent], + repositoryEvents: [], + deletionEvents, + relayOrigin: null, + }); + + assert.equal( + projects.length, + 0, + "project at deletion event 2001+ must be suppressed when all tombstones are applied", + ); +}); diff --git a/desktop/src/features/projects/projectModels.ts b/desktop/src/features/projects/projectModels.ts new file mode 100644 index 0000000000..6d539b54d9 --- /dev/null +++ b/desktop/src/features/projects/projectModels.ts @@ -0,0 +1,550 @@ +import type { RelayEvent } from "@/shared/api/types"; +import { + KIND_PROJECT_ANNOUNCEMENT, + KIND_REPO_ANNOUNCEMENT, +} from "@/shared/constants/kinds"; +import { effectiveCloneUrls } from "./lib/projectCloneUrl"; + +export type Repository = { + id: string; + dtag: string; + name: string; + description: string; + cloneUrls: string[]; + webUrl: string | null; + owner: string; + contributors: string[]; + createdAt: number; + status: string; + defaultBranch: string; + repoAddress: string; + maintainers?: string[]; + channelId?: string | null; + eventContent?: string; + eventTags?: string[][]; +}; + +export type Project = { + id: string; + dtag: string; + name: string; + description: string; + owner: string; + createdAt: number; + projectChannelId: string | null; + status: string; + projectAddress: string; + primaryRepositoryAddress: string | null; + repositoryAddresses: string[]; + repositoryRelayHints?: Record; + repositories: Repository[]; + unavailableRepositoryAddresses?: string[]; + visibility?: "listed" | "unlisted"; + legacy: boolean; +}; + +type BuildProjectReadModelsInput = { + projectEvents: RelayEvent[]; + repositoryEvents: RelayEvent[]; + /** NIP-09 kind:5 deletion events relevant to projects and repositories. */ + deletionEvents?: RelayEvent[]; + relayOrigin?: string | null; + hiddenAddresses?: ReadonlySet; +}; + +const MAX_D_TAG_BYTES = 1_024; + +function getTag(event: RelayEvent, name: string): string | undefined { + const value = event.tags.find((tag) => tag[0] === name)?.[1]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function getAllTags(event: RelayEvent, name: string): string[] { + return event.tags + .filter( + (tag) => + tag[0] === name && typeof tag[1] === "string" && tag[1].length > 0, + ) + .map((tag) => tag[1]); +} + +function getAllTagValues(event: RelayEvent, name: string): string[] { + return event.tags + .filter((tag) => tag[0] === name) + .flatMap((tag) => tag.slice(1)) + .filter((value) => value.length > 0); +} + +function getCloneUrls(event: RelayEvent): string[] { + const tag = event.tags.find((candidate) => candidate[0] === "clone"); + return tag?.slice(1).filter((value) => value.length > 0) ?? []; +} + +function isValidDTag(value: string): boolean { + return ( + value.length > 0 && + new TextEncoder().encode(value).byteLength <= MAX_D_TAG_BYTES + ); +} + +function isValidPubkey(value: string): boolean { + return /^[a-fA-F0-9]{64}$/.test(value); +} + +/** + * Validates a pubkey as a lowercase-only 64-hex string, per NIP-MP rule + * `member-coordinate-malformed`: owner hex MUST be lowercase so that `#a` + * filter matching (which is byte-exact) can resolve the coordinate. + */ +function isValidProjectMemberOwner(value: string): boolean { + return /^[0-9a-f]{64}$/.test(value); +} + +/** NIP-MP rule `member-cap`: a project may carry at most 64 member `a` tags. */ +export const MAX_PROJECT_MEMBERS = 64; + +export function isValidProjectChannelId(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + value, + ); +} + +const SINGLETON_METADATA_TAGS = [ + "name", + "description", + "buzz-channel", + "buzz-visibility", +] as const; + +const MAX_METADATA_TAG_BYTES: Record = { + name: 256, + description: 2_048, + "buzz-channel": 256, + "buzz-visibility": 256, +}; + +/** + * Validates the NIP-MP tag/content envelope for a kind:30621 project event. + * Shared by both the read parser (`eventToExplicitProject`) and the write + * helper (`buildProjectPatchTemplate`) so Desktop's input and output agree on + * which heads are valid. + * + * Throws a descriptive error on the first violation found. + */ +export function validateProjectEventEnvelope( + tags: string[][], + content: string, +): void { + // NIP-MP rule `d-cardinality`: exactly one `d` tag is required. + const dTags = tags.filter((tag) => tag[0] === "d"); + if (dTags.length !== 1 || !dTags[0][1]) { + throw new Error( + `NIP-MP: expected exactly one non-empty 'd' tag, found ${dTags.length}.`, + ); + } + const dtag = dTags[0][1]; + if (!isValidDTag(dtag)) { + throw new Error(`NIP-MP: 'd' tag value exceeds the maximum byte length.`); + } + + // NIP-MP rule `metadata-cardinality`: at most one each of the singleton tags. + const encoder = new TextEncoder(); + for (const tagName of SINGLETON_METADATA_TAGS) { + const count = tags.filter((tag) => tag[0] === tagName).length; + if (count > 1) { + throw new Error( + `NIP-MP: duplicate '${tagName}' tag — at most one is permitted.`, + ); + } + } + + // NIP-MP rule `metadata-length`: per-field byte caps. + for (const [tagName, maxBytes] of Object.entries(MAX_METADATA_TAG_BYTES)) { + const value = tags.find((tag) => tag[0] === tagName)?.[1]; + if (value !== undefined && encoder.encode(value).byteLength > maxBytes) { + throw new Error( + `NIP-MP: '${tagName}' tag value exceeds the ${maxBytes}-byte limit.`, + ); + } + } + + // NIP-MP rule `member-cap`: at most 64 `a` membership tags. + const memberTags = tags.filter((tag) => tag[0] === "a"); + if (memberTags.length > MAX_PROJECT_MEMBERS) { + throw new Error( + `NIP-MP: project exceeds the ${MAX_PROJECT_MEMBERS}-member limit.`, + ); + } + + // NIP-MP rule `member-coordinate-malformed` + `member-arity`: + // each `a` tag must be a valid repository coordinate with a lowercase owner, + // and must carry 2 or 3 elements (address + optional relay hint). + const seenAddresses = new Set(); + for (const tag of memberTags) { + const address = tag[1]; + if (!address) { + throw new Error("NIP-MP: 'a' tag is missing a repository address."); + } + if (tag.length !== 2 && tag.length !== 3) { + throw new Error( + `NIP-MP: 'a' tag for '${address}' must have 2 or 3 elements.`, + ); + } + const parsed = parseRepositoryAddress(address); + if (!parsed) { + throw new Error( + `NIP-MP: invalid repository address '${address}' — expected '30617::'.`, + ); + } + if (seenAddresses.has(address)) { + throw new Error(`NIP-MP: duplicate repository address '${address}'.`); + } + seenAddresses.add(address); + } + + void content; // content is preserved verbatim; no constraint in NIP-MP. +} + +function deduplicateAddressableEvents(events: RelayEvent[]): RelayEvent[] { + const latest = new Map(); + for (const event of events) { + const dtag = getTag(event, "d"); + if (!dtag) continue; + const key = `${event.kind}:${event.pubkey.toLowerCase()}:${dtag}`; + const current = latest.get(key); + if ( + !current || + event.created_at > current.created_at || + (event.created_at === current.created_at && event.id < current.id) + ) { + latest.set(key, event); + } + } + return [...latest.values()]; +} + +function parseRepositoryAddress( + value: string, +): { owner: string; dtag: string } | null { + const firstSeparator = value.indexOf(":"); + const secondSeparator = value.indexOf(":", firstSeparator + 1); + if ( + value.slice(0, firstSeparator) !== String(KIND_REPO_ANNOUNCEMENT) || + secondSeparator < 0 + ) { + return null; + } + + const owner = value.slice(firstSeparator + 1, secondSeparator); + const dtag = value.slice(secondSeparator + 1); + return isValidProjectMemberOwner(owner) && isValidDTag(dtag) + ? { owner, dtag } + : null; +} + +export function eventToRepository( + event: RelayEvent, + relayOrigin?: string | null, +): Repository | null { + const dtag = getTag(event, "d"); + if ( + event.kind !== KIND_REPO_ANNOUNCEMENT || + !dtag || + !isValidDTag(dtag) || + !isValidPubkey(event.pubkey) + ) { + return null; + } + + const owner = event.pubkey.toLowerCase(); + const setupUsers = getAllTags(event, "auth"); + const channel = getTag(event, "buzz-channel"); + return { + id: `${owner}:${dtag}`, + dtag, + name: getTag(event, "name") ?? dtag, + description: getTag(event, "description") ?? event.content ?? "", + cloneUrls: effectiveCloneUrls( + getCloneUrls(event), + relayOrigin, + owner, + dtag, + ), + webUrl: getTag(event, "web") ?? null, + owner, + contributors: [...new Set([...getAllTags(event, "p"), ...setupUsers])], + createdAt: event.created_at, + status: getTag(event, "status") ?? "active", + defaultBranch: getTag(event, "default-branch") ?? "main", + repoAddress: `${KIND_REPO_ANNOUNCEMENT}:${owner}:${dtag}`, + channelId: channel && isValidProjectChannelId(channel) ? channel : null, + eventContent: event.content, + eventTags: event.tags.map((tag) => [...tag]), + maintainers: getAllTagValues(event, "maintainers") + .map((maintainer) => maintainer.toLowerCase()) + .filter(isValidPubkey), + }; +} + +export function eventToExplicitProject( + event: RelayEvent, + repositoriesByAddress: ReadonlyMap, + visibleRepositoriesByAddress: ReadonlyMap, +): Project | null { + if ( + event.kind !== KIND_PROJECT_ANNOUNCEMENT || + !isValidPubkey(event.pubkey) + ) { + return null; + } + + // Delegate all NIP-MP envelope validation to the shared validator so the + // read parser and the write helper (`buildProjectPatchTemplate`) agree on + // which heads are valid. The parser rejects invalid events silently (returns + // null) while the write helper throws, so wrap in a try/catch here. + try { + validateProjectEventEnvelope(event.tags, event.content); + } catch { + return null; + } + + // After validation we know: exactly one `d` tag with a valid value, at most + // 64 `a` tags with valid repo coordinates, no duplicate addresses, and all + // singleton metadata tags within their byte caps. + const dtag = event.tags.find((tag) => tag[0] === "d")?.[1] ?? ""; + const membershipTags = event.tags.filter((tag) => tag[0] === "a"); + const repositoryAddresses: string[] = []; + const repositoryRelayHints: Record = {}; + for (const membershipTag of membershipTags) { + const repositoryAddress = membershipTag[1]; + repositoryAddresses.push(repositoryAddress); + if (membershipTag[2]) { + repositoryRelayHints[repositoryAddress] = membershipTag[2]; + } + } + repositoryAddresses.sort(); + const primaryRepositoryAddress = + repositoryAddresses.find( + (address) => visibleRepositoriesByAddress.get(address)?.dtag === dtag, + ) ?? + repositoryAddresses.find((address) => + visibleRepositoriesByAddress.has(address), + ) ?? + null; + + const owner = event.pubkey.toLowerCase(); + const projectAddress = `${KIND_PROJECT_ANNOUNCEMENT}:${owner}:${dtag}`; + + const rawVisibility = getTag(event, "buzz-visibility"); + const visibility = + rawVisibility === "unlisted" ? ("unlisted" as const) : ("listed" as const); + const channel = getTag(event, "buzz-channel"); + return { + id: projectAddress, + dtag, + name: getTag(event, "name") ?? dtag, + description: getTag(event, "description") ?? "", + owner, + createdAt: event.created_at, + projectChannelId: + channel && isValidProjectChannelId(channel) ? channel : null, + status: visibility === "listed" ? "active" : "unlisted", + projectAddress, + primaryRepositoryAddress, + repositoryAddresses, + repositoryRelayHints, + repositories: repositoryAddresses.flatMap((address) => { + const repository = visibleRepositoriesByAddress.get(address); + return repository ? [repository] : []; + }), + unavailableRepositoryAddresses: repositoryAddresses.filter( + (address) => !repositoriesByAddress.has(address), + ), + visibility, + legacy: false, + }; +} + +function repositoryToLegacyProject(repository: Repository): Project { + return { + id: repository.repoAddress, + dtag: repository.dtag, + name: repository.name, + description: repository.description, + owner: repository.owner, + createdAt: repository.createdAt, + projectChannelId: null, + status: repository.status, + projectAddress: repository.repoAddress, + primaryRepositoryAddress: repository.repoAddress, + repositoryAddresses: [repository.repoAddress], + repositoryRelayHints: {}, + repositories: [repository], + unavailableRepositoryAddresses: [], + visibility: "listed", + legacy: true, + }; +} + +/** + * Builds the set of addressable coordinates that have been authoritatively + * deleted per NIP-09 semantics: the deletion signer must equal the coordinate + * owner, and the deletion's `created_at` must be ≥ the live head's timestamp. + * Returns a `Map` for threshold comparison. + */ +function buildDeletionThresholds( + deletionEvents: RelayEvent[], +): Map { + const thresholds = new Map(); + for (const event of deletionEvents) { + const signer = event.pubkey.toLowerCase(); + for (const tag of event.tags) { + if (tag[0] !== "a" || !tag[1]) continue; + const coordinate = tag[1]; + // The signer must be the owner of the coordinate. + const firstColon = coordinate.indexOf(":"); + const secondColon = coordinate.indexOf(":", firstColon + 1); + if (firstColon < 0 || secondColon < 0) continue; + const owner = coordinate.slice(firstColon + 1, secondColon).toLowerCase(); + if (owner !== signer) continue; + // Keep the latest (most permissive) deletion threshold. + const existing = thresholds.get(coordinate); + if (existing === undefined || event.created_at > existing) { + thresholds.set(coordinate, event.created_at); + } + } + } + return thresholds; +} + +export function buildProjectReadModels({ + projectEvents, + repositoryEvents, + deletionEvents = [], + relayOrigin, + hiddenAddresses = new Set(), +}: BuildProjectReadModelsInput): Project[] { + const deletionThresholds = buildDeletionThresholds(deletionEvents); + + /** Returns true when the event's addressable coordinate has been deleted. */ + function isDeleted(event: RelayEvent): boolean { + const dtag = event.tags.find((tag) => tag[0] === "d")?.[1]; + if (!dtag) return false; + const coordinate = `${event.kind}:${event.pubkey.toLowerCase()}:${dtag}`; + const threshold = deletionThresholds.get(coordinate); + return threshold !== undefined && event.created_at <= threshold; + } + + const repositories = deduplicateAddressableEvents(repositoryEvents) + .filter((event) => !isDeleted(event)) + .flatMap((event) => { + const repository = eventToRepository(event, relayOrigin); + return repository ? [repository] : []; + }); + const repositoriesByAddress = new Map( + repositories.map((repository) => [repository.repoAddress, repository]), + ); + const visibleRepositories = repositories.filter( + (repository) => !hiddenAddresses.has(repository.repoAddress), + ); + const visibleRepositoriesByAddress = new Map( + visibleRepositories.map((repository) => [ + repository.repoAddress, + repository, + ]), + ); + + const explicitProjects = deduplicateAddressableEvents(projectEvents) + .filter((event) => !isDeleted(event)) + .flatMap((event) => { + const project = eventToExplicitProject( + event, + repositoriesByAddress, + visibleRepositoriesByAddress, + ); + return project && + project.visibility === "listed" && + !hiddenAddresses.has(project.projectAddress) + ? [project] + : []; + }); + const claimedRepositories = new Set( + explicitProjects.flatMap((project) => + project.repositoryAddresses.filter((address) => { + const repository = repositoriesByAddress.get(address); + return ( + repository && + (repository.owner === project.owner || + repository.maintainers?.includes(project.owner)) + ); + }), + ), + ); + const legacyProjects = visibleRepositories + .filter((repository) => !claimedRepositories.has(repository.repoAddress)) + .map(repositoryToLegacyProject); + + return [...explicitProjects, ...legacyProjects].sort( + (left, right) => right.createdAt - left.createdAt, + ); +} + +export function selectProjectRepository( + project: Project | null | undefined, + requestedRepositoryId: string | null | undefined, +): Repository | null { + if (!project) return null; + + const requested = requestedRepositoryId + ? project.repositories.find( + (repository) => repository.id === requestedRepositoryId, + ) + : null; + if (requested) return requested; + + return ( + project.repositories.find( + (repository) => + repository.repoAddress === project.primaryRepositoryAddress, + ) ?? + project.repositories[0] ?? + null + ); +} + +/** Returns the optimistic read model after adding a resolved repository. */ +export function addRepositoryToProject( + project: Project, + repository: Repository, + createdAt: number, +): Project { + const projectAddress = `${KIND_PROJECT_ANNOUNCEMENT}:${project.owner}:${project.dtag}`; + const repositoryAddresses = [ + ...new Set([...project.repositoryAddresses, repository.repoAddress]), + ].sort(); + const repositories = [ + ...project.repositories.filter( + (candidate) => candidate.repoAddress !== repository.repoAddress, + ), + repository, + ].sort((left, right) => left.repoAddress.localeCompare(right.repoAddress)); + + return { + ...project, + id: projectAddress, + createdAt, + legacy: false, + projectAddress, + primaryRepositoryAddress: + repositories.find((candidate) => candidate.dtag === project.dtag) + ?.repoAddress ?? + repositories[0]?.repoAddress ?? + null, + repositoryAddresses, + repositories, + unavailableRepositoryAddresses: + project.unavailableRepositoryAddresses?.filter( + (address) => address !== repository.repoAddress, + ) ?? [], + }; +} diff --git a/desktop/src/features/projects/projectPullRequests.d.mts b/desktop/src/features/projects/projectPullRequests.d.mts index af865d2433..87e03f8d38 100644 --- a/desktop/src/features/projects/projectPullRequests.d.mts +++ b/desktop/src/features/projects/projectPullRequests.d.mts @@ -78,6 +78,8 @@ export type ProjectPullRequest = { repoAddress: string | null; /** Channel where the pull request originated (`h` tag), when provided. */ channelId: string | null; + /** Agent display name retained instead of a private conversation ID. */ + originAgentName: string | null; labels: string[]; recipients: string[]; /** Requested reviewers (root `p` tags + trusted review-request comments). */ diff --git a/desktop/src/features/projects/projectPullRequests.mjs b/desktop/src/features/projects/projectPullRequests.mjs index 3eebaa74f0..044f421815 100644 --- a/desktop/src/features/projects/projectPullRequests.mjs +++ b/desktop/src/features/projects/projectPullRequests.mjs @@ -364,6 +364,7 @@ export function eventToProjectPullRequest( createdAt: pullRequest.created_at, repoAddress: getTag(pullRequest, "a") ?? null, channelId: getTag(pullRequest, "h") ?? null, + originAgentName: getTag(pullRequest, "buzz-origin-agent") ?? null, labels: getAllTags(pullRequest, "t"), recipients: getAllTags(pullRequest, "p"), reviewers, diff --git a/desktop/src/features/projects/projectPullRequests.test.mjs b/desktop/src/features/projects/projectPullRequests.test.mjs index 9374604818..75a9877460 100644 --- a/desktop/src/features/projects/projectPullRequests.test.mjs +++ b/desktop/src/features/projects/projectPullRequests.test.mjs @@ -49,6 +49,15 @@ test("preserves an optional source channel from the pull request", () => { assert.equal(eventToProjectPullRequest(pullRequestEvent()).channelId, null); }); +test("preserves a private-safe agent origin without a channel ID", () => { + const event = pullRequestEvent(); + event.tags.push(["buzz-origin-agent", "Builder"]); + + const pullRequest = eventToProjectPullRequest(event); + assert.equal(pullRequest.channelId, null); + assert.equal(pullRequest.originAgentName, "Builder"); +}); + function updateEvent({ pubkey, createdAt, commit, cloneUrl }) { return { id: `update-${pubkey.slice(0, 8)}-${createdAt}`, diff --git a/desktop/src/features/projects/projectRepositoryCreation.test.mjs b/desktop/src/features/projects/projectRepositoryCreation.test.mjs new file mode 100644 index 0000000000..0a80e382c9 --- /dev/null +++ b/desktop/src/features/projects/projectRepositoryCreation.test.mjs @@ -0,0 +1,347 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildRepositoryChannelBindingTemplate, + buildProjectPatchTemplate, + buildAddedRepositoryEventTemplatesFromHead, +} from "./projectRepositoryCreation.ts"; +import { validateProjectEventEnvelope } from "./projectModels.ts"; + +const OWNER = "a".repeat(64); + +test("buildRepositoryChannelBindingTemplate preserves repository metadata", () => { + const repository = { + id: `${OWNER}:desktop`, + dtag: "desktop", + name: "Desktop", + description: "Desktop app", + owner: OWNER, + createdAt: 1, + repoAddress: `30617:${OWNER}:desktop`, + eventContent: "Desktop app", + eventTags: [ + ["d", "desktop"], + ["name", "Desktop"], + ["x-custom", "preserve-me"], + ], + }; + const template = buildRepositoryChannelBindingTemplate({ + channelId: "11111111-1111-4111-8111-111111111111", + ownerPubkey: OWNER, + repository, + }); + + assert.equal(template.content, "Desktop app"); + assert.deepEqual(template.tags, [ + ["d", "desktop"], + ["name", "Desktop"], + ["x-custom", "preserve-me"], + ["buzz-channel", "11111111-1111-4111-8111-111111111111"], + ]); +}); + +// ── buildProjectPatchTemplate: unknown-tag preservation ──────────────────── + +test("buildProjectPatchTemplate preserves unknown tags from the live head", () => { + const OWNER = "a".repeat(64); + const existingAddress = `30617:${OWNER}:desktop`; + const liveHead = { + id: "e".repeat(64), + kind: 30621, + pubkey: OWNER, + created_at: 100, + content: "ignored-by-nip-mp", + tags: [ + ["d", "platform"], + ["name", "Platform"], + ["description", "Multi-repo project"], + ["buzz-channel", "11111111-1111-4111-8111-111111111111"], + ["future-metadata", "preserve-me"], + ["alt", "extension tag that must survive round-trip"], + ["a", existingAddress], + ], + }; + const newAddress = `30617:${OWNER}:mobile`; + const template = buildProjectPatchTemplate({ + liveHead, + ownerPubkey: OWNER, + repositoryAddresses: [existingAddress, newAddress], + }); + + // Content must be preserved verbatim (NIP-MP §content). + assert.equal(template.content, "ignored-by-nip-mp"); + + // Non-`a` tags — including unknown ones — must survive in order. + const nonMemberTags = template.tags.filter((t) => t[0] !== "a"); + assert.deepEqual(nonMemberTags, [ + ["d", "platform"], + ["name", "Platform"], + ["description", "Multi-repo project"], + ["buzz-channel", "11111111-1111-4111-8111-111111111111"], + ["future-metadata", "preserve-me"], + ["alt", "extension tag that must survive round-trip"], + ]); + + // New address must be added; sorted order maintained. + const memberTags = template.tags.filter((t) => t[0] === "a").map((t) => t[1]); + assert.deepEqual(memberTags.sort(), [existingAddress, newAddress].sort()); +}); + +test("buildProjectPatchTemplate preserves relay hints on existing members", () => { + const OWNER = "a".repeat(64); + const OTHER = "b".repeat(64); + const existingWithHint = `30617:${OTHER}:relay`; + const liveHead = { + id: "e".repeat(64), + kind: 30621, + pubkey: OWNER, + created_at: 100, + content: "", + tags: [ + ["d", "platform"], + ["a", existingWithHint, "wss://relay.example"], + ], + }; + const newAddress = `30617:${OWNER}:mobile`; + const template = buildProjectPatchTemplate({ + liveHead, + ownerPubkey: OWNER, + repositoryAddresses: [existingWithHint, newAddress], + }); + + const existingTag = template.tags.find( + (t) => t[0] === "a" && t[1] === existingWithHint, + ); + assert.ok(existingTag, "existing member tag must be present"); + assert.equal( + existingTag[2], + "wss://relay.example", + "relay hint must be preserved", + ); +}); + +test("buildProjectPatchTemplate rejects a non-owner caller", () => { + const OWNER = "a".repeat(64); + const OTHER = "b".repeat(64); + const liveHead = { + id: "e".repeat(64), + kind: 30621, + pubkey: OWNER, + created_at: 100, + content: "", + tags: [["d", "platform"]], + }; + assert.throws( + () => + buildProjectPatchTemplate({ + liveHead, + ownerPubkey: OTHER, + repositoryAddresses: [], + }), + /Only the project owner/, + ); +}); + +test("buildProjectPatchTemplate rejects a repository address list exceeding 64 members", () => { + const OWNER = "a".repeat(64); + const liveHead = { + id: "e".repeat(64), + kind: 30621, + pubkey: OWNER, + created_at: 100, + content: "", + tags: [["d", "wide"]], + }; + const tooMany = Array.from( + { length: 65 }, + (_, i) => `30617:${"a".repeat(64)}:repo-${String(i).padStart(2, "0")}`, + ); + assert.throws( + () => + buildProjectPatchTemplate({ + liveHead, + ownerPubkey: OWNER, + repositoryAddresses: tooMany, + }), + /64/, + ); +}); + +// ── buildAddedRepositoryEventTemplatesFromHead: racing writer ─────────────── + +test("buildAddedRepositoryEventTemplatesFromHead detects a concurrent add via the live head", () => { + const OWNER = "a".repeat(64); + const existingAddress = `30617:${OWNER}:desktop`; + const newDtag = "mobile"; + const newAddress = `30617:${OWNER}:${newDtag}`; + + // Live head already contains the address (another session snuck it in). + const liveHeadWithRace = { + id: "e".repeat(64), + kind: 30621, + pubkey: OWNER, + created_at: 150, + content: "", + tags: [ + ["d", "platform"], + ["a", existingAddress], + ["a", newAddress], // concurrent add + ], + }; + assert.throws( + () => + buildAddedRepositoryEventTemplatesFromHead({ + accessChannelId: "11111111-1111-4111-8111-111111111111", + existingRepositoryAddresses: [existingAddress], + liveHead: liveHeadWithRace, + name: "Mobile", + ownerPubkey: OWNER, + }), + /already contains.*mobile.*another session/, + ); +}); + +// ── validateProjectEventEnvelope: shared full-envelope validator ───────────── +// These tests pin the NIP-MP validation rules through the WRITE helper so that +// a nonconforming live head (e.g. from a permissive relay) is caught before +// Desktop signs and re-submits it. + +test("validateProjectEventEnvelope accepts a valid minimal envelope", () => { + assert.doesNotThrow(() => + validateProjectEventEnvelope([["d", "platform"]], ""), + ); +}); + +test("validateProjectEventEnvelope rejects missing d tag", () => { + assert.throws( + () => validateProjectEventEnvelope([["name", "X"]], ""), + /NIP-MP.*'d'/, + ); +}); + +test("validateProjectEventEnvelope rejects duplicate d tags", () => { + assert.throws( + () => + validateProjectEventEnvelope( + [ + ["d", "a"], + ["d", "b"], + ], + "", + ), + /NIP-MP.*'d'/, + ); +}); + +test("validateProjectEventEnvelope rejects duplicate name tags", () => { + assert.throws( + () => + validateProjectEventEnvelope( + [ + ["d", "platform"], + ["name", "X"], + ["name", "Y"], + ], + "", + ), + /NIP-MP.*duplicate.*'name'/, + ); +}); + +test("validateProjectEventEnvelope rejects a name tag that exceeds 256 bytes", () => { + const longName = "x".repeat(257); + assert.throws( + () => + validateProjectEventEnvelope( + [ + ["d", "platform"], + ["name", longName], + ], + "", + ), + /NIP-MP.*'name'.*256/, + ); +}); + +test("validateProjectEventEnvelope rejects a description tag that exceeds 2048 bytes", () => { + const longDesc = "x".repeat(2049); + assert.throws( + () => + validateProjectEventEnvelope( + [ + ["d", "platform"], + ["description", longDesc], + ], + "", + ), + /NIP-MP.*'description'.*2048/, + ); +}); + +test("validateProjectEventEnvelope rejects more than 64 a-tags", () => { + const tags = [["d", "wide"]]; + for (let i = 0; i < 65; i++) { + tags.push([ + "a", + `30617:${"a".repeat(64)}:repo-${String(i).padStart(2, "0")}`, + ]); + } + assert.throws(() => validateProjectEventEnvelope(tags, ""), /NIP-MP.*64/); +}); + +test("validateProjectEventEnvelope rejects a member address with uppercase owner hex", () => { + const upperAddr = `30617:${"A".repeat(64)}:desktop`; + assert.throws( + () => + validateProjectEventEnvelope( + [ + ["d", "platform"], + ["a", upperAddr], + ], + "", + ), + /NIP-MP.*invalid.*address/, + ); +}); + +test("validateProjectEventEnvelope rejects duplicate a-tag addresses", () => { + const addr = `30617:${"a".repeat(64)}:desktop`; + assert.throws( + () => + validateProjectEventEnvelope( + [ + ["d", "platform"], + ["a", addr], + ["a", addr], + ], + "", + ), + /NIP-MP.*duplicate.*address/, + ); +}); + +test("buildProjectPatchTemplate catches duplicate d in live head via full-envelope validation", () => { + // A relay that accepted a nonconforming event could serve a head with two d tags. + // buildProjectPatchTemplate must catch this before signing. + const badLiveHead = { + id: "e".repeat(64), + kind: 30621, + pubkey: OWNER, + created_at: 100, + content: "", + tags: [ + ["d", "platform"], + ["d", "extra"], + ], + }; + assert.throws( + () => + buildProjectPatchTemplate({ + liveHead: badLiveHead, + ownerPubkey: OWNER, + repositoryAddresses: [], + }), + /NIP-MP.*'d'/, + ); +}); diff --git a/desktop/src/features/projects/projectRepositoryCreation.ts b/desktop/src/features/projects/projectRepositoryCreation.ts new file mode 100644 index 0000000000..832350f901 --- /dev/null +++ b/desktop/src/features/projects/projectRepositoryCreation.ts @@ -0,0 +1,239 @@ +import type { RelayEvent } from "@/shared/api/types"; +import type { Repository } from "@/features/projects/hooks"; +import { + isValidProjectChannelId, + MAX_PROJECT_MEMBERS, + validateProjectEventEnvelope, +} from "@/features/projects/projectModels"; +import { + KIND_PROJECT_ANNOUNCEMENT, + KIND_REPO_ANNOUNCEMENT, +} from "@/shared/constants/kinds"; +import type { ProjectEventTemplate } from "./projectCreation"; + +function repositoryDtagFromName(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +/** + * Creates a project-replacement event template from a live, signed raw head + * (fetched immediately before the mutation). Only the `a` membership tags are + * patched; every other tag and the `content` field are preserved verbatim, + * satisfying NIP-MP's extension-tag preservation rule and preventing a cached + * UI projection from silently erasing unknown tags. + * + * Performs full NIP-MP envelope validation on the patched output via the shared + * `validateProjectEventEnvelope` validator — the same checks applied by the + * read parser — so Desktop's write path agrees with its read path on which + * heads are valid regardless of the relay in use. + */ +function buildProjectPatchTemplate({ + liveHead, + ownerPubkey, + repositoryAddresses, +}: { + liveHead: RelayEvent; + ownerPubkey: string; + repositoryAddresses: string[]; +}): ProjectEventTemplate { + const normalizedOwner = ownerPubkey.trim().toLowerCase(); + if (normalizedOwner !== liveHead.pubkey.toLowerCase()) { + throw new Error("Only the project owner can add repositories."); + } + if (repositoryAddresses.length > MAX_PROJECT_MEMBERS) { + throw new Error( + `A project cannot contain more than ${MAX_PROJECT_MEMBERS} repositories.`, + ); + } + if (new Set(repositoryAddresses).size !== repositoryAddresses.length) { + throw new Error("A project cannot contain duplicate repositories."); + } + if ( + repositoryAddresses.some( + (address) => !/^30617:[0-9a-f]{64}:.+$/.test(address), + ) + ) { + throw new Error("Repository address is invalid."); + } + + // Replace all existing `a` tags with the new set, preserving everything else + // (d, name, description, buzz-channel, buzz-visibility, relay hints embedded + // in `a` tags, and any future/unknown tags). + const nonMemberTags = liveHead.tags.filter((tag) => tag[0] !== "a"); + const existingHints = new Map(); + for (const tag of liveHead.tags) { + if (tag[0] === "a" && tag[1] && tag[2]) { + existingHints.set(tag[1], tag[2]); + } + } + const memberTags = repositoryAddresses.sort().map((address): string[] => { + const hint = existingHints.get(address); + return hint ? ["a", address, hint] : ["a", address]; + }); + + const patchedTags = [...nonMemberTags, ...memberTags]; + const content = liveHead.content; + + // Validate the full patched envelope against NIP-MP rules. This catches + // nonconforming live heads (e.g., from a relay that accepted a malformed + // event) before we sign and re-submit, and pins the write path to the same + // spec the read parser enforces: duplicate `d`, duplicate/oversized + // metadata, malformed member arity, and the 64-member boundary. + validateProjectEventEnvelope(patchedTags, content); + + return { + kind: KIND_PROJECT_ANNOUNCEMENT, + content, + tags: patchedTags, + }; +} + +export { buildProjectPatchTemplate }; + +export function buildRepositoryChannelBindingTemplate({ + channelId, + ownerPubkey, + repository, +}: { + channelId: string; + ownerPubkey: string; + repository: Repository; +}): ProjectEventTemplate { + const normalizedChannelId = channelId.trim(); + if (ownerPubkey.trim().toLowerCase() !== repository.owner.toLowerCase()) { + throw new Error("Only the repository owner can repair its access."); + } + if (!isValidProjectChannelId(normalizedChannelId)) { + throw new Error("Repository access channel is invalid."); + } + if (!repository.eventTags) { + throw new Error( + "Repository metadata is unavailable. Refresh and try again.", + ); + } + + return { + kind: KIND_REPO_ANNOUNCEMENT, + content: repository.eventContent ?? repository.description, + tags: [ + ...repository.eventTags + .filter((tag) => tag[0] !== "buzz-channel") + .map((tag) => [...tag]), + ["buzz-channel", normalizedChannelId], + ], + }; +} + +export type AddedRepositoryEventTemplatesFromHead = { + project: ProjectEventTemplate; + repository: ProjectEventTemplate; + repositoryAddress: string; + repositoryDtag: string; +}; + +/** + * Builds the project-replacement + new-repository templates for `addRepo`, + * patching the live signed project head rather than reconstructing from the + * cached UI projection. This is the preferred path: it preserves unknown tags + * and detects concurrent writes before they cause data loss. + * + * The caller is responsible for checking that `liveHead.created_at` is not + * newer than the cached project's `createdAt` + the caller's margin (i.e., a + * dominated-write guard) before using the result. + */ +export function buildAddedRepositoryEventTemplatesFromHead({ + accessChannelId, + cloneUrl, + description, + existingRepositoryAddresses, + liveHead, + name, + ownerPubkey, + webUrl, +}: { + accessChannelId?: string; + cloneUrl?: string; + description?: string; + existingRepositoryAddresses: string[]; + liveHead: RelayEvent; + name: string; + ownerPubkey: string; + webUrl?: string; +}): AddedRepositoryEventTemplatesFromHead { + const normalizedOwner = ownerPubkey.trim().toLowerCase(); + + const normalizedName = name.trim(); + if (!normalizedName) throw new Error("Repository name is required."); + const repositoryDtag = repositoryDtagFromName(normalizedName); + if (!repositoryDtag) { + throw new Error("Repository name must include letters or numbers."); + } + + const repositoryAddress = `${KIND_REPO_ANNOUNCEMENT}:${normalizedOwner}:${repositoryDtag}`; + + // Read live membership from the fetched head (not the cached projection). + const liveAddresses = liveHead.tags + .filter((tag) => tag[0] === "a" && tag[1]) + .map((tag) => tag[1] as string); + + // If the repo is already in the live head (race: another session added it), + // surface that to the caller. + if (liveAddresses.includes(repositoryAddress)) { + throw new Error( + `This project already contains "${repositoryDtag}" (it was added by another session).`, + ); + } + + // An "unavailable member" is a coordinate already in the project's address + // list (cached projection) but absent from resolved repositories. When this + // happens we keep the existing addresses and add nothing new. + const isUnavailableMember = + existingRepositoryAddresses.includes(repositoryAddress); + + const normalizedDescription = description?.trim() ?? ""; + const repositoryTags: string[][] = [ + ["d", repositoryDtag], + ["name", normalizedName], + ]; + const normalizedAccessChannelId = accessChannelId?.trim(); + if (!normalizedAccessChannelId) { + throw new Error( + "This project has no repository access channel to inherit.", + ); + } + if (!isValidProjectChannelId(normalizedAccessChannelId)) { + throw new Error("Repository access channel is invalid."); + } + repositoryTags.push(["buzz-channel", normalizedAccessChannelId]); + if (normalizedDescription) { + repositoryTags.push(["description", normalizedDescription]); + } + const normalizedCloneUrl = cloneUrl?.trim(); + if (normalizedCloneUrl) repositoryTags.push(["clone", normalizedCloneUrl]); + const normalizedWebUrl = webUrl?.trim(); + if (normalizedWebUrl) repositoryTags.push(["web", normalizedWebUrl]); + + const newAddresses = isUnavailableMember + ? [...liveAddresses] + : [...liveAddresses, repositoryAddress]; + + const projectTemplate = buildProjectPatchTemplate({ + liveHead, + ownerPubkey, + repositoryAddresses: newAddresses, + }); + + return { + project: projectTemplate, + repository: { + kind: KIND_REPO_ANNOUNCEMENT, + content: normalizedDescription, + tags: repositoryTags, + }, + repositoryAddress, + repositoryDtag, + }; +} diff --git a/desktop/src/features/projects/projectRoutes.ts b/desktop/src/features/projects/projectRoutes.ts new file mode 100644 index 0000000000..2862c11d1e --- /dev/null +++ b/desktop/src/features/projects/projectRoutes.ts @@ -0,0 +1,73 @@ +import { + KIND_PROJECT_ANNOUNCEMENT, + KIND_REPO_ANNOUNCEMENT, +} from "@/shared/constants/kinds"; +import type { Project } from "./projectModels"; + +function parseProjectRouteId(projectId: string): { + address: string | null; + owner: string | null; + dtag: string; +} { + const firstSeparator = projectId.indexOf(":"); + const secondSeparator = projectId.indexOf(":", firstSeparator + 1); + const kind = Number(projectId.slice(0, firstSeparator)); + if ( + secondSeparator > 0 && + (kind === KIND_PROJECT_ANNOUNCEMENT || kind === KIND_REPO_ANNOUNCEMENT) + ) { + const owner = projectId.slice(firstSeparator + 1, secondSeparator); + if (/^[0-9a-fA-F]{64}$/.test(owner)) { + const normalizedOwner = owner.toLowerCase(); + const dtag = projectId.slice(secondSeparator + 1); + return { + address: `${kind}:${normalizedOwner}:${dtag}`, + owner: normalizedOwner, + dtag, + }; + } + } + + const owner = projectId.slice(0, 64); + if (projectId[64] === ":" && /^[0-9a-fA-F]{64}$/.test(owner)) { + return { + address: null, + owner: owner.toLowerCase(), + dtag: projectId.slice(65), + }; + } + return { address: null, owner: null, dtag: projectId }; +} + +/** + * Matches a project against a route id. + * + * The route id may be: + * - A canonical project coordinate: `30621::` → exact address match. + * - A canonical repository coordinate: `30617::` → matches any + * project that contains that repository address, enabling entity links from + * PR/issue deep links to land on the correct project regardless of container + * placement. This is the contract Hayt's entity-link routing expects. + * - A legacy `:` form → dtag + owner match. + */ +export function projectMatchesRouteId( + project: Project, + projectId: string, +): boolean { + const { address, owner, dtag } = parseProjectRouteId(projectId); + + // Canonical 30617 coordinate: resolve to the project that contains this repo. + if ( + address && + Number(projectId.slice(0, projectId.indexOf(":"))) === + KIND_REPO_ANNOUNCEMENT + ) { + return project.repositoryAddresses.includes(address); + } + + return ( + (!address || project.projectAddress === address) && + project.dtag === dtag && + (!owner || project.owner.toLowerCase() === owner) + ); +} diff --git a/desktop/src/features/projects/projectWorkItems.test.mjs b/desktop/src/features/projects/projectWorkItems.test.mjs new file mode 100644 index 0000000000..21ee577b54 --- /dev/null +++ b/desktop/src/features/projects/projectWorkItems.test.mjs @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { fetchProjectsWorkItems } from "./projectWorkItems.ts"; + +// ── Work-item deduplication ───────────────────────────────────────────────── +// +// NIP-MP §Multiple membership: a repository may belong to several projects. +// When it does, global issue/PR lists must produce exactly one row per work +// item — not one row per project membership. These tests call the exported +// production function with a stubbed fetchEvents to verify the dedup contract +// end-to-end, not just the filter algorithm in isolation. + +const REPO_OWNER = "a".repeat(64); +const REPO_DTAG = "relay"; +const REPO_ADDRESS = `30617:${REPO_OWNER}:${REPO_DTAG}`; + +const ISSUE_ID = "i".repeat(64); +const PR_ID = "p".repeat(64); +const PR_ID_2 = "q".repeat(64); + +// Two projects that both contain the same repository. +const projectA = { + repositories: [{ repoAddress: REPO_ADDRESS }], +}; +const projectB = { + repositories: [{ repoAddress: REPO_ADDRESS }], +}; + +// Minimal valid NIP-34 issue event for the shared repo. +function makeIssue(id, updatedAt = 100) { + return { + id, + kind: 1621, + pubkey: REPO_OWNER, + created_at: updatedAt, + content: "An issue", + tags: [ + ["a", REPO_ADDRESS], + ["subject", "Fix the thing"], + ], + }; +} + +// Minimal valid NIP-34 pull request event for the shared repo. +function makePR(id, updatedAt = 100) { + return { + id, + kind: 1618, // KIND_GIT_PULL_REQUEST + pubkey: REPO_OWNER, + created_at: updatedAt, + content: "A PR", + tags: [ + ["a", REPO_ADDRESS], + ["subject", "Add a feature"], + ], + }; +} + +// fetchEvents stub: returns the given root events (issues + PRs) and empty +// arrays for all other query kinds (updates, comments, statuses). +function makeFetchEvents(rootEvents) { + return async (filter) => { + const { kinds } = filter; + // Root issues (kind 1621) + PRs (kind 1618) + if (kinds?.includes(1621) || kinds?.includes(1618)) { + return rootEvents.filter((e) => kinds.includes(e.kind)); + } + // Everything else (PR updates, comments, statuses) — empty + return []; + }; +} + +test("fetchProjectsWorkItems deduplicates issues from a shared repository", async () => { + const issue = makeIssue(ISSUE_ID); + const fetchEvents = makeFetchEvents([issue]); + + const result = await fetchProjectsWorkItems( + [projectA, projectB], + fetchEvents, + ); + + assert.equal( + result.issues.items.length, + 1, + "duplicate issue from shared repo must collapse to one row", + ); + assert.equal(result.issues.items[0].issue.id, ISSUE_ID); +}); + +test("fetchProjectsWorkItems deduplicates pull requests from a shared repository", async () => { + const pr1 = makePR(PR_ID, 100); + const pr2 = makePR(PR_ID_2, 90); + const fetchEvents = makeFetchEvents([pr1, pr2]); + + const result = await fetchProjectsWorkItems( + [projectA, projectB], + fetchEvents, + ); + + assert.equal( + result.pullRequests.items.length, + 2, + "distinct PRs must survive dedup; only exact-id duplicates collapse", + ); + const ids = result.pullRequests.items.map((item) => item.pullRequest.id); + assert.ok(ids.includes(PR_ID), "first PR must be present"); + assert.ok(ids.includes(PR_ID_2), "second PR must be present"); +}); + +test("fetchProjectsWorkItems returns a single row for a PR present in both project contexts", async () => { + // Same PR id returned twice (once per project's relay query). + const pr = makePR(PR_ID, 100); + // The stub returns the same event for every root query, simulating + // the relay returning the same PR for both projects' repo addresses. + let callCount = 0; + const fetchEvents = async (filter) => { + if (filter.kinds?.includes(1618)) { + callCount += 1; + return [pr]; + } + return []; + }; + + const result = await fetchProjectsWorkItems( + [projectA, projectB], + fetchEvents, + ); + + // The relay was queried once per unique repo address — but even if it + // returned the same id twice across the two project contexts, dedupe fires. + assert.equal( + result.pullRequests.items.length, + 1, + "same PR id appearing in both project contexts must produce one row", + ); + // Sanity: the stub was actually called (proves we ran the production path). + assert.ok(callCount >= 1, "fetchEvents must have been called"); +}); diff --git a/desktop/src/features/projects/projectWorkItems.ts b/desktop/src/features/projects/projectWorkItems.ts index a2f0047703..c2170e687a 100644 --- a/desktop/src/features/projects/projectWorkItems.ts +++ b/desktop/src/features/projects/projectWorkItems.ts @@ -20,10 +20,17 @@ import { projectPullRequestEventsToPullRequests, } from "./projectPullRequests.mjs"; -type ProjectReference = { +type RepositoryReference = { repoAddress: string; }; +type ProjectReference = { + repositories: RepositoryReference[]; +}; + +type ProjectRepository = + TProject["repositories"][number]; + /** Optional event groups that can fail without discarding root work items. */ export type ProjectWorkItemSection = | "comments" @@ -33,11 +40,19 @@ export type ProjectWorkItemSection = /** Aggregate work items plus any optional event groups that failed to load. */ export type ProjectsWorkItemsResult = { issues: { - items: Array<{ project: TProject; issue: ProjectIssue }>; + items: Array<{ + project: TProject; + repository: ProjectRepository; + issue: ProjectIssue; + }>; failedSections: ProjectWorkItemSection[]; }; pullRequests: { - items: Array<{ project: TProject; pullRequest: ProjectPullRequest }>; + items: Array<{ + project: TProject; + repository: ProjectRepository; + pullRequest: ProjectPullRequest; + }>; failedSections: ProjectWorkItemSection[]; }; }; @@ -54,31 +69,40 @@ function groupByRepoAddress(events: RelayEvent[]): Map { return grouped; } +type FetchEventsInput = Parameters<(typeof relayClient)["fetchEvents"]>[0]; + /** Loads aggregate issue and pull-request data with bounded relay fan-out. */ export async function fetchProjectsWorkItems( projects: TProject[], + fetchEvents: ( + filter: FetchEventsInput, + ) => Promise = relayClient.fetchEvents.bind(relayClient), ): Promise> { const repoAddresses = [ - ...new Set(projects.map((project) => project.repoAddress)), + ...new Set( + projects.flatMap((project) => + project.repositories.map((repository) => repository.repoAddress), + ), + ), ]; const [rootResult, updateResult, commentResult, statusResult] = await Promise.allSettled([ - relayClient.fetchEvents({ + fetchEvents({ kinds: [KIND_GIT_ISSUE, KIND_GIT_PULL_REQUEST], "#a": repoAddresses, limit: 2_000, }), - relayClient.fetchEvents({ + fetchEvents({ kinds: [KIND_GIT_PR_UPDATE], "#a": repoAddresses, limit: 2_000, }), - relayClient.fetchEvents({ + fetchEvents({ kinds: [KIND_TEXT_NOTE], "#a": repoAddresses, limit: 2_000, }), - relayClient.fetchEvents({ + fetchEvents({ kinds: [ KIND_GIT_STATUS_OPEN, KIND_GIT_STATUS_MERGED, @@ -109,27 +133,64 @@ export async function fetchProjectsWorkItems( const pullRequests = projects .flatMap((project) => - projectPullRequestEventsToPullRequests( - (rootsByRepo.get(project.repoAddress) ?? []).filter( - (event) => event.kind === KIND_GIT_PULL_REQUEST, - ), - updatesByRepo.get(project.repoAddress) ?? [], - commentsByRepo.get(project.repoAddress) ?? [], - statusesByRepo.get(project.repoAddress) ?? [], - ).map((pullRequest) => ({ project, pullRequest })), + project.repositories.flatMap((repository) => + projectPullRequestEventsToPullRequests( + (rootsByRepo.get(repository.repoAddress) ?? []).filter( + (event) => event.kind === KIND_GIT_PULL_REQUEST, + ), + updatesByRepo.get(repository.repoAddress) ?? [], + commentsByRepo.get(repository.repoAddress) ?? [], + statusesByRepo.get(repository.repoAddress) ?? [], + ).map((pullRequest) => ({ project, pullRequest, repository })), + ), + ) + // Deduplicate by (repoAddress, pull-request id): a repository in N projects + // must produce exactly one aggregate row per pull request (NIP-MP §Multiple + // membership). First occurrence wins; that project's navigation context is + // kept for the row. + .filter( + (() => { + const seen = new Set(); + return (item: { + repository: RepositoryReference; + pullRequest: ProjectPullRequest; + }) => { + const key = `${item.repository.repoAddress}:${item.pullRequest.id}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }; + })(), ) .sort( (left, right) => right.pullRequest.updatedAt - left.pullRequest.updatedAt, ); const issues = projects .flatMap((project) => - projectIssueEventsToIssues( - (rootsByRepo.get(project.repoAddress) ?? []).filter( - (event) => event.kind === KIND_GIT_ISSUE, - ), - statusesByRepo.get(project.repoAddress) ?? [], - commentsByRepo.get(project.repoAddress) ?? [], - ).map((issue) => ({ project, issue })), + project.repositories.flatMap((repository) => + projectIssueEventsToIssues( + (rootsByRepo.get(repository.repoAddress) ?? []).filter( + (event) => event.kind === KIND_GIT_ISSUE, + ), + statusesByRepo.get(repository.repoAddress) ?? [], + commentsByRepo.get(repository.repoAddress) ?? [], + ).map((issue) => ({ issue, project, repository })), + ), + ) + // Deduplicate by (repoAddress, issue id). + .filter( + (() => { + const seen = new Set(); + return (item: { + repository: RepositoryReference; + issue: ProjectIssue; + }) => { + const key = `${item.repository.repoAddress}:${item.issue.id}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }; + })(), ) .sort((left, right) => right.issue.updatedAt - left.issue.updatedAt); const sharedFailedSections: ProjectWorkItemSection[] = []; diff --git a/desktop/src/features/projects/pullRequestMutations.ts b/desktop/src/features/projects/pullRequestMutations.ts index 4eae6464bf..160c0a3403 100644 --- a/desktop/src/features/projects/pullRequestMutations.ts +++ b/desktop/src/features/projects/pullRequestMutations.ts @@ -12,7 +12,7 @@ import { KIND_GIT_PULL_REQUEST, } from "@/shared/constants/kinds"; import { normalizePubkey } from "@/shared/lib/pubkey"; -import type { Project, ProjectPullRequest } from "./hooks"; +import type { ProjectPullRequest, Repository as Project } from "./hooks"; import { nextProjectPullRequestStatusCreatedAt } from "./projectPullRequests.mjs"; import { useProjectPullRequestWriteInvalidation } from "./pullRequestReviews"; diff --git a/desktop/src/features/projects/pullRequestReviews.ts b/desktop/src/features/projects/pullRequestReviews.ts index aa72c31643..ed6484385a 100644 --- a/desktop/src/features/projects/pullRequestReviews.ts +++ b/desktop/src/features/projects/pullRequestReviews.ts @@ -14,7 +14,7 @@ import { KIND_GIT_STATUS_OPEN, KIND_TEXT_NOTE, } from "@/shared/constants/kinds"; -import type { Project } from "./hooks"; +import type { Repository as Project } from "./hooks"; import { nextProjectPullRequestStatusCreatedAt, type ProjectPullRequest, diff --git a/desktop/src/features/projects/repoSyncHooks.ts b/desktop/src/features/projects/repoSyncHooks.ts index 457ccca175..2a25f5d78f 100644 --- a/desktop/src/features/projects/repoSyncHooks.ts +++ b/desktop/src/features/projects/repoSyncHooks.ts @@ -6,7 +6,11 @@ import { pullProjectLocalRepository, pushProjectLocalRepository, } from "@/shared/api/projectGit"; -import type { Project, ProjectPullRequest } from "@/features/projects/hooks"; +import type { + ProjectPullRequest, + Repository as Project, +} from "@/features/projects/hooks"; +import { useProjectRepoHost } from "@/features/projects/useProjectRepoHost"; import { publishProjectPullRequestUpdate } from "./pullRequestMutations"; /** Local-vs-remote git sync status for a project checkout (ahead/behind @@ -21,9 +25,10 @@ export function useProjectRepoSyncStatusQuery( ) { const selectedBranch = branchName ?? project?.defaultBranch ?? null; const selectedBaseBranch = baseBranch ?? project?.defaultBranch ?? null; + const host = useProjectRepoHost(project); return useQuery({ - enabled: Boolean(project?.cloneUrls[0]), + enabled: Boolean(host.kind === "buzz" && project?.cloneUrls[0]), queryKey: [ "project", project?.id ?? "none", diff --git a/desktop/src/features/projects/repositoryActivityHooks.ts b/desktop/src/features/projects/repositoryActivityHooks.ts new file mode 100644 index 0000000000..10733116a8 --- /dev/null +++ b/desktop/src/features/projects/repositoryActivityHooks.ts @@ -0,0 +1,32 @@ +import { useQuery } from "@tanstack/react-query"; +import * as React from "react"; + +import { + fetchRepositoryActivitySummaries, + type Project, +} from "@/features/projects/hooks"; + +/** Fetches repository-specific activity for the repositories in these projects. */ +export function useRepositoryActivitySummariesQuery(projects: Project[]) { + const repositories = React.useMemo( + () => [ + ...new Map( + projects + .flatMap((project) => project.repositories) + .map((repository) => [repository.repoAddress, repository]), + ).values(), + ], + [projects], + ); + const repoAddresses = React.useMemo( + () => repositories.map((repository) => repository.repoAddress).sort(), + [repositories], + ); + + return useQuery({ + enabled: repoAddresses.length > 0, + queryKey: ["projects", "activity-summaries", "repositories", repoAddresses], + queryFn: () => fetchRepositoryActivitySummaries(repositories), + staleTime: 30_000, + }); +} diff --git a/desktop/src/features/projects/ui/AddProjectRepositoryDialog.tsx b/desktop/src/features/projects/ui/AddProjectRepositoryDialog.tsx new file mode 100644 index 0000000000..609c38a2cc --- /dev/null +++ b/desktop/src/features/projects/ui/AddProjectRepositoryDialog.tsx @@ -0,0 +1,199 @@ +import * as React from "react"; + +import type { Project } from "@/features/projects/hooks"; +import type { AddProjectRepositoryInput } from "@/features/projects/useAddProjectRepository"; +import type { Channel } from "@/shared/api/types"; +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"; + +const FIELD_SHELL_CLASS = + "flex min-h-11 items-center rounded-xl border border-input bg-muted/40 px-3 transition-colors hover:border-muted-foreground/40 focus-within:border-muted-foreground/50"; +const FIELD_CONTROL_CLASS = + "h-8 border-0 bg-transparent px-0 py-0 text-muted-foreground/55 shadow-none outline-none ring-0 placeholder:text-muted-foreground/55 focus:bg-transparent focus:text-foreground focus-visible:ring-0"; + +export function AddProjectRepositoryDialog({ + accessChannelId, + channels, + isCreating, + onAdd, + onOpenChange, + open, + project, +}: { + accessChannelId?: string; + channels: Channel[]; + isCreating: boolean; + onAdd: (input: AddProjectRepositoryInput) => Promise; + onOpenChange: (open: boolean) => void; + open: boolean; + project: Project; +}) { + const [name, setName] = React.useState(""); + const [cloneUrl, setCloneUrl] = React.useState(""); + const [selectedChannelId, setSelectedChannelId] = React.useState(""); + const [errorMessage, setErrorMessage] = React.useState(null); + const nameInputRef = React.useRef(null); + + React.useEffect(() => { + if (!open) return; + setName(""); + setCloneUrl(""); + setSelectedChannelId(accessChannelId ?? ""); + setErrorMessage(null); + const timerId = globalThis.setTimeout( + () => nameInputRef.current?.focus(), + 50, + ); + return () => globalThis.clearTimeout(timerId); + }, [accessChannelId, open]); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (!name.trim() || !selectedChannelId) return; + setErrorMessage(null); + try { + await onAdd({ + accessChannelId: selectedChannelId, + cloneUrl: cloneUrl.trim() || undefined, + name: name.trim(), + project, + }); + onOpenChange(false); + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : "Failed to add repository.", + ); + } + } + + return ( + { + if (!nextOpen && isCreating) return; + onOpenChange(nextOpen); + }} + open={open} + > + + {isCreating ? "Adding..." : "Add repository"} + + } + footerClassName="border-t-0 pt-0" + headerClassName="pb-2" + title="Add repository" + > +
void handleSubmit(event)} + > +
+ +
+ { + setName(event.target.value); + setErrorMessage(null); + }} + placeholder="mobile-app" + ref={nameInputRef} + spellCheck={false} + value={name} + /> +
+
+
+ +
+ +
+

+ Members of this channel can access the repository. +

+
+
+ +
+ { + setCloneUrl(event.target.value); + setErrorMessage(null); + }} + placeholder="https://relay.example.com/git/mobile-app.git" + spellCheck={false} + value={cloneUrl} + /> +
+
+ {errorMessage ? ( +

{errorMessage}

+ ) : null} +
+
+
+ ); +} diff --git a/desktop/src/features/projects/ui/AttachProjectRepositoryDialog.tsx b/desktop/src/features/projects/ui/AttachProjectRepositoryDialog.tsx new file mode 100644 index 0000000000..8c4c080c61 --- /dev/null +++ b/desktop/src/features/projects/ui/AttachProjectRepositoryDialog.tsx @@ -0,0 +1,92 @@ +import { FolderGit2 } from "lucide-react"; +import * as React from "react"; + +import type { Project, Repository } from "@/features/projects/hooks"; +import { Button } from "@/shared/ui/button"; +import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; +import { Dialog } from "@/shared/ui/dialog"; + +export function AttachProjectRepositoryDialog({ + isAttaching, + onAttach, + onOpenChange, + open, + project, + repositories, +}: { + isAttaching: boolean; + onAttach: (repository: Repository) => Promise; + onOpenChange: (open: boolean) => void; + open: boolean; + project: Project; + repositories: Repository[]; +}) { + const [errorMessage, setErrorMessage] = React.useState(null); + + React.useEffect(() => { + if (open) setErrorMessage(null); + }, [open]); + + async function handleAttach(repository: Repository) { + setErrorMessage(null); + try { + await onAttach(repository); + onOpenChange(false); + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : "Failed to attach repository.", + ); + } + } + + return ( + { + if (!nextOpen && isAttaching) return; + onOpenChange(nextOpen); + }} + open={open} + > + +
+ {repositories.length === 0 ? ( +

+ Every available repository is already in this project. +

+ ) : ( + repositories.map((repository) => ( + + )) + )} + {errorMessage ? ( +

{errorMessage}

+ ) : null} +
+
+
+ ); +} diff --git a/desktop/src/features/projects/ui/CreateProjectDialog.tsx b/desktop/src/features/projects/ui/CreateProjectDialog.tsx index c5e6c2670c..ff214d8084 100644 --- a/desktop/src/features/projects/ui/CreateProjectDialog.tsx +++ b/desktop/src/features/projects/ui/CreateProjectDialog.tsx @@ -1,5 +1,6 @@ import * as React from "react"; +import { useChannelsQuery } from "@/features/channels/hooks"; import type { CreateProjectInput } from "@/features/projects/useCreateProject"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; @@ -22,7 +23,7 @@ type CreateProjectDialogProps = { open: boolean; }; -/** Modal for publishing a new project (NIP-34 repo announcement). */ +/** Modal for publishing a project with its initial NIP-34 repository. */ export function CreateProjectDialog({ isCreating, onCreate, @@ -33,8 +34,20 @@ export function CreateProjectDialog({ const [description, setDescription] = React.useState(""); const [cloneUrl, setCloneUrl] = React.useState(""); const [webUrl, setWebUrl] = React.useState(""); + const [accessChannelId, setAccessChannelId] = React.useState(""); const [errorMessage, setErrorMessage] = React.useState(null); const nameInputRef = React.useRef(null); + const channelsQuery = useChannelsQuery({ enabled: open }); + const accessChannels = React.useMemo( + () => + (channelsQuery.data ?? []).filter( + (channel) => + channel.isMember && + !channel.archivedAt && + channel.channelType !== "dm", + ), + [channelsQuery.data], + ); React.useEffect(() => { if (!open) return; @@ -43,6 +56,7 @@ export function CreateProjectDialog({ setDescription(""); setCloneUrl(""); setWebUrl(""); + setAccessChannelId(accessChannels[0]?.id ?? ""); setErrorMessage(null); // Small delay to let the dialog animation start before focusing. @@ -50,18 +64,19 @@ export function CreateProjectDialog({ nameInputRef.current?.focus(); }, 50); return () => globalThis.clearTimeout(timerId); - }, [open]); + }, [accessChannels, open]); async function handleSubmit(event: React.FormEvent) { event.preventDefault(); const trimmedName = name.trim(); - if (!trimmedName) return; + if (!trimmedName || !accessChannelId) return; setErrorMessage(null); try { await onCreate({ + accessChannelId, name: trimmedName, description: description.trim() || undefined, cloneUrl: cloneUrl.trim() || undefined, @@ -88,12 +103,14 @@ export function CreateProjectDialog({ className="max-w-lg" contentClassName="pt-3" data-testid="create-project-dialog" - description="Projects are repositories published to this workspace's relay." + description="Projects group one or more repositories published to this workspace's relay." footer={
+
+ +
+ +
+

+ Members of this channel can access project repositories. +

+
+
- Web URL + Initial repository web URL Optional
void | Promise; + onCreated: ( + project: Project, + repository: Repository, + issueId: string, + ) => void | Promise; onOpenChange: (open: boolean) => void; open: boolean; projects: Project[]; }) { + const repositoryOptions = React.useMemo( + () => + projects.flatMap((project) => + project.repositories.map((repository) => ({ project, repository })), + ), + [projects], + ); 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); + const [repositoryId, setRepositoryId] = React.useState( + selectProjectRepository(initialProject, null)?.id ?? "", + ); + const selection = + repositoryOptions.find( + (candidate) => candidate.repository.id === repositoryId, + ) ?? repositoryOptions[0]; + const project = selection?.project; + const repository = selection?.repository; + const createMutation = useCreateProjectIssueMutation(repository); React.useEffect(() => { if (!open) return; const nextProject = projects.find((candidate) => candidate.id === initialProjectId) ?? projects[0]; - setProjectId(nextProject?.id ?? ""); + setRepositoryId(selectProjectRepository(nextProject, null)?.id ?? ""); }, [initialProjectId, open, projects]); async function handleCreate(input: CreateProjectWorkItemDialogInput) { - if (!project) throw new Error("Choose a repository."); + if (!project || !repository) throw new Error("Choose a repository."); const issueId = await createMutation.mutateAsync(input); toast.success("Issue created."); - await onCreated(project, issueId); + await onCreated(project, repository, issueId); } return ( @@ -66,12 +84,17 @@ export function CreateProjectIssueDialog({ className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm font-normal outline-hidden focus:ring-1 focus:ring-ring" data-testid="create-issue-repository" disabled={createMutation.isPending} - onChange={(event) => setProjectId(event.target.value)} - value={project?.id ?? ""} + onChange={(event) => setRepositoryId(event.target.value)} + value={repository?.id ?? ""} > - {projects.map((candidate) => ( - ))} diff --git a/desktop/src/features/projects/ui/CreatePullRequestDialog.tsx b/desktop/src/features/projects/ui/CreatePullRequestDialog.tsx index d64d132aed..11ba74d8d8 100644 --- a/desktop/src/features/projects/ui/CreatePullRequestDialog.tsx +++ b/desktop/src/features/projects/ui/CreatePullRequestDialog.tsx @@ -3,9 +3,11 @@ import { toast } from "sonner"; import { type Project, + type Repository, useProjectPullRequestsQuery, useRepoStateQuery, } from "@/features/projects/hooks"; +import { selectProjectRepository } from "@/features/projects/projectModels"; import { useCreateProjectPullRequestMutation } from "@/features/projects/pullRequestMutations"; import { useProjectRepoSyncStatusQuery } from "@/features/projects/repoSyncHooks"; @@ -25,61 +27,79 @@ export function CreatePullRequestDialog({ reposDir, }: { initialProjectId?: string; - onCreated: (project: Project, pullRequestId: string) => void | Promise; + onCreated: ( + project: Project, + repository: Repository, + pullRequestId: string, + ) => void | Promise; onOpenChange: (open: boolean) => void; open: boolean; projects: Project[]; reposDir?: string | null; }) { + const repositoryOptions = React.useMemo( + () => + projects.flatMap((project) => + project.repositories.map((repository) => ({ project, repository })), + ), + [projects], + ); 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 repoStateQuery = useRepoStateQuery(project); - const pullRequestsQuery = useProjectPullRequestsQuery(project); + const initialRepository = selectProjectRepository(initialProject, null); + const [repositoryId, setRepositoryId] = React.useState( + initialRepository?.id ?? "", + ); + const selection = + repositoryOptions.find( + (candidate) => candidate.repository.id === repositoryId, + ) ?? repositoryOptions[0]; + const project = selection?.project; + const repository = selection?.repository; + const repoStateQuery = useRepoStateQuery(repository); + const pullRequestsQuery = useProjectPullRequestsQuery(repository); const initialSyncQuery = useProjectRepoSyncStatusQuery( - project, + repository, reposDir, - project?.defaultBranch, + repository?.defaultBranch, ); const branchOptions = React.useMemo(() => { const names = [ - project?.defaultBranch, + repository?.defaultBranch, ...(repoStateQuery.data?.branches.map((branch) => branch.name) ?? []), initialSyncQuery.data?.localBranch, ].filter((name): name is string => Boolean(name)); return [...new Set(names)]; }, [ initialSyncQuery.data?.localBranch, - project?.defaultBranch, + repository?.defaultBranch, repoStateQuery.data?.branches, ]); const [targetBranch, setTargetBranch] = React.useState( - project?.defaultBranch ?? "", + repository?.defaultBranch ?? "", ); const [sourceBranch, setSourceBranch] = React.useState(""); const sourceSyncQuery = useProjectRepoSyncStatusQuery( - project, + repository, reposDir, sourceBranch || null, targetBranch || null, ); - const createMutation = useCreateProjectPullRequestMutation(project); + const createMutation = useCreateProjectPullRequestMutation(repository); React.useEffect(() => { if (!open) return; const nextProject = projects.find((candidate) => candidate.id === initialProjectId) ?? projects[0]; - setProjectId(nextProject?.id ?? ""); + setRepositoryId(selectProjectRepository(nextProject, null)?.id ?? ""); }, [initialProjectId, open, projects]); React.useEffect(() => { - if (!project) return; - setTargetBranch(project.defaultBranch); + if (!repository) return; + setTargetBranch(repository.defaultBranch); setSourceBranch(""); - }, [project]); + }, [repository]); React.useEffect(() => { if ( @@ -104,9 +124,9 @@ export function CreatePullRequestDialog({ (pullRequest) => (pullRequest.status === "Open" || pullRequest.status === "Draft") && pullRequest.branchName === sourceBranch && - (pullRequest.targetBranch ?? project?.defaultBranch) === targetBranch, + (pullRequest.targetBranch ?? repository?.defaultBranch) === targetBranch, ); - const selectionError = !project + const selectionError = !repository ? "Choose a repository." : !targetBranch ? "Choose a base branch." @@ -120,12 +140,12 @@ export function CreatePullRequestDialog({ ? "The compare branch must be pushed before opening a pull request." : null; const description = - project && sourceBranch && targetBranch - ? `${project.name}: ${sourceBranch} → ${targetBranch}${sourceCommit ? ` at ${sourceCommit.slice(0, 7)}` : ""}` + repository && sourceBranch && targetBranch + ? `${repository.name}: ${sourceBranch} → ${targetBranch}${sourceCommit ? ` at ${sourceCommit.slice(0, 7)}` : ""}` : "Choose a repository and branches to compare."; async function handleCreate(input: CreatePullRequestDialogInput) { - if (!project || !sourceCommit || selectionError) { + if (!project || !repository || !sourceCommit || selectionError) { throw new Error( selectionError ?? "Pull request branches are incomplete.", ); @@ -139,7 +159,7 @@ export function CreatePullRequestDialog({ reviewers: [], }); toast.success("Pull request created."); - await onCreated(project, pullRequestId); + await onCreated(project, repository, pullRequestId); } return ( @@ -165,12 +185,17 @@ export function CreatePullRequestDialog({ className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm font-normal outline-hidden focus:ring-1 focus:ring-ring" data-testid="create-pull-request-repository" disabled={createMutation.isPending} - onChange={(event) => setProjectId(event.target.value)} - value={project?.id ?? ""} + onChange={(event) => setRepositoryId(event.target.value)} + value={repository?.id ?? ""} > - {projects.map((candidate) => ( - ))} diff --git a/desktop/src/features/projects/ui/GitHubMark.tsx b/desktop/src/features/projects/ui/GitHubMark.tsx new file mode 100644 index 0000000000..29c9602102 --- /dev/null +++ b/desktop/src/features/projects/ui/GitHubMark.tsx @@ -0,0 +1,9 @@ +import type { SVGProps } from "react"; + +export function GitHubMark(props: SVGProps) { + return ( + + ); +} diff --git a/desktop/src/features/projects/ui/MergePullRequestButton.tsx b/desktop/src/features/projects/ui/MergePullRequestButton.tsx index 40757f1529..c023f4dbb8 100644 --- a/desktop/src/features/projects/ui/MergePullRequestButton.tsx +++ b/desktop/src/features/projects/ui/MergePullRequestButton.tsx @@ -2,7 +2,10 @@ import { AlertTriangle, Copy, GitMerge, SquareTerminal } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; -import type { Project, ProjectPullRequest } from "@/features/projects/hooks"; +import type { + ProjectPullRequest, + Repository as Project, +} from "@/features/projects/hooks"; import { projectPullRequestConflictCommands } from "@/features/projects/projectPullRequestConflictRecovery"; import { useMergeProjectPullRequestMutation, diff --git a/desktop/src/features/projects/ui/ProjectAuthorIdentity.tsx b/desktop/src/features/projects/ui/ProjectAuthorIdentity.tsx new file mode 100644 index 0000000000..8126292894 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectAuthorIdentity.tsx @@ -0,0 +1,72 @@ +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +/** Compact work-item author identity with a minimal hover summary. */ +export function ProjectAuthorIdentity({ + label, + profiles, + pubkey, + testId, +}: { + label: string; + profiles?: UserProfileLookup; + pubkey: string; + testId?: string; +}) { + const profile = profiles?.[normalizePubkey(pubkey)]; + const roleLabel = profile?.isAgent === true ? "Agent" : "Person"; + + return ( + + + + + + + + + + {label} + + {roleLabel} + + + + + + + ); +} diff --git a/desktop/src/features/projects/ui/ProjectCards.tsx b/desktop/src/features/projects/ui/ProjectCards.tsx index 4f4fef7678..ed28ddd583 100644 --- a/desktop/src/features/projects/ui/ProjectCards.tsx +++ b/desktop/src/features/projects/ui/ProjectCards.tsx @@ -1,6 +1,7 @@ import { + CircleAlert, CircleDot, - FolderGit2, + Folders, GitCommit, GitPullRequest, TerminalSquare, @@ -22,6 +23,7 @@ import { getProjectUpdatedAt, relativeTime, } from "@/features/projects/lib/projectsViewHelpers"; +import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability"; import { projectTerminalLabel } from "@/features/projects/ui/useOpenProjectTerminal"; import { PROJECT_LIST_ROW_CLASS, @@ -83,7 +85,7 @@ function ProjectUpdatedLabel({ ); } -function ProjectPeopleStack({ +export function ProjectPeopleStack({ pubkeys, profiles, workOwnerPubkey, @@ -100,7 +102,7 @@ function ProjectPeopleStack({ } return ( -
+
{visible.map((pubkey, index) => { const profile = profiles?.[normalizePubkey(pubkey)]; const label = resolveUserLabel({ pubkey, profiles }); @@ -167,7 +169,7 @@ const PROJECT_STAT_ITEMS = [ }, ] as const; -function ProjectStatsRow({ +export function ProjectStatsRow({ summary, fixedColumns = false, }: { @@ -207,7 +209,7 @@ function ProjectStatsRow({ // Segmented commits/PRs/issues distribution — the card's "progress bar". // Hovering thickens the bar and reveals a tooltip with the exact breakdown. -function ProjectActivityBar({ +export function ProjectActivityBar({ summary, }: { summary: ProjectActivitySummary | undefined; @@ -263,10 +265,58 @@ function StatusPill({ status }: { status: string }) { ); } +function RepositoryUnavailableIndicator({ + reason, +}: { + reason: ProjectRepoUnavailableReason | undefined; +}) { + if (!reason) return null; + const status = { + authentication: { + description: "Buzz could not authenticate with this repository.", + label: "Access failed", + }, + missing: { + description: "No git repository was found on the Buzz relay.", + label: "Uninitialized", + }, + network: { + description: "The Buzz git service could not be reached.", + label: "Unreachable", + }, + ref: { + description: "The advertised branch is missing from the git remote.", + label: "Branch missing", + }, + unknown: { + description: "Buzz could not load this repository.", + label: "Unavailable", + }, + }[reason]; + + return ( + + + + + + + +

{status.label}

+

{status.description}

+
+
+ ); +} + export function EmptyState() { return (
- +

No projects yet

@@ -280,7 +330,7 @@ export function EmptyState() { export function EmptyFilteredState() { return (

- +

No matching projects @@ -302,7 +352,7 @@ function ProjectCardButton({ }) { return ( + + {activeWorkItemCrumb ? ( + <> + + + + + + {activeWorkItemCrumb.title} + + + ) : activeTabCrumb ? ( + <> + + + + {activeTabCrumb} + + + ) : ( + + {project.name} + + )} + + {project.projectChannelId ? ( + + ) : null} +

+
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx index 587dd0d11b..1b2adf316a 100644 --- a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx @@ -1,10 +1,4 @@ -import { - ArrowLeft, - ChevronRight, - ExternalLink, - FolderGit2, - MessageSquare, -} from "lucide-react"; +import { ArrowLeft, ExternalLink, FolderGit2 } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; @@ -12,6 +6,7 @@ import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useOpenDmMutation } from "@/features/channels/hooks"; import { type Project, + type Repository, useProjectQuery, useProjectIssuesQuery, useProjectLocalRepoDiffQuery, @@ -47,18 +42,13 @@ import { import { useIdentityQuery } from "@/shared/api/hooks"; import { openProjectMergeRecoveryTerminal } from "@/shared/api/projectGit"; import { useMainInsetRef } from "@/shared/layout/MainInsetContext"; -import { - channelChrome, - channelContentTopPaddingMeasurement, - topChromeInset, -} from "@/shared/layout/chromeLayout"; +import { channelContentTopPaddingMeasurement } from "@/shared/layout/chromeLayout"; import { useMeasuredCssVariable } from "@/shared/layout/useMeasuredCssVariable"; -import { cn } from "@/shared/lib/cn"; -import { isSafeUrl } from "@/shared/lib/url"; import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState"; import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth"; import { Button } from "@/shared/ui/button"; +import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; import { useCommunities } from "@/features/communities/useCommunities"; import { useProjectCommitDiffQuery } from "@/features/projects/useProjectCommitDiff"; import { useGitIdentityQuery } from "@/features/projects/useGitIdentity"; @@ -70,14 +60,21 @@ import { resolveProjectDefaultBranch, } from "@/features/projects/lib/projectBranches"; import { normalizeRepositoryUrl } from "@/features/projects/lib/projectsViewHelpers"; +import { selectProjectRepository } from "@/features/projects/projectModels"; +import { KIND_REPO_ANNOUNCEMENT } from "@/shared/constants/kinds"; +import { useProjectRepoPresentation } from "@/features/projects/useProjectRepoHost"; import { WorkspaceTabs } from "./ProjectWorkspaceTabs"; import type { RepoSourceHeaderControls } from "./ProjectRepositorySource"; +import { showProjectCloneErrorToast } from "./projectGitErrorToast"; import { projectTerminalLabel, useOpenProjectTerminal, } from "./useOpenProjectTerminal"; import type { CreateIssueDialogInput } from "./CreateIssueDialog"; import { ProjectBranchActionDialogs } from "./ProjectBranchActionDialogs"; +import { ProjectDetailChrome } from "./ProjectDetailChrome"; +import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement"; +import { UnavailableProjectRepositories } from "./UnavailableProjectRepositories"; import { PROJECT_TAB_CRUMB_LABELS, projectPeople, @@ -90,6 +87,7 @@ type ProjectDetailScreenProps = { projectId: string; pullRequestId?: string; issueId?: string; + repositoryId?: string; }; const PROJECT_DETAIL_PANEL_SEARCH_KEYS = [ @@ -97,9 +95,15 @@ const PROJECT_DETAIL_PANEL_SEARCH_KEYS = [ "profileTab", "profileView", ] as const; +const PROJECT_REPOSITORY_SEARCH_KEYS = [ + "repositoryId", + "issueId", + "pullRequestId", + "commitHash", +] as const; export function ProjectDetailScreen(props: ProjectDetailScreenProps) { - const { commitHash, projectId, pullRequestId, issueId } = props; + const { commitHash, projectId, pullRequestId, issueId, repositoryId } = props; const { goChannel, goProject, goProjects } = useAppNavigation(); const { activeCommunity } = useCommunities(); const mainInsetRef = useMainInsetRef(); @@ -111,16 +115,34 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { const projectQuery = useProjectQuery(projectId); const projectsQuery = useProjectsQuery(); const project = projectQuery.data; - const repoStateQuery = useRepoStateQuery(project); - const pullRequestsQuery = useProjectPullRequestsQuery(project); - const defaultBranch = project - ? resolveProjectDefaultBranch(project.defaultBranch, repoStateQuery.data) + // When the projectId is a canonical 30617:: coordinate (emitted by + // entity links in #4695), derive the repository selection directly from the + // : portion rather than falling back to the project's primary + // repository. Repository.id is ":", so stripping the kind+colon + // prefix gives the exact repository id. This ensures a linked PR/issue on a + // non-primary member opens from the correct repository instead of the primary. + const routeRepositoryId: string | undefined = React.useMemo(() => { + if (repositoryId) return repositoryId; + const kindStr = `${String(KIND_REPO_ANNOUNCEMENT)}:`; + if (!projectId.startsWith(kindStr)) return undefined; + // projectId is "30617::" — strip "30617:" to get ":" + return projectId.slice(kindStr.length); + }, [projectId, repositoryId]); + const repository = selectProjectRepository(project, routeRepositoryId); + const repoRemote = useProjectRepoPresentation(repository); + const { applyPatch: applyRepositorySearch } = useHistorySearchState( + PROJECT_REPOSITORY_SEARCH_KEYS, + ); + const repoStateQuery = useRepoStateQuery(repository); + const pullRequestsQuery = useProjectPullRequestsQuery(repository); + const defaultBranch = repository + ? resolveProjectDefaultBranch(repository.defaultBranch, repoStateQuery.data) : null; const { branchOptions, forgetBranch, managedBranches, rememberBranch } = useOptimisticProjectBranches({ defaultBranch, observedBranches: repoStateQuery.data?.branches ?? [], - projectId, + projectId: repository?.id ?? projectId, referencedBranches: pullRequestsQuery.data?.map( (pullRequest) => pullRequest.branchName ?? null, @@ -130,7 +152,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { useProjectRepositoryRefSelection({ branchOptions, defaultBranch, - projectAvailable: Boolean(project), + projectAvailable: Boolean(repository), projectPending: projectQuery.isPending, tags: repoStateQuery.data?.tags ?? [], }); @@ -183,10 +205,10 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { }, [], ); - const issuesQuery = useProjectIssuesQuery(project); + const issuesQuery = useProjectIssuesQuery(repository); const selectedBranchPullRequest = React.useMemo(() => { const projectRepositories = new Set( - (project?.cloneUrls ?? []).map(normalizeRepositoryUrl), + (repository?.cloneUrls ?? []).map(normalizeRepositoryUrl), ); const matches = pullRequestsQuery.data?.filter( @@ -197,7 +219,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { ), ) ?? []; return matches.length === 1 ? matches[0] : null; - }, [activeBranch, project?.cloneUrls, pullRequestsQuery.data]); + }, [activeBranch, pullRequestsQuery.data, repository?.cloneUrls]); const openBranchPullRequest = selectedBranchPullRequest?.status === "Open" || selectedBranchPullRequest?.status === "Draft" @@ -210,58 +232,59 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { "remote", ); const repoSnapshotQuery = useProjectRepoSnapshotQuery( - project, + repository, activeBranch, selectedTag ? null : selectedBranchPullRequest, activeTag, + repoRemote.host.kind === "buzz", ); const repoDiffQuery = useProjectRepoDiffQuery( - project, + repository, activeBranch, activeRepoPullRequest, repoSource === "remote", ); const localRepoDiffQuery = useProjectLocalRepoDiffQuery( - project, + repository, activeCommunity?.reposDir, activeBranch, activeRepoPullRequest, repoSource === "local" && Boolean(activeRepoPullRequest), ); const commitDiffQuery = useProjectCommitDiffQuery( - project, + repository, selectedCommitHash, repoSource, activeCommunity?.reposDir, ); const localRepoSnapshotQuery = useProjectLocalRepoSnapshotQuery( - project, + repository, activeCommunity?.reposDir, activeBranch, ); const repoSyncStatusQuery = useProjectRepoSyncStatusQuery( - project, + repository, activeCommunity?.reposDir, activeBranch, ); const pushLocalRepoMutation = usePushProjectLocalRepositoryMutation( - project, + repository, activeCommunity?.reposDir, activeBranch, openBranchPullRequest, ); const pullLocalRepoMutation = usePullProjectLocalRepositoryMutation( - project, + repository, activeCommunity?.reposDir, activeBranch, ); const cloneRepoMutation = useCloneProjectRepositoryMutation( - project, + repository, activeCommunity?.reposDir, ); - const createIssueMutation = useCreateProjectIssueMutation(project); + const createIssueMutation = useCreateProjectIssueMutation(repository); const updatePullRequestMutation = useUpdateProjectPullRequestMutation( - project, + repository, openBranchPullRequest, ); const hasLocalCheckout = Boolean( @@ -321,7 +344,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { defaultBranch, deleteBranchReason, forgetBranch, - project, + project: repository, refetchRepoState: repoStateQuery.refetch, rememberBranch, selectBranch: handleBranchChange, @@ -375,9 +398,9 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { : repoSyncStatusQuery.data?.localPath || localRepoSnapshotQuery.data ? "Local" : "Local missing", - remoteLabel: repoSnapshotQuery.isLoading ? "Remote checking" : "Remote", + ...repoRemote.controls, onCloneLocal: - !selectedTag && project?.cloneUrls[0] + !selectedTag && repository?.cloneUrls[0] && repoRemote.canCloneLocally ? () => { void handleCloneRepo(); } @@ -421,7 +444,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { }; const projectPending = projectQuery.isPending; React.useEffect(() => { - if (!project) { + if (!repository) { // While the project query is still loading, keep the URL-seeded // pullRequestId/issueId selections — clearing here would discard them // before the detail view ever gets a chance to open. @@ -430,7 +453,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { setSelectedIssueId(null); setSelectedCommitHash(null); } - }, [project, projectPending]); + }, [projectPending, repository]); React.useEffect(() => { setRepoSource((currentSource) => { if (selectedTag) return "remote"; @@ -446,7 +469,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { }); }, [hasLocalCheckout, hasRemoteSnapshot, selectedTag]); const peoplePubkeys = React.useMemo(() => { - if (!project) return []; + if (!repository) return []; // Include PR authors/updaters so commit rows can resolve avatars for // publishers who are not listed as project contributors. const pullRequestPubkeys = (pullRequestsQuery.data ?? []).flatMap( @@ -465,12 +488,12 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { ]); return [ ...new Set([ - ...projectPeople(project), + ...projectPeople(repository), ...pullRequestPubkeys, ...issuePubkeys, ]), ]; - }, [issuesQuery.data, project, pullRequestsQuery.data]); + }, [issuesQuery.data, pullRequestsQuery.data, repository]); const profilesQuery = useUsersBatchQuery(peoplePubkeys, { enabled: peoplePubkeys.length > 0, }); @@ -566,21 +589,36 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { toast.success(result.message); setRepoSource("local"); } catch (error) { - toast.error( - error instanceof Error ? error.message : "Failed to clone repository", - ); + showProjectCloneErrorToast(error, repository?.cloneUrls[0]); } - }, [cloneRepoMutation]); + }, [cloneRepoMutation, repository?.cloneUrls]); const handlePullRequestCreated = React.useCallback( - async (createdProject: Project, pullRequestId: string) => { + async ( + createdProject: Project, + createdRepository: Repository, + pullRequestId: string, + ) => { if (createdProject.id !== projectId) { - await goProject(createdProject.id, { pullRequestId }); + await goProject(createdProject.id, { + pullRequestId, + repositoryId: createdRepository.id, + }); return; } - await pullRequestsQuery.refetch(); + if (createdRepository.id === repository?.id) { + await pullRequestsQuery.refetch(); + } else { + applyRepositorySearch({ repositoryId: createdRepository.id }); + } setSelectedPullRequestId(pullRequestId); }, - [goProject, projectId, pullRequestsQuery], + [ + applyRepositorySearch, + goProject, + projectId, + pullRequestsQuery, + repository?.id, + ], ); const handleCreateIssue = React.useCallback( async ({ body, title }: CreateIssueDialogInput) => { @@ -640,12 +678,12 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { ]); const openTerminal = useOpenProjectTerminal(activeCommunity?.reposDir); const handleOpenTerminal = React.useCallback(() => { - if (!project) return Promise.resolve(); - return openTerminal(project, { + if (!repository) return Promise.resolve(); + return openTerminal(repository, { branch: activeBranch, hasLocalCheckout, }); - }, [activeBranch, hasLocalCheckout, openTerminal, project]); + }, [activeBranch, hasLocalCheckout, openTerminal, repository]); const handleOpenMergeRecoveryTerminal = React.useCallback( async (input: { expectedCommit: string; @@ -653,22 +691,22 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { sourceCloneUrl: string; targetBranch: string; }) => { - const targetCloneUrl = project?.cloneUrls[0]; - if (!project || !targetCloneUrl) { + const targetCloneUrl = repository?.cloneUrls[0]; + if (!repository || !targetCloneUrl) { throw new Error("No project selected."); } return openProjectMergeRecoveryTerminal({ ...input, - projectDtag: project.dtag, + projectDtag: repository.dtag, reposDir: activeCommunity?.reposDir, targetCloneUrl, }); }, - [activeCommunity?.reposDir, project], + [activeCommunity?.reposDir, repository], ); if (projectQuery.isLoading) { - return null; + return ; } if (projectQuery.isError) { return ( @@ -717,10 +755,20 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
); } + if (!repository) { + return ( +
+ +

{project.name}

+

+ This project does not have any available repositories yet. +

+ +
+ ); + } const repoContributors = repoSnapshotQuery.data?.contributors ?? []; - const safeWebUrl = - project.webUrl && isSafeUrl(project.webUrl) ? project.webUrl : null; const selectedPullRequest = pullRequestsQuery.data?.find((item) => item.id === selectedPullRequestId) ?? null; @@ -770,6 +818,19 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { // instead of whatever tab the work item left behind. setTabsResetKey((key) => key + 1); }; + const handleRepositoryChange = (nextRepositoryId: string) => { + applyRepositorySearch({ + repositoryId: nextRepositoryId, + issueId: null, + pullRequestId: null, + commitHash: null, + }); + setSelectedPullRequestId(null); + setSelectedIssueId(null); + setSelectedCommitHash(null); + setRepoSource("remote"); + setTabsResetKey((key) => key + 1); + }; return ( @@ -781,101 +842,19 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { />
-
-
- - {project.projectChannelId ? ( - - ) : null} -
-
+ { + void goChannel(channelId); + }} + onGoProjectHome={handleGoToProjectHome} + onGoProjects={() => { + void goProjects(); + }} + project={project} + />
@@ -886,7 +865,9 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {

{project.name}

- {safeWebUrl ? ( + {repoRemote.webUrl && + (repoRemote.host.kind !== "external" || + repoSource === "local") ? (
+
+ + Repository + + +
+ [...comments].sort( + (left, right) => + left.createdAt - right.createdAt || left.id.localeCompare(right.id), + ), + [comments], + ); + const earlierCommentCount = Math.max( + 0, + orderedComments.length - COLLAPSED_COMMENT_COUNT, + ); + const visibleComments = + isExpanded || earlierCommentCount === 0 + ? orderedComments + : orderedComments.slice(-COLLAPSED_COMMENT_COUNT); + const displayedComments = isCollapsed ? [] : visibleComments; + + if (orderedComments.length === 0) { + return

No comments yet.

; + } + + return ( +
+ + + {!isCollapsed && earlierCommentCount > 0 && !isExpanded ? ( + + ) : null} + + {displayedComments.map((comment, index) => ( +
+
+ {index < displayedComments.length - 1 ? ( + + ) : null} + + + +
+
+
+ + + {resolveUserLabel({ profiles, pubkey: comment.author })} + + + + {relativeTime(comment.createdAt)} + +
+ +
+
+ ))} +
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx index 104000e553..6f34248dd1 100644 --- a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx @@ -4,8 +4,8 @@ import { toast } from "sonner"; import { ForumComposer } from "@/features/forum/ui/ForumComposer"; import { - type Project, type ProjectIssue, + type Repository as Project, useCreateProjectIssueCommentMutation, useProjectIssuesQuery, } from "@/features/projects/hooks"; @@ -22,6 +22,8 @@ import { ProjectFeedRowCluster, ProjectFeedRowMonoCell, } from "./ProjectFeedRow"; +import { ProjectIssueCommentTimeline } from "./ProjectIssueCommentTimeline"; +import { ProjectOriginReference } from "./ProjectOriginReference"; import { OverviewRailSection } from "./ProjectOverviewPanel"; import { ProfileIdentityButton } from "./ProjectProfileIdentity"; import { ProjectRichContent } from "./ProjectRichContent"; @@ -67,29 +69,6 @@ function issueMembers( }); } -function AuthorIdentity({ - profiles, - pubkey, - role, -}: { - profiles?: UserProfileLookup; - pubkey: string; - role?: React.ReactNode; -}) { - const profile = profiles?.[normalizePubkey(pubkey)]; - return ( - - ); -} - function IssueRow({ issue, onOpen, @@ -141,10 +120,15 @@ function IssueRow({ trailing={ <> {issue.comments.length > 0 ? ( - + ) : null}
-

+

Issue from {authorLabel} +

{issue.title}{" "} @@ -228,37 +216,26 @@ export function ProjectIssueDetail({

+

Add Your Comment

- {issue.comments.length > 0 ? ( -
- {issue.comments.map((item) => ( -
-
- -
- -
- ))} -
- ) : ( -

No comments yet.

- )} - +
+ +
diff --git a/desktop/src/features/projects/ui/ProjectOriginReference.tsx b/desktop/src/features/projects/ui/ProjectOriginReference.tsx new file mode 100644 index 0000000000..e9037372cf --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectOriginReference.tsx @@ -0,0 +1,56 @@ +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useChannelsQuery } from "@/features/channels/hooks"; + +export function ProjectOriginReference({ + agentName, + channelId, +}: { + agentName?: string | null; + channelId?: string | null; +}) { + const { goChannel } = useAppNavigation(); + const channelsQuery = useChannelsQuery({ enabled: Boolean(channelId) }); + const channel = channelsQuery.data?.find( + (candidate) => candidate.id === channelId, + ); + + if (channelId) { + return ( + + started from + {channel ? ( + + ) : ( + a public channel + )} + (author-claimed) + + ); + } + + if (agentName) { + return ( + + started privately with + + {agentName} + + + ); + } + + return null; +} diff --git a/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx b/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx index d0e5c76bef..de9044e1a5 100644 --- a/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx @@ -9,11 +9,11 @@ import type * as React from "react"; import { cn } from "@/shared/lib/cn"; import type { - Project, ProjectPullRequest, ProjectRepoContributor, ProjectRepoFile, ProjectRepoSnapshot, + Repository as Project, } from "@/features/projects/hooks"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { @@ -21,6 +21,7 @@ import { languageForPath, topLanguagesFromCounts, } from "@/features/projects/lib/projectLanguages"; +import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { PROJECT_DETAIL_PANEL_CLASS } from "./projectPanelStyles"; @@ -29,7 +30,10 @@ import type { RepoSourceHeaderControls } from "./ProjectRepositorySource"; type ProjectOverviewPanelProps = { contributors: ProjectRepoContributor[]; + externalHost?: string; + externalUrl?: string | null; files: ProjectRepoFile[]; + gitDataState: GitDataState; project: Project; onViewContributors: () => void; profiles?: UserProfileLookup; @@ -38,11 +42,10 @@ type ProjectOverviewPanelProps = { snapshot: ProjectRepoSnapshot | null | undefined; /** Branch picker + remote/local toggle for the readme header. */ sourceControls?: RepoSourceHeaderControls; + unavailableReason?: ProjectRepoUnavailableReason; }; -function shortHash(hash: string | undefined) { - return hash ? hash.slice(0, 7) : "None"; -} +export type GitDataState = "checking" | "available" | "empty" | "unavailable"; function topLanguages(files: ProjectRepoFile[]) { const counts: Record = {}; @@ -139,7 +142,10 @@ export function OverviewRailSection({ export function ProjectOverviewPanel({ contributors, + externalHost, + externalUrl, files, + gitDataState, onViewContributors, project, profiles, @@ -147,91 +153,115 @@ export function ProjectOverviewPanel({ readmeFile, snapshot, sourceControls, + unavailableReason, }: ProjectOverviewPanelProps) { const languages = topLanguages(files); const people = projectPeople(project); const latestCommit = snapshot?.latestCommit ?? null; + const gitDataAvailable = gitDataState === "available"; + const unavailableSplash = gitDataState === "unavailable"; return (
{/* ReadmePanel renders its own "no README" fallback while keeping the branch + source controls reachable. */} - +
- + + + {languages.length > 0 ? ( + + ) : ( +

+ No language data is available yet. +

+ )} +
+ +
+
+
+ + Pull Requests +
+
+ {pullRequests.length} +
+
+
+
+ +
+
+
+ + Branch +
+
+ {project.defaultBranch} +
+
+
+
+ + Latest +
+
+ {gitDataAvailable && latestCommit + ? latestCommit.hash.slice(0, 7) + : "—"} +
+
+
+
+ + Files +
+
+ {gitDataAvailable ? files.length : "—"} +
+
+
+
+ + Contributors +
+
+ {gitDataAvailable ? contributors.length : "—"} +
+
+
+
+ + ) : null}
); } diff --git a/desktop/src/features/projects/ui/ProjectPullRequestFilesChangedPanel.tsx b/desktop/src/features/projects/ui/ProjectPullRequestFilesChangedPanel.tsx index ae41725788..7e67ea3ae7 100644 --- a/desktop/src/features/projects/ui/ProjectPullRequestFilesChangedPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectPullRequestFilesChangedPanel.tsx @@ -28,9 +28,9 @@ import * as React from "react"; import { toast } from "sonner"; import { - type Project, type ProjectPullRequest, type ProjectPullRequestCommentAnchor, + type Repository as Project, useCreateProjectPullRequestCommentMutation, } from "@/features/projects/hooks"; import { canReviewProjectPullRequest } from "@/features/projects/pullRequestReviews"; diff --git a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx index c93691a924..54e3689e40 100644 --- a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx @@ -16,13 +16,12 @@ import { import * as React from "react"; import { toast } from "sonner"; -import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useIsManagedAgent } from "@/features/agent-memory/hooks"; -import { useChannelsQuery } from "@/features/channels/hooks"; +import { ProjectOriginReference } from "./ProjectOriginReference"; import { ForumComposer } from "@/features/forum/ui/ForumComposer"; import { - type Project, type ProjectPullRequest, + type Repository as Project, type ProjectPullRequestCommentAnchor, useCreateProjectPullRequestCommentMutation, } from "@/features/projects/hooks"; @@ -331,14 +330,6 @@ export function PullRequestDetailHeader({ pullRequest: ProjectPullRequest; }) { const authorLabel = labelForPubkey(pullRequest.author, profiles); - const sourceChannelId = pullRequest.channelId; - const { goChannel } = useAppNavigation(); - const channelsQuery = useChannelsQuery({ - enabled: Boolean(sourceChannelId), - }); - const sourceChannel = channelsQuery.data?.find( - (channel) => channel.id === sourceChannelId, - ); return (
@@ -364,27 +355,10 @@ export function PullRequestDetailHeader({ created {relativeTime(pullRequest.createdAt)} - {sourceChannelId ? ( - - linked from - {sourceChannel ? ( - - ) : ( - an unavailable channel - )} - (author-claimed) - - ) : null} +

); diff --git a/desktop/src/features/projects/ui/ProjectReadmePanel.tsx b/desktop/src/features/projects/ui/ProjectReadmePanel.tsx index 9150f5a0c2..290733aeca 100644 --- a/desktop/src/features/projects/ui/ProjectReadmePanel.tsx +++ b/desktop/src/features/projects/ui/ProjectReadmePanel.tsx @@ -1,6 +1,19 @@ -import { BookOpen } from "lucide-react"; +import { + BookOpen, + CircleAlert, + CloudOff, + DownloadCloud, + ExternalLink, + GitBranch, + Globe, + Loader2, + LockKeyhole, + RefreshCw, +} from "lucide-react"; import type { ProjectRepoFile } from "@/features/projects/hooks"; +import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability"; +import { Button } from "@/shared/ui/button"; import { Markdown, SyntaxHighlightedCode } from "@/shared/ui/markdown"; import { baseName, @@ -13,6 +26,7 @@ import { RepoSyncActionButton, RepositoryBranchDropdown, } from "./ProjectRepositorySource"; +import { GitHubMark } from "./GitHubMark"; export function findReadmeFile(files: ProjectRepoFile[]) { const readmes = files.filter((file) => @@ -78,9 +92,17 @@ function normalizeReadmeMarkdown(content: string) { export function ReadmePanel({ file, + gitDataState, + externalHost, + externalUrl, sourceControls, + unavailableReason, }: { file: ProjectRepoFile | null; + gitDataState: "checking" | "available" | "empty" | "unavailable"; + externalHost?: string; + externalUrl?: string | null; + unavailableReason?: ProjectRepoUnavailableReason; /** Branch picker + remote/local toggle rendered in the panel header. */ sourceControls?: RepoSourceHeaderControls; }) { @@ -125,13 +147,146 @@ export function ReadmePanel({ ); + if (gitDataState === "checking") { + return ( +
+ {header} +
+ + Loading repository… +
+
+ ); + } + + if (gitDataState === "unavailable") { + const reason = unavailableReason ?? "unknown"; + const unavailableContent = { + authentication: { + description: + "Buzz could not authenticate with this repository. Check your access and try again.", + icon: LockKeyhole, + title: "Repository access failed", + }, + missing: { + description: + "The project announcement exists, but its git repository was not found on the Buzz relay.", + icon: CircleAlert, + title: "Repository not initialized", + }, + network: { + description: + "The Buzz git service could not be reached. Check your connection and try again.", + icon: CloudOff, + title: "Couldn’t reach repository", + }, + ref: { + description: + "The selected branch is advertised by the project but is missing from its git remote.", + icon: GitBranch, + title: "Branch unavailable", + }, + unknown: { + description: + "Buzz could not load this repository. Try again or contact the project owner.", + icon: CircleAlert, + title: "Repository unavailable", + }, + } satisfies Record< + ProjectRepoUnavailableReason, + { + description: string; + icon: typeof CircleAlert; + title: string; + } + >; + const unavailable = unavailableContent[reason]; + const UnavailableIcon = unavailable.icon; + + return ( +
+
+
+ {externalHost === "github.com" ? ( + + ) : externalHost ? ( + + ) : ( + + )} +
+

+ {externalHost + ? `Code hosted on ${externalHost}` + : unavailable.title} +

+

+ {externalHost + ? "Clone this repository locally to explore its files, commits, and contributors in Buzz." + : unavailable.description} +

+ {externalUrl ? ( +
+ {externalUrl} + + ) : null} +
+ {!externalHost && sourceControls?.onFetch ? ( + + ) : null} + {externalHost && sourceControls?.onCloneLocal ? ( + + ) : null} + {externalUrl ? ( + + ) : null} +
+
+
+ ); + } + if (!file?.previewContent) { return (
- {sourceControls ? header : null} + {header}
- Add a README to this repository to describe setup, usage, and project - context. + {gitDataState === "empty" + ? "No files have been pushed to this repository yet." + : "Add a README to this repository to describe setup, usage, and project context."}
); diff --git a/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx b/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx new file mode 100644 index 0000000000..f1a9c8a150 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx @@ -0,0 +1,169 @@ +import * as React from "react"; +import { Check, ShieldCheck } from "lucide-react"; +import { toast } from "sonner"; + +import { useChannelsQuery } from "@/features/channels/hooks"; +import type { Project, Repository } from "@/features/projects/hooks"; +import { useAddProjectRepositoryMutation } from "@/features/projects/useAddProjectRepository"; +import { useAttachProjectRepositoryMutation } from "@/features/projects/useAttachProjectRepository"; +import { useBindProjectRepositoryChannelMutation } from "@/features/projects/useBindProjectRepositoryChannel"; +import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; +import { AddProjectRepositoryDialog } from "./AddProjectRepositoryDialog"; +import { AttachProjectRepositoryDialog } from "./AttachProjectRepositoryDialog"; +import { ProjectRepositoryPicker } from "./ProjectRepositoryPicker"; + +export function ProjectRepositoryManagement({ + identityPubkey, + onChange, + project, + projects, + repository, +}: { + identityPubkey?: string; + onChange: (repositoryId: string) => void; + project: Project; + projects: Project[]; + repository: Repository; +}) { + const [createOpen, setCreateOpen] = React.useState(false); + const [attachOpen, setAttachOpen] = React.useState(false); + const channelsQuery = useChannelsQuery(); + const createMutation = useAddProjectRepositoryMutation(); + const attachMutation = useAttachProjectRepositoryMutation(); + const repairMutation = useBindProjectRepositoryChannelMutation(); + const canEdit = identityPubkey?.toLowerCase() === project.owner.toLowerCase(); + const accessChannels = React.useMemo( + () => + (channelsQuery.data ?? []).filter( + (channel) => + channel.isMember && + !channel.archivedAt && + channel.channelType !== "dm", + ), + [channelsQuery.data], + ); + const inheritedChannelId = [ + repository.channelId, + project.projectChannelId, + project.repositories.find( + (candidate) => candidate.id !== repository.id && candidate.channelId, + )?.channelId, + ].find( + (candidate) => + candidate && accessChannels.some((channel) => channel.id === candidate), + ); + const canManageAccess = + accessChannels.length > 0 && + identityPubkey?.toLowerCase() === repository.owner.toLowerCase(); + const attachCandidates = React.useMemo(() => { + const currentAddresses = new Set(project.repositoryAddresses); + const candidates = new Map(); + for (const candidateProject of projects) { + for (const candidate of candidateProject.repositories) { + if (!currentAddresses.has(candidate.repoAddress)) { + candidates.set(candidate.repoAddress, candidate); + } + } + } + return [...candidates.values()].sort((left, right) => + left.name.localeCompare(right.name), + ); + }, [project.repositoryAddresses, projects]); + + return ( + <> + { + const result = await createMutation.mutateAsync(input); + onChange(result.repository.id); + toast.success(`Repository "${result.repository.name}" created.`); + }} + onOpenChange={setCreateOpen} + open={createOpen} + project={project} + /> + { + const result = await attachMutation.mutateAsync({ + project, + repository: candidate, + }); + onChange(result.repository.id); + toast.success(`Repository "${result.repository.name}" added.`); + }} + onOpenChange={setAttachOpen} + open={attachOpen} + project={project} + repositories={attachCandidates} + /> + setAttachOpen(true) : undefined} + onChange={onChange} + onCreate={canEdit ? () => setCreateOpen(true) : undefined} + project={project} + repository={repository} + /> + {canManageAccess ? ( + + + + + + Repository access channel + {accessChannels.map((channel) => ( + { + if (channel.id === repository.channelId) return; + void repairMutation + .mutateAsync({ + channelId: channel.id, + repository, + }) + .then(() => { + toast.success( + `Repository access set to #${channel.name}.`, + ); + }) + .catch((error: unknown) => { + toast.error( + error instanceof Error + ? error.message + : "Failed to update repository access.", + ); + }); + }} + > + #{channel.name} + {channel.id === repository.channelId ? ( + + ) : null} + + ))} + + + ) : null} + + ); +} diff --git a/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx b/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx index 9aa67189f9..c4becd9057 100644 --- a/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx @@ -618,6 +618,7 @@ export function RepositoryFilesPanel({ profiles, fallbackAuthorPubkey, sourceControls, + unavailableMessage, }: { files: ProjectRepoFile[]; snapshot: ProjectRepoSnapshot | null | undefined; @@ -627,6 +628,7 @@ export function RepositoryFilesPanel({ fallbackAuthorPubkey?: string; /** Branch picker + remote/local toggle rendered in the panel header. */ sourceControls?: RepoSourceHeaderControls; + unavailableMessage?: string; }) { const [currentPath, setCurrentPath] = React.useState(""); const [selectedFile, setSelectedFile] = @@ -683,11 +685,13 @@ export function RepositoryFilesPanel({ // remote/local toggle must stay reachable when one source fails to load. const stateMessage = isLoading ? "Loading repository files…" - : error - ? "Could not load the repository file tree." - : files.length === 0 - ? "No files have been pushed yet." - : null; + : unavailableMessage + ? unavailableMessage + : error + ? "Could not load the repository file tree." + : files.length === 0 + ? "No files have been pushed yet." + : null; if (stateMessage) { if (!sourceControls) { return ( diff --git a/desktop/src/features/projects/ui/ProjectRepositoryPicker.tsx b/desktop/src/features/projects/ui/ProjectRepositoryPicker.tsx new file mode 100644 index 0000000000..889ac0af36 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectRepositoryPicker.tsx @@ -0,0 +1,130 @@ +import { + Check, + ChevronDown, + FolderPlus, + GitBranch, + Link, + Plus, +} from "lucide-react"; + +import type { Project, Repository } from "@/features/projects/hooks"; +import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; + +export function ProjectRepositoryPicker({ + onAttach, + onChange, + onCreate, + project, + repository, +}: { + onAttach?: () => void; + onChange: (repositoryId: string) => void; + onCreate?: () => void; + project: Project; + repository: Repository; +}) { + const repositoryLabel = ( + <> + + {repository.name} + + ); + const unavailableRepositories = project.unavailableRepositoryAddresses ?? []; + + return ( +
+ {project.repositoryAddresses.length === 1 ? ( +
+ {repositoryLabel} +
+ ) : ( + + + + + + Repositories + {project.repositories.map((candidate) => ( + onChange(candidate.id)} + > + {candidate.name} + {candidate.id === repository.id ? ( + + ) : null} + + ))} + {unavailableRepositories.map((address) => ( + + + {address.slice(address.indexOf(":", 6) + 1)} + + + Unavailable + + + ))} + + + )} + {onCreate && onAttach ? ( + + + + + + + + Create new repository + + + + Add existing repository + + + + ) : null} +
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectRepositorySource.tsx b/desktop/src/features/projects/ui/ProjectRepositorySource.tsx index 83943b2834..f1c9ecd451 100644 --- a/desktop/src/features/projects/ui/ProjectRepositorySource.tsx +++ b/desktop/src/features/projects/ui/ProjectRepositorySource.tsx @@ -2,7 +2,9 @@ import { ChevronDown, Cloud, DownloadCloud, + ExternalLink, GitBranch, + Globe, HardDrive, Loader2, Plus, @@ -23,6 +25,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; +import { GitHubMark } from "./GitHubMark"; import { PROJECT_PANEL_ACTION_BUTTON_CLASS } from "./projectPanelStyles"; /** Branch picker shared by the readme and files panel headers. */ @@ -181,6 +184,8 @@ export type RepoSourceHeaderControls = { localDisabled: boolean; localLabel: string; remoteLabel: string; + remoteKind?: "buzz" | "external"; + externalUrl?: string | null; /** Clones the repository when no local checkout is available. */ onCloneLocal?: () => void; clonePending?: boolean; @@ -213,7 +218,13 @@ export function RepoSourceDropdown({ }) { const isLocal = controls.source === "local"; const cloneLocal = controls.localDisabled && controls.onCloneLocal; - const SourceIcon = isLocal ? HardDrive : Cloud; + const RemoteIcon = + controls.remoteKind === "external" + ? controls.remoteLabel === "github.com" + ? GitHubMark + : Globe + : Cloud; + const SourceIcon = isLocal ? HardDrive : RemoteIcon; return ( @@ -238,7 +249,7 @@ export function RepoSourceDropdown({ value={controls.source} > - + {controls.remoteLabel} {!cloneLocal ? ( @@ -280,6 +291,23 @@ export function RepoSyncActionButton({ }: { controls: RepoSourceHeaderControls; }) { + if (controls.remoteKind === "external") { + return controls.externalUrl ? ( + + ) : null; + } + const pull = controls.canPull && controls.onPull; const push = controls.canPush && controls.onPush; diff --git a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx index a6fd329907..1548f3607b 100644 --- a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx +++ b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx @@ -16,11 +16,14 @@ import type { ProjectRepoContributor, ProjectRepoDiff, ProjectRepoSnapshot, + Repository, } from "@/features/projects/hooks"; import { commitAuthorPubkeysFromPullRequests, type ViewerGitIdentity, } from "@/features/projects/lib/projectContributorMatching"; +import type { ProjectRepoHost } from "@/features/projects/lib/projectRepoHost"; +import { projectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { Button } from "@/shared/ui/button"; import { Tabs, TabsContent } from "@/shared/ui/tabs"; @@ -31,7 +34,10 @@ import { ProjectCommitDetailPanel } from "./ProjectCommitDetailPanel"; import { ActivityPanel, ContributorsPanel } from "./ProjectDetailFeedPanels"; import { ProjectIssuesPanel } from "./ProjectIssuesPanel"; import type { OpenMergeRecoveryTerminal } from "./MergePullRequestButton"; -import { ProjectOverviewPanel } from "./ProjectOverviewPanel"; +import { + type GitDataState, + ProjectOverviewPanel, +} from "./ProjectOverviewPanel"; import { PullRequestDetailHeader, PullRequestMetaRail, @@ -56,7 +62,11 @@ import { PROJECT_PANEL_ACTION_BUTTON_CLASS } from "./projectPanelStyles"; type CreatePullRequestAction = { projects: Project[]; reposDir?: string | null; - onCreated: (project: Project, pullRequestId: string) => void | Promise; + onCreated: ( + project: Project, + repository: Repository, + pullRequestId: string, + ) => void | Promise; }; type CreateIssueAction = { @@ -116,6 +126,7 @@ export function WorkspaceTabs({ localSnapshotError, localSnapshotLoading, project, + projectId, repoDiff, repoDiffError, repoDiffLoading, @@ -138,6 +149,7 @@ export function WorkspaceTabs({ profiles, repoContributors, repoSource, + repoHost, sourceControls, terminalTitle, viewerGitIdentity, @@ -151,7 +163,8 @@ export function WorkspaceTabs({ localSnapshot: ProjectLocalRepoSnapshot | null | undefined; localSnapshotError: unknown; localSnapshotLoading: boolean; - project: Project; + project: Repository; + projectId: string; repoDiff: ProjectRepoDiff | null | undefined; repoDiffError: unknown; repoDiffLoading: boolean; @@ -175,6 +188,7 @@ export function WorkspaceTabs({ profiles?: UserProfileLookup; repoContributors: ProjectRepoContributor[]; repoSource: "remote" | "local"; + repoHost: ProjectRepoHost; /** Branch picker + remote/local toggle for the Code tab header. */ sourceControls?: RepoSourceHeaderControls; terminalTitle?: string; @@ -191,6 +205,23 @@ export function WorkspaceTabs({ displayedSnapshot?.contributors ?? repoContributors; const files = displayedSnapshot?.files ?? []; const readmeFile = React.useMemo(() => findReadmeFile(files), [files]); + const externalHost = + repoSource === "remote" && repoHost.kind === "external" + ? repoHost.host + : undefined; + const gitDataState: GitDataState = displayedSnapshotLoading + ? "checking" + : externalHost || displayedSnapshotError || !displayedSnapshot + ? "unavailable" + : files.length === 0 + ? "empty" + : "available"; + const unavailableReason = + gitDataState === "unavailable" && !externalHost + ? projectRepoUnavailableReason(displayedSnapshotError) + : undefined; + const repositoryLoaded = + gitDataState === "available" || gitDataState === "empty"; const commitAuthorPubkeys = React.useMemo( () => commitAuthorPubkeysFromPullRequests(pullRequests), [pullRequests], @@ -199,6 +230,15 @@ export function WorkspaceTabs({ pullRequests.find( (pullRequest) => pullRequest.id === selectedPullRequestId, ) ?? null; + const selectedCommitPullRequest = React.useMemo( + () => + pullRequests.find( + (pullRequest) => + pullRequest.commit === selectedCommitHash || + pullRequest.initialCommit === selectedCommitHash, + ), + [pullRequests, selectedCommitHash], + ); const isPullRequestSelected = Boolean(selectedPullRequest); const [selectedTab, setSelectedTab] = React.useState("overview"); const [pullRequestCommentTarget, setPullRequestCommentTarget] = @@ -278,34 +318,36 @@ export function WorkspaceTabs({ onValueChange={handleTabChange} value={selectedTab} > -
- - {onOpenTerminal ? ( - - ) : null} - {updatePullRequestAction ? ( - - ) : null} -
+ {repositoryLoaded ? ( +
+ + {onOpenTerminal ? ( + + ) : null} + {updatePullRequestAction ? ( + + ) : null} +
+ ) : null} {selectedPullRequest ? (
{/* Two full-height columns: the meta rail runs all the way to the @@ -370,7 +412,10 @@ export function WorkspaceTabs({ setSelectedTab("contributors")} profiles={profiles} project={project} @@ -378,6 +423,7 @@ export function WorkspaceTabs({ readmeFile={readmeFile} snapshot={displayedSnapshot} sourceControls={sourceControls} + unavailableReason={unavailableReason} /> @@ -395,6 +441,8 @@ export function WorkspaceTabs({ diff={commitDiff} diffError={commitDiffError} diffLoading={commitDiffLoading} + originAgentName={selectedCommitPullRequest?.originAgentName} + originChannelId={selectedCommitPullRequest?.channelId} profiles={profiles} /> ) : ( @@ -480,6 +528,11 @@ export function WorkspaceTabs({ profiles={profiles} snapshot={displayedSnapshot} sourceControls={sourceControls} + unavailableMessage={ + externalHost + ? `Not mirrored on Buzz. Repository files are hosted on ${externalHost}.` + : undefined + } /> @@ -491,7 +544,7 @@ export function WorkspaceTabs({ {createPullRequestAction && createPullRequestOpen ? ( void; - onOpenIssue: (project: Project, issue: ProjectIssue) => void; + onOpenIssue: ( + project: Project, + repository: Repository, + issue: ProjectIssue, + ) => void; onOpenProject: (project: Project) => void; onOpenPullRequest: ( project: Project, + repository: Repository, pullRequest: ProjectPullRequest, ) => void; profiles?: UserProfileLookup; @@ -129,10 +141,15 @@ function buildActivityItems({ }); } - for (const { project, pullRequest } of pullRequests) { - const target = { type: "pull-request", project, pullRequest } as const; + for (const { project, pullRequest, repository } of pullRequests) { + const target = { + type: "pull-request", + project, + pullRequest, + repository, + } as const; items.push({ - id: `pr:${pullRequest.id}`, + id: `pr:${repository.id}:${pullRequest.id}`, kind: "pull-request", createdAt: pullRequest.createdAt, actorPubkey: pullRequest.author, @@ -145,7 +162,7 @@ function buildActivityItems({ }); for (const update of pullRequest.updates) { items.push({ - id: `pr-update:${update.id}`, + id: `pr-update:${repository.id}:${update.id}`, kind: "commit", createdAt: update.createdAt, actorPubkey: update.author, @@ -172,7 +189,7 @@ function buildActivityItems({ ? "review-request" : "comment"; items.push({ - id: `pr-comment:${comment.id}`, + id: `pr-comment:${repository.id}:${comment.id}`, kind, createdAt: comment.createdAt, actorPubkey: comment.author, @@ -198,10 +215,10 @@ function buildActivityItems({ } } - for (const { project, issue } of issues) { - const target = { type: "issue", project, issue } as const; + for (const { project, issue, repository } of issues) { + const target = { type: "issue", project, issue, repository } as const; items.push({ - id: `issue:${issue.id}`, + id: `issue:${repository.id}:${issue.id}`, kind: "issue", createdAt: issue.createdAt, actorPubkey: issue.author, @@ -214,7 +231,7 @@ function buildActivityItems({ }); for (const comment of issue.comments) { items.push({ - id: `issue-comment:${comment.id}`, + id: `issue-comment:${repository.id}:${comment.id}`, kind: "comment", createdAt: comment.createdAt, actorPubkey: comment.author, @@ -422,10 +439,15 @@ export function ProjectsActivityFeed(props: ProjectsActivityFeedProps) { } else if (item.target.type === "pull-request") { props.onOpenPullRequest( item.target.project, + item.target.repository, item.target.pullRequest, ); } else { - props.onOpenIssue(item.target.project, item.target.issue); + props.onOpenIssue( + item.target.project, + item.target.repository, + item.target.issue, + ); } }} onOpenProject={() => props.onOpenProject(item.target.project)} diff --git a/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx b/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx index 29705425f6..52245da577 100644 --- a/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx +++ b/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx @@ -81,10 +81,19 @@ const REPO_CONTEXT_MARKER = "Workspace repositories:"; * with the first message of a conversation. */ function repoContextBlock(projects: readonly Project[]) { if (projects.length === 0) return ""; - const listed = projects + const repositories = projects.flatMap((project) => + project.repositories.map((repository) => ({ + label: + project.repositories.length > 1 + ? `${project.name} / ${repository.name}` + : project.name, + repoAddress: repository.repoAddress, + })), + ); + const listed = repositories .slice(0, MAX_CONTEXT_REPOS) - .map((project) => `- ${project.name} (${project.repoAddress})`); - const remaining = projects.length - listed.length; + .map((repository) => `- ${repository.label} (${repository.repoAddress})`); + const remaining = repositories.length - listed.length; return ["", "---", REPO_CONTEXT_MARKER, ...listed] .concat(remaining > 0 ? [`…and ${remaining} more`] : []) .join("\n"); diff --git a/desktop/src/features/projects/ui/ProjectsCreateMenu.tsx b/desktop/src/features/projects/ui/ProjectsCreateMenu.tsx index 4550682c2e..b1e64002a5 100644 --- a/desktop/src/features/projects/ui/ProjectsCreateMenu.tsx +++ b/desktop/src/features/projects/ui/ProjectsCreateMenu.tsx @@ -12,12 +12,12 @@ const MENU_ITEM_CLASS = export function ProjectsCreateMenu({ onCreateIssue, + onCreateProject, onCreatePullRequest, - onCreateRepository, }: { onCreateIssue: () => void; + onCreateProject: () => void; onCreatePullRequest: () => void; - onCreateRepository: () => void; }) { const [open, setOpen] = React.useState(false); const containerRef = React.useRef(null); @@ -88,12 +88,12 @@ export function ProjectsCreateMenu({ > - + {includeDate ? ( ` · ${issue.status}` ) : ( @@ -182,6 +187,7 @@ function IssueListRow({
{loadNotice}
- {issues.map(({ project, issue }) => ( + {issues.map(({ project, issue, repository }) => ( + onOpen(selectedProject, repository, selectedIssue) + } profiles={profiles} project={project} /> @@ -286,11 +294,13 @@ export function ProjectsIssuesList({ className={PROJECT_LIST_CONTAINER_CLASS} data-testid="projects-list-container" > - {issues.map(({ project, issue }) => ( + {issues.map(({ project, issue, repository }) => ( + onOpen(selectedProject, repository, selectedIssue) + } profiles={profiles} project={project} /> diff --git a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx index b11e7d1235..b31e120abc 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx @@ -1,4 +1,4 @@ -import { CircleDot, FolderGit2, GitPullRequest, Radio } from "lucide-react"; +import { CircleDot, FolderGit2, Folders, GitPullRequest } from "lucide-react"; import type * as React from "react"; import type { @@ -7,14 +7,13 @@ import type { } from "@/features/projects/hooks"; export type ProjectsOverviewSection = + | "projects" | "repositories" | "prs" - | "local" | "issues"; type ProjectsOverviewPanelProps = { children: React.ReactNode; - localRepositoryCount: number; metadata: React.ReactNode; onSelectSection: (section: ProjectsOverviewSection) => void; projects: Project[]; @@ -27,7 +26,7 @@ function overviewStats( ) { return projects.reduce( (stats, project) => { - const summary = summaries?.[project.repoAddress]; + const summary = summaries?.[project.id]; return { issues: stats.issues + (summary?.issueCount ?? 0), prs: stats.prs + (summary?.prCount ?? 0), @@ -70,7 +69,6 @@ function StatPill({ export function ProjectsOverviewPanel({ children, - localRepositoryCount, metadata, onSelectSection, projects, @@ -84,6 +82,15 @@ export function ProjectsOverviewPanel({
onSelectSection("projects")} + /> + count + project.repositories.length, + 0, + )} icon={FolderGit2} label="Repositories" onClick={() => onSelectSection("repositories")} @@ -94,12 +101,6 @@ export function ProjectsOverviewPanel({ label="Pull requests" onClick={() => onSelectSection("prs")} /> - onSelectSection("local")} - /> [ project.owner, - ...project.contributors, - ...(summaries?.[project.repoAddress]?.participantPubkeys ?? []), + ...project.repositories.flatMap((repository) => [ + repository.owner, + ...repository.contributors, + ]), + ...(summaries?.[project.id]?.participantPubkeys ?? []), ].map(normalizePubkey), ), ), @@ -41,7 +44,7 @@ function overviewActivityByDay( ) { const merged: Record = {}; for (const project of projects) { - const byDay = summaries?.[project.repoAddress]?.activityByDay; + const byDay = summaries?.[project.id]?.activityByDay; if (!byDay) continue; for (const [day, count] of Object.entries(byDay)) { merged[day] = (merged[day] ?? 0) + count; diff --git a/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx b/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx index a1f28254e8..f04acb5b82 100644 --- a/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx +++ b/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx @@ -4,6 +4,7 @@ import type { Project, ProjectPullRequest, ProjectPullRequestListItem, + Repository, } from "@/features/projects/hooks"; import { relativeTime } from "@/features/projects/lib/projectsViewHelpers"; import type { ProjectWorkItemSection } from "@/features/projects/projectWorkItems"; @@ -11,10 +12,10 @@ import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; -import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { Button } from "@/shared/ui/button"; import { Card } from "@/shared/ui/card"; import { DropdownMenuItem } from "@/shared/ui/dropdown-menu"; +import { ProjectAuthorIdentity } from "./ProjectAuthorIdentity"; import { ProjectEventTypeIcon } from "./ProjectEventTypeIcon"; import { ProjectListRowMenu } from "./ProjectListRowMenu"; import { ProjectsWorkItemsLoadNotice } from "./ProjectsWorkItemsLoadNotice"; @@ -29,32 +30,16 @@ import { PROJECT_LIST_ROW_TRAILING_CLASS, } from "./projectListRowStyles"; -/** Author name that opens the user profile popover. */ -function AuthorNameButton({ - label, - pubkey, -}: { - label: string; - pubkey: string; -}) { - return ( - - - - ); -} - type ProjectsPullRequestsListProps = { error: unknown; failedSections: ProjectWorkItemSection[]; isLoading: boolean; isRetrying: boolean; - onOpen: (project: Project, pullRequest: ProjectPullRequest) => void; + onOpen: ( + project: Project, + repository: Repository, + pullRequest: ProjectPullRequest, + ) => void; onRetry: () => void; profiles?: UserProfileLookup; pullRequests: ProjectPullRequestListItem[]; @@ -140,8 +125,9 @@ function PullRequestGridCard({ created {relativeTime(pullRequest.createdAt)} by{" "} - @@ -200,11 +186,13 @@ function PullRequestListRow({ #{pullRequest.id.slice(0, 8)} - - by{" "} - + by + · @@ -291,10 +279,12 @@ export function ProjectsPullRequestsList({
{loadNotice}
- {pullRequests.map(({ project, pullRequest }) => ( + {pullRequests.map(({ project, pullRequest, repository }) => ( + onOpen(selectedProject, repository, selectedPullRequest) + } profiles={profiles} project={project} pullRequest={pullRequest} @@ -312,10 +302,12 @@ export function ProjectsPullRequestsList({ className={PROJECT_LIST_CONTAINER_CLASS} data-testid="projects-list-container" > - {pullRequests.map(({ project, pullRequest }) => ( + {pullRequests.map(({ project, pullRequest, repository }) => ( + onOpen(selectedProject, repository, selectedPullRequest) + } profiles={profiles} project={project} pullRequest={pullRequest} diff --git a/desktop/src/features/projects/ui/ProjectsToolbar.tsx b/desktop/src/features/projects/ui/ProjectsToolbar.tsx index 5e4ee73204..925e708c3b 100644 --- a/desktop/src/features/projects/ui/ProjectsToolbar.tsx +++ b/desktop/src/features/projects/ui/ProjectsToolbar.tsx @@ -59,7 +59,8 @@ export function ProjectsToolbar({ label: string; value: ProjectsFilter; }> = [ - { label: "Overview", value: "all" }, + { label: "Activity", value: "all" }, + { label: "Projects", value: "projects" }, { label: "Repositories", value: "repositories" }, { label: "Pull Requests", value: "prs" }, { label: "Issues", value: "issues" }, @@ -82,6 +83,7 @@ export function ProjectsToolbar({ option.value === "all" && "pl-0 after:left-0", filter === option.value && SELECTED_MENU_ITEM_CLASSES, )} + data-testid={`projects-section-${option.value}`} key={option.value} onClick={() => onFilterChange(option.value)} type="button" diff --git a/desktop/src/features/projects/ui/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx index d0a3d2eb9a..72a514cea5 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -7,14 +7,21 @@ import { type Project, type ProjectIssue, type ProjectPullRequest, + type Repository, useDeleteProjectMutation, useProjectActivitySummariesQuery, useProjectLocalRepositoriesQuery, useProjectsQuery, useProjectsWorkItemsQuery, } from "@/features/projects/hooks"; +import { useRepositoryActivitySummariesQuery } from "@/features/projects/repositoryActivityHooks"; import { useCreateProjectMutation } from "@/features/projects/useCreateProject"; +import { selectProjectRepository } from "@/features/projects/projectModels"; import { useProjectsRepoSnapshotsQuery } from "@/features/projects/useProjectsRepoSnapshots"; +import { + projectRepoHostForProject, + projectRepoHostForRepository, +} from "@/features/projects/lib/projectRepoHost"; import { ProjectsActivityFeed } from "@/features/projects/ui/ProjectsActivityFeed"; import { EmptyFilteredState, @@ -37,7 +44,14 @@ import { ProjectsToolbar, ProjectsViewModeToggle, } from "@/features/projects/ui/ProjectsToolbar"; -import { hasLocalCheckout } from "@/features/projects/lib/projectLocalRepos"; +import { + hasLocalCheckout, + hasLocalRepositoryCheckout, +} from "@/features/projects/lib/projectLocalRepos"; +import { + RepositoryGridCard, + RepositoryListRow, +} from "@/features/projects/ui/RepositoryCards"; import { getProjectUpdatedAt, isProjectMine, @@ -56,7 +70,6 @@ import { readStoredRepositoryScope, readStoredSort, readStoredViewMode, - uniqueRepositories, writeStoredFilter, writeStoredIssueScope, writeStoredPullRequestScope, @@ -65,15 +78,25 @@ import { writeStoredViewMode, } from "@/features/projects/lib/projectsViewHelpers"; import { useOpenProjectTerminal } from "@/features/projects/ui/useOpenProjectTerminal"; +import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; import { useCommunities } from "@/features/communities/useCommunities"; import { useIdentityQuery } from "@/shared/api/hooks"; import { topChromeInset } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; +import { useRelayOrigin } from "@/shared/lib/useRelayOrigin"; import { Button } from "@/shared/ui/button"; import { PageHeader } from "@/shared/ui/PageHeader"; const MANY_PROJECTS_THRESHOLD = 12; +const PROJECT_SCOPE_OPTIONS: Array<{ + label: string; + value: ProjectsRepositoryScope; +}> = [ + { label: "All", value: "all" }, + { label: "My Projects", value: "mine" }, + { label: "Local", value: "local" }, +]; const REPOSITORY_SCOPE_OPTIONS: Array<{ label: string; value: ProjectsRepositoryScope; @@ -81,6 +104,8 @@ const REPOSITORY_SCOPE_OPTIONS: Array<{ { label: "All", value: "all" }, { label: "My Repositories", value: "mine" }, { label: "Local", value: "local" }, + { label: "Buzz-hosted", value: "buzz" }, + { label: "Linked", value: "linked" }, ]; const PULL_REQUEST_SCOPE_OPTIONS: Array<{ label: string; @@ -100,6 +125,7 @@ const ISSUE_SCOPE_OPTIONS: Array<{ export function ProjectsView() { const { goProject } = useAppNavigation(); const { activeCommunity } = useCommunities(); + const relayOrigin = useRelayOrigin(); const scrollIdleTimerRef = React.useRef | null>( null, ); @@ -152,10 +178,21 @@ export function ProjectsView() { : storedFilter; }); const activitySummariesQuery = useProjectActivitySummariesQuery( - filter === "prs" || filter === "issues" ? [] : projects, + filter === "prs" || filter === "issues" || filter === "repositories" + ? [] + : projects, + ); + const repositoryActivitySummariesQuery = useRepositoryActivitySummariesQuery( + filter === "repositories" ? projects : [], ); const [repositoryScope, setRepositoryScope] = - React.useState(() => readStoredRepositoryScope()); + React.useState(() => { + const storedScope = readStoredRepositoryScope(); + return filter === "projects" && + (storedScope === "buzz" || storedScope === "linked") + ? "all" + : storedScope; + }); const [pullRequestScope, setPullRequestScope] = React.useState(() => readStoredPullRequestScope()); const [issueScope, setIssueScope] = React.useState( @@ -164,11 +201,17 @@ export function ProjectsView() { const projectsWorkItemsQuery = useProjectsWorkItemsQuery( filter === "all" || filter === "prs" || filter === "issues" ? projects : [], ); - // One blobless clone per unique repository — only scan while the overview - // header (filter === "all") is actually visible. + // One blobless clone per primary Buzz repository, only while the overview + // header is visible. const snapshotProjects = React.useMemo( - () => (filter === "all" ? uniqueRepositories(projects) : []), - [filter, projects], + () => + filter === "all" + ? projects.filter( + (project) => + projectRepoHostForProject(project, relayOrigin).kind === "buzz", + ) + : [], + [filter, projects, relayOrigin], ); const repoSnapshotsQuery = useProjectsRepoSnapshotsQuery( snapshotProjects, @@ -191,10 +234,7 @@ export function ProjectsView() { ...new Set( [ ...projects.flatMap((project) => - projectPeople( - project, - activitySummariesQuery.data?.[project.repoAddress], - ), + projectPeople(project, activitySummariesQuery.data?.[project.id]), ), ...(projectsWorkItemsQuery.data?.pullRequests.items.flatMap( ({ pullRequest }) => [ @@ -231,10 +271,20 @@ export function ProjectsView() { [], ); - const handleFilterChange = React.useCallback((nextFilter: ProjectsFilter) => { - setFilter(nextFilter); - writeStoredFilter(nextFilter); - }, []); + const handleFilterChange = React.useCallback( + (nextFilter: ProjectsFilter) => { + if ( + nextFilter === "projects" && + (repositoryScope === "buzz" || repositoryScope === "linked") + ) { + setRepositoryScope("all"); + writeStoredRepositoryScope("all"); + } + setFilter(nextFilter); + writeStoredFilter(nextFilter); + }, + [repositoryScope], + ); const handleRepositoryScopeChange = React.useCallback( (scope: ProjectsRepositoryScope) => { @@ -275,30 +325,27 @@ export function ProjectsView() { [localRepositoriesQuery.data], ); - // Count projects with a checkout on this machine — matches what the - // "Local" filter actually lists, not every directory in the repos folder. - const localProjectCount = React.useMemo( - () => - projects.filter((project) => hasLocalCheckout(project, localRepoNames)) - .length, - [localRepoNames, projects], - ); - const visibleProjects = React.useMemo(() => { - // The PRs and Issues filters render dedicated lists - // (visiblePullRequests / visibleIssues), not project cards. - if (filter === "prs" || filter === "issues") { + if (filter !== "projects" && filter !== "agents" && filter !== "users") { return []; } const sortedProjects = projects .filter((project) => { - const summary = activitySummariesQuery.data?.[project.repoAddress]; + const summary = activitySummariesQuery.data?.[project.id]; const people = projectPeople(project, summary); if (repositoryScope === "mine") return isProjectMine(project, currentPubkey); if (repositoryScope === "local") return hasLocalCheckout(project, localRepoNames); + if (repositoryScope === "buzz") + return ( + projectRepoHostForProject(project, relayOrigin).kind === "buzz" + ); + if (repositoryScope === "linked") + return ( + projectRepoHostForProject(project, relayOrigin).kind === "external" + ); if (filter === "agents") { return projectHasAgent(project, people, profiles); } @@ -306,8 +353,8 @@ export function ProjectsView() { return true; }) .sort((left, right) => { - const leftSummary = activitySummariesQuery.data?.[left.repoAddress]; - const rightSummary = activitySummariesQuery.data?.[right.repoAddress]; + const leftSummary = activitySummariesQuery.data?.[left.id]; + const rightSummary = activitySummariesQuery.data?.[right.id]; if (sort === "name") { return left.name.localeCompare(right.name); } @@ -320,9 +367,7 @@ export function ProjectsView() { ); }); - return filter === "repositories" - ? uniqueRepositories(sortedProjects) - : sortedProjects; + return sortedProjects; }, [ activitySummariesQuery.data, currentPubkey, @@ -330,6 +375,76 @@ export function ProjectsView() { localRepoNames, profiles, projects, + relayOrigin, + repositoryScope, + sort, + ]); + + const visibleRepositories = React.useMemo(() => { + if (filter !== "repositories") return []; + const repositories = [ + ...new Map( + projects + .flatMap((project) => + project.repositories.map((repository) => ({ + project, + repository, + })), + ) + .map((item) => [item.repository.repoAddress, item]), + ).values(), + ]; + return repositories + .filter(({ repository }) => { + if (repositoryScope === "mine") { + if (!currentPubkey) return false; + const normalizedCurrentPubkey = normalizePubkey(currentPubkey); + return ( + normalizePubkey(repository.owner) === normalizedCurrentPubkey || + repository.contributors.some( + (pubkey) => normalizePubkey(pubkey) === normalizedCurrentPubkey, + ) + ); + } + if (repositoryScope === "local") { + return hasLocalRepositoryCheckout(repository, localRepoNames); + } + if (repositoryScope === "buzz") { + return ( + projectRepoHostForRepository(repository, relayOrigin).kind === + "buzz" + ); + } + if (repositoryScope === "linked") { + return ( + projectRepoHostForRepository(repository, relayOrigin).kind === + "external" + ); + } + return true; + }) + .sort((left, right) => { + if (sort === "name") { + return left.repository.name.localeCompare(right.repository.name); + } + if (sort === "created") { + return right.repository.createdAt - left.repository.createdAt; + } + const leftUpdatedAt = + repositoryActivitySummariesQuery.data?.[left.repository.repoAddress] + ?.updatedAt ?? left.repository.createdAt; + const rightUpdatedAt = + repositoryActivitySummariesQuery.data?.[right.repository.repoAddress] + ?.updatedAt ?? right.repository.createdAt; + return rightUpdatedAt - leftUpdatedAt; + }); + }, [ + currentPubkey, + filter, + localRepoNames, + projects, + relayOrigin, + repositoryActivitySummariesQuery.data, repositoryScope, sort, ]); @@ -384,6 +499,13 @@ export function ProjectsView() { [goProject], ); + const handleOpenRepository = React.useCallback( + (project: Project, repository: Repository) => { + void goProject(project.id, { repositoryId: repository.id }); + }, + [goProject], + ); + const handleOpenCommit = React.useCallback( (project: Project, commitHash: string) => { void goProject(project.id, { commitHash }); @@ -392,24 +514,52 @@ export function ProjectsView() { ); const handleOpenPullRequest = React.useCallback( - (project: Project, pullRequest: ProjectPullRequest) => { - void goProject(project.id, { pullRequestId: pullRequest.id }); + ( + project: Project, + repository: Repository, + pullRequest: ProjectPullRequest, + ) => { + void goProject(project.id, { + pullRequestId: pullRequest.id, + repositoryId: repository.id, + }); }, [goProject], ); const handleOpenIssue = React.useCallback( - (project: Project, issue: ProjectIssue) => { - void goProject(project.id, { issueId: issue.id }); + (project: Project, repository: Repository, issue: ProjectIssue) => { + void goProject(project.id, { + issueId: issue.id, + repositoryId: repository.id, + }); }, [goProject], ); const openTerminal = useOpenProjectTerminal(activeCommunity?.reposDir); const handleOpenTerminal = React.useCallback( - (project: Project) => - openTerminal(project, { - hasLocalCheckout: hasLocalCheckout(project, localRepoNames), + (project: Project) => { + const repository = selectProjectRepository(project, null); + if (!repository) return Promise.resolve(); + return openTerminal(repository, { + // Check the selected repository only — not all members — so the + // terminal affordance reflects the repository the button will open. + hasLocalCheckout: hasLocalRepositoryCheckout( + repository, + localRepoNames, + ), + }); + }, + [localRepoNames, openTerminal], + ); + const handleOpenRepositoryTerminal = React.useCallback( + (repository: Repository) => + openTerminal(repository, { + hasLocalCheckout: hasLocalRepositoryCheckout( + repository, + localRepoNames, + ), }), [localRepoNames, openTerminal], ); @@ -429,7 +579,7 @@ export function ProjectsView() { ); if (projectsQuery.isLoading) { - return null; + return ; } if (projectsQuery.isError) { @@ -451,7 +601,7 @@ export function ProjectsView() { return ; } - const repositoryItems = + const projectItems = visibleProjects.length === 0 ? ( ) : viewMode === "grid" ? ( @@ -462,7 +612,7 @@ export function ProjectsView() { )} > {visibleProjects.map((project) => { - const summary = activitySummariesQuery.data?.[project.repoAddress]; + const summary = activitySummariesQuery.data?.[project.id]; return ( ); @@ -486,7 +639,7 @@ export function ProjectsView() { data-testid="projects-list-container" > {visibleProjects.map((project) => { - const summary = activitySummariesQuery.data?.[project.repoAddress]; + const summary = activitySummariesQuery.data?.[project.id]; return ( ); @@ -506,6 +662,45 @@ export function ProjectsView() {
); + const repositoryItems = + visibleRepositories.length === 0 ? ( + + ) : viewMode === "grid" ? ( +
+ {visibleRepositories.map(({ project, repository }) => ( + + ))} +
+ ) : ( +
+ {visibleRepositories.map(({ project, repository }) => ( + + ))} +
+ ); + const listControls = (