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..fb1c527355 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -23,6 +23,7 @@ import { KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, + KIND_PROJECT_ANNOUNCEMENT, KIND_REPO_ANNOUNCEMENT, KIND_REPO_STATE, KIND_TEXT_NOTE, @@ -39,10 +40,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 +57,27 @@ import { projectPullRequestEventsToPullRequests, } from "./projectPullRequests.mjs"; import { fetchProjectsWorkItems } from "./projectWorkItems"; +import { + buildProjectReadModels, + eventToRepository, + type Project, + type Repository, +} from "./projectModels"; +import { 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,26 @@ 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); - } +): Repository { + const repository = eventToRepository(event, relayOrigin); + if (!repository) { + throw new Error("Invalid repository announcement."); } - - return [...best.values()]; + return repository; } 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, - }), + const [projectEvents, repositoryEvents] = await Promise.all([ + fetchProjectEventsExhaustively([KIND_PROJECT_ANNOUNCEMENT]), + fetchProjectEventsExhaustively([KIND_REPO_ANNOUNCEMENT]), ]); - 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) }; - } - 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, - }); - - 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, - ), - }; + return buildProjectReadModels({ + projectEvents, + repositoryEvents, + relayOrigin: getCachedRelayOrigin(), + hiddenAddresses: new Set(readHiddenProjectCards()), + }).sort((a, b) => b.createdAt - a.createdAt); } function eventToRepoState(event: RelayEvent): RepoState { @@ -349,7 +202,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 +221,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 +251,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 +309,7 @@ async function createProjectPullRequestComment({ decision?: ProjectPullRequestCommentDecision; mediaTags?: string[][]; mentionPubkeys?: string[]; - project: Project; + project: Repository; pullRequest: ProjectPullRequest; }): Promise { const body = content.trim(); @@ -531,7 +386,7 @@ async function createProjectIssueComment({ mediaTags?: string[][]; mentionPubkeys?: string[]; issue: ProjectIssue; - project: Project; + project: Repository; }): Promise { const body = content.trim(); if (!body) { @@ -550,10 +405,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 +426,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 +448,7 @@ async function fetchProjectRepoSnapshot( } async function fetchProjectRepoDiff( - project: Project, + project: Repository, branchName?: string | null, pullRequest?: ProjectPullRequest | null, ): Promise { @@ -604,7 +465,7 @@ async function fetchProjectRepoDiff( } async function fetchProjectLocalRepoDiff( - project: Project, + project: Repository, reposDir?: string | null, branchName?: string | null, pullRequest?: ProjectPullRequest | null, @@ -625,7 +486,7 @@ async function fetchProjectLocalRepoDiff( } async function fetchProjectLocalRepoSnapshot( - project: Project, + project: Repository, reposDir?: string | null, branchName?: string | null, ): Promise { @@ -638,11 +499,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,16 +515,90 @@ 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()) { @@ -673,7 +608,7 @@ async function deleteProject(project: Project): Promise { const event = await signRelayEvent({ kind: KIND_DELETION, content: `Delete project ${project.name}`, - tags: [["a", project.repoAddress]], + tags: [["a", project.projectAddress]], }); await relayClient.publishEvent( @@ -695,13 +630,16 @@ export function useProjectsQuery() { 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 +652,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 +687,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 +714,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 +748,7 @@ export function useProjectLocalRepoDiffQuery( } export function useProjectLocalRepoSnapshotQuery( - project: Project | null | undefined, + project: Repository | null | undefined, reposDir?: string | null, branchName?: string | null, ) { @@ -842,7 +781,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 +794,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 +818,7 @@ export function useProjectsWorkItemsQuery(projects: Project[]) { } export function useCreateProjectIssueCommentMutation( - project: Project | null | undefined, + project: Repository | null | undefined, ) { const queryClient = useQueryClient(); @@ -919,7 +858,7 @@ export function useCreateProjectIssueCommentMutation( } export function useCreateProjectPullRequestCommentMutation( - project: Project | null | undefined, + project: Repository | null | undefined, ) { const queryClient = useQueryClient(); @@ -966,7 +905,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], ); 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..8ffc0f90da --- /dev/null +++ b/desktop/src/features/projects/projectEnumeration.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { enumerateProjectEvents } 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/, + ); +}); diff --git a/desktop/src/features/projects/projectEnumeration.ts b/desktop/src/features/projects/projectEnumeration.ts new file mode 100644 index 0000000000..0a2b9931f4 --- /dev/null +++ b/desktop/src/features/projects/projectEnumeration.ts @@ -0,0 +1,72 @@ +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; + +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) { + 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, + ); +} 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..4a4705d161 --- /dev/null +++ b/desktop/src/features/projects/projectModels.test.mjs @@ -0,0 +1,321 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + addRepositoryToProject, + buildProjectReadModels, + 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, + ); + } +}); diff --git a/desktop/src/features/projects/projectModels.ts b/desktop/src/features/projects/projectModels.ts new file mode 100644 index 0000000000..a74dd6128c --- /dev/null +++ b/desktop/src/features/projects/projectModels.ts @@ -0,0 +1,395 @@ +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[]; + 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); +} + +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, + ); +} + +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 isValidPubkey(owner) && isValidDTag(dtag) + ? { owner: owner.toLowerCase(), 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), + }; +} + +function eventToExplicitProject( + event: RelayEvent, + repositoriesByAddress: ReadonlyMap, + visibleRepositoriesByAddress: ReadonlyMap, +): Project | null { + const dtag = getTag(event, "d"); + if ( + event.kind !== KIND_PROJECT_ANNOUNCEMENT || + !dtag || + !isValidDTag(dtag) || + !isValidPubkey(event.pubkey) + ) { + return null; + } + + const membershipTags = event.tags.filter((tag) => tag[0] === "a"); + const repositoryAddresses: string[] = []; + const repositoryRelayHints: Record = {}; + const seen = new Set(); + for (const membershipTag of membershipTags) { + const repositoryAddress = membershipTag[1]; + if ( + !repositoryAddress || + !parseRepositoryAddress(repositoryAddress) || + (membershipTag.length !== 2 && membershipTag.length !== 3) || + seen.has(repositoryAddress) + ) { + return null; + } + seen.add(repositoryAddress); + 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, + }; +} + +export function buildProjectReadModels({ + projectEvents, + repositoryEvents, + relayOrigin, + hiddenAddresses = new Set(), +}: BuildProjectReadModelsInput): Project[] { + const repositories = deduplicateAddressableEvents(repositoryEvents).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).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..cb6662d2d5 --- /dev/null +++ b/desktop/src/features/projects/projectRepositoryCreation.test.mjs @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildAddedRepositoryEventTemplates, + buildAttachedRepositoryProjectEventTemplate, + buildRepositoryChannelBindingTemplate, +} from "./projectRepositoryCreation.ts"; + +const OWNER = "a".repeat(64); +const OTHER_OWNER = "b".repeat(64); + +const project = { + id: `${OWNER}:buzz`, + dtag: "buzz", + name: "Buzz", + description: "A multi-repository workspace", + owner: OWNER, + createdAt: 1, + projectChannelId: "11111111-1111-4111-8111-111111111111", + status: "active", + projectAddress: `30621:${OWNER}:buzz`, + primaryRepositoryAddress: `30617:${OWNER}:desktop`, + repositoryAddresses: [`30617:${OWNER}:desktop`, `30617:${OTHER_OWNER}:relay`], + repositoryRelayHints: { + [`30617:${OTHER_OWNER}:relay`]: "wss://relay.example", + }, + repositories: [], + unavailableRepositoryAddresses: [], + visibility: "listed", + legacy: false, +}; + +test("buildAddedRepositoryEventTemplates preserves NIP-MP project metadata and membership", () => { + const templates = buildAddedRepositoryEventTemplates({ + accessChannelId: "11111111-1111-4111-8111-111111111111", + project, + ownerPubkey: OWNER, + name: "Mobile App", + description: "Flutter client", + cloneUrl: "https://relay.example/git/mobile-app.git", + }); + + assert.equal(templates.repository.kind, 30617); + assert.deepEqual(templates.repository.tags, [ + ["d", "mobile-app"], + ["name", "Mobile App"], + ["buzz-channel", "11111111-1111-4111-8111-111111111111"], + ["description", "Flutter client"], + ["clone", "https://relay.example/git/mobile-app.git"], + ]); + assert.equal(templates.project.kind, 30621); + assert.deepEqual(templates.project.tags, [ + ["d", "buzz"], + ["name", "Buzz"], + ["description", "A multi-repository workspace"], + ["buzz-channel", "11111111-1111-4111-8111-111111111111"], + ["a", `30617:${OWNER}:desktop`], + ["a", `30617:${OWNER}:mobile-app`], + ["a", `30617:${OTHER_OWNER}:relay`, "wss://relay.example"], + ]); + assert.equal(templates.project.content, ""); +}); + +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"], + ]); +}); + +test("buildAttachedRepositoryProjectEventTemplate links an existing repository", () => { + const repositoryAddress = `30617:${"c".repeat(64)}:design-system`; + const template = buildAttachedRepositoryProjectEventTemplate({ + project, + ownerPubkey: OWNER, + repositoryAddress, + }); + + assert.equal(template.kind, 30621); + assert.equal(template.content, ""); + assert.deepEqual(template.tags.at(-1), ["a", repositoryAddress]); +}); + +test("buildAddedRepositoryEventTemplates rejects updates by another owner", () => { + assert.throws( + () => + buildAddedRepositoryEventTemplates({ + accessChannelId: "11111111-1111-4111-8111-111111111111", + project, + ownerPubkey: OTHER_OWNER, + name: "Mobile", + }), + /Only the project owner/, + ); +}); diff --git a/desktop/src/features/projects/projectRepositoryCreation.ts b/desktop/src/features/projects/projectRepositoryCreation.ts new file mode 100644 index 0000000000..ccaacb207d --- /dev/null +++ b/desktop/src/features/projects/projectRepositoryCreation.ts @@ -0,0 +1,198 @@ +import type { Project, Repository } from "@/features/projects/hooks"; +import { isValidProjectChannelId } from "@/features/projects/projectModels"; +import { + KIND_PROJECT_ANNOUNCEMENT, + KIND_REPO_ANNOUNCEMENT, +} from "@/shared/constants/kinds"; +import type { ProjectEventTemplate } from "./projectCreation"; + +export type AddedRepositoryEventTemplates = { + project: ProjectEventTemplate; + repository: ProjectEventTemplate; + repositoryAddress: string; + repositoryDtag: string; +}; + +function repositoryDtagFromName(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function buildProjectReplacementTemplate({ + ownerPubkey, + project, + repositoryAddresses, +}: { + ownerPubkey: string; + project: Project; + repositoryAddresses: string[]; +}): ProjectEventTemplate { + const normalizedOwner = ownerPubkey.trim().toLowerCase(); + if (normalizedOwner !== project.owner.toLowerCase()) { + throw new Error("Only the project owner can add repositories."); + } + if (repositoryAddresses.length > 64) { + throw new Error("A project cannot contain more than 64 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}:.+$/i.test(address), + ) + ) { + throw new Error("Repository address is invalid."); + } + + const tags: string[][] = [ + ["d", project.dtag], + ["name", project.name], + ]; + if (project.description) tags.push(["description", project.description]); + if (project.projectChannelId) { + tags.push(["buzz-channel", project.projectChannelId]); + } + if (project.visibility === "unlisted") { + tags.push(["buzz-visibility", "unlisted"]); + } + for (const address of repositoryAddresses.sort()) { + const relayHint = project.repositoryRelayHints?.[address]; + tags.push(relayHint ? ["a", address, relayHint] : ["a", address]); + } + return { kind: KIND_PROJECT_ANNOUNCEMENT, content: "", tags }; +} + +export function buildAttachedRepositoryProjectEventTemplate({ + ownerPubkey, + project, + repositoryAddress, +}: { + ownerPubkey: string; + project: Project; + repositoryAddress: string; +}): ProjectEventTemplate { + if (project.repositoryAddresses.includes(repositoryAddress)) { + throw new Error("This repository is already part of the project."); + } + return buildProjectReplacementTemplate({ + ownerPubkey, + project, + repositoryAddresses: [...project.repositoryAddresses, repositoryAddress], + }); +} + +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 function buildAddedRepositoryEventTemplates({ + accessChannelId, + cloneUrl, + description, + name, + ownerPubkey, + project, + webUrl, +}: { + accessChannelId?: string; + cloneUrl?: string; + description?: string; + name: string; + ownerPubkey: string; + project: Project; + webUrl?: string; +}): AddedRepositoryEventTemplates { + 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}`; + const isUnavailableMember = + project.unavailableRepositoryAddresses?.includes(repositoryAddress) ?? + false; + if ( + project.repositoryAddresses.includes(repositoryAddress) && + !isUnavailableMember + ) { + throw new Error(`This project already contains "${repositoryDtag}".`); + } + 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 projectTemplate = buildProjectReplacementTemplate({ + ownerPubkey, + project, + repositoryAddresses: isUnavailableMember + ? [...project.repositoryAddresses] + : [...project.repositoryAddresses, repositoryAddress], + }); + + 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..df55758b42 --- /dev/null +++ b/desktop/src/features/projects/projectRoutes.ts @@ -0,0 +1,53 @@ +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 canonical coordinate routes and legacy owner/d-tag project links. */ +export function projectMatchesRouteId( + project: Project, + projectId: string, +): boolean { + const { address, owner, dtag } = parseProjectRouteId(projectId); + return ( + (!address || project.projectAddress === address) && + project.dtag === dtag && + (!owner || project.owner.toLowerCase() === owner) + ); +} diff --git a/desktop/src/features/projects/projectWorkItems.ts b/desktop/src/features/projects/projectWorkItems.ts index a2f0047703..ed4b2ac61c 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[]; }; }; @@ -59,7 +74,11 @@ export async function fetchProjectsWorkItems( projects: TProject[], ): 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([ @@ -109,27 +128,31 @@ 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 })), + ), ) .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 })), + ), ) .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..e2fd1220eb 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,20 @@ import { resolveProjectDefaultBranch, } from "@/features/projects/lib/projectBranches"; import { normalizeRepositoryUrl } from "@/features/projects/lib/projectsViewHelpers"; +import { selectProjectRepository } from "@/features/projects/projectModels"; +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 +86,7 @@ type ProjectDetailScreenProps = { projectId: string; pullRequestId?: string; issueId?: string; + repositoryId?: string; }; const PROJECT_DETAIL_PANEL_SEARCH_KEYS = [ @@ -97,9 +94,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 +114,21 @@ 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) + const repository = selectProjectRepository(project, repositoryId); + 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 +138,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { useProjectRepositoryRefSelection({ branchOptions, defaultBranch, - projectAvailable: Boolean(project), + projectAvailable: Boolean(repository), projectPending: projectQuery.isPending, tags: repoStateQuery.data?.tags ?? [], }); @@ -183,10 +191,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 +205,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 +218,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 +330,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { defaultBranch, deleteBranchReason, forgetBranch, - project, + project: repository, refetchRepoState: repoStateQuery.refetch, rememberBranch, selectBranch: handleBranchChange, @@ -375,9 +384,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 +430,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 +439,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { setSelectedIssueId(null); setSelectedCommitHash(null); } - }, [project, projectPending]); + }, [projectPending, repository]); React.useEffect(() => { setRepoSource((currentSource) => { if (selectedTag) return "remote"; @@ -446,7 +455,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 +474,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 +575,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 +664,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 +677,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 +741,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 +804,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 +828,19 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { />
-
-
- - {project.projectChannelId ? ( - - ) : null} -
-
+ { + void goChannel(channelId); + }} + onGoProjectHome={handleGoToProjectHome} + onGoProjects={() => { + void goProjects(); + }} + project={project} + />
@@ -886,7 +851,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..27ebec05ab 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,47 @@ 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, { + (project: Project) => { + const repository = selectProjectRepository(project, null); + if (!repository) return Promise.resolve(); + return openTerminal(repository, { hasLocalCheckout: hasLocalCheckout(project, localRepoNames), + }); + }, + [localRepoNames, openTerminal], + ); + const handleOpenRepositoryTerminal = React.useCallback( + (repository: Repository) => + openTerminal(repository, { + hasLocalCheckout: hasLocalRepositoryCheckout( + repository, + localRepoNames, + ), }), [localRepoNames, openTerminal], ); @@ -429,7 +574,7 @@ export function ProjectsView() { ); if (projectsQuery.isLoading) { - return null; + return ; } if (projectsQuery.isError) { @@ -451,7 +596,7 @@ export function ProjectsView() { return ; } - const repositoryItems = + const projectItems = visibleProjects.length === 0 ? ( ) : viewMode === "grid" ? ( @@ -462,7 +607,7 @@ export function ProjectsView() { )} > {visibleProjects.map((project) => { - const summary = activitySummariesQuery.data?.[project.repoAddress]; + const summary = activitySummariesQuery.data?.[project.id]; return ( ); @@ -486,7 +634,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 +657,45 @@ export function ProjectsView() {
); + const repositoryItems = + visibleRepositories.length === 0 ? ( + + ) : viewMode === "grid" ? ( +
+ {visibleRepositories.map(({ project, repository }) => ( + + ))} +
+ ) : ( +
+ {visibleRepositories.map(({ project, repository }) => ( + + ))} +
+ ); + const listControls = (