diff --git a/crates/buzz-cli/src/commands/git_activity.rs b/crates/buzz-cli/src/commands/git_activity.rs new file mode 100644 index 000000000..41901d0dd --- /dev/null +++ b/crates/buzz-cli/src/commands/git_activity.rs @@ -0,0 +1,294 @@ +use buzz_sdk::{GitSourceMessage, ThreadRef}; +use nostr::{EventBuilder, EventId}; +use uuid::Uuid; + +use crate::client::BuzzClient; +use crate::error::CliError; +use crate::validate::{ + parse_event_id, parse_uuid, percent_encode, validate_hex64, validate_repo_id, +}; + +pub(crate) struct GitConversationContext { + pub(crate) channel_id: Option, + pub(crate) source_message: Option, + activity: Option, +} + +struct ActivityContext { + channel_id: Uuid, + source_message: EventId, +} + +#[derive(Clone, Copy)] +pub(crate) enum GitEntityType { + Repository, + Issue, + PullRequest, + Patch, +} + +impl GitEntityType { + fn as_str(self) -> &'static str { + match self { + Self::Repository => "repository", + Self::Issue => "issue", + Self::PullRequest => "pull-request", + Self::Patch => "patch", + } + } +} + +pub(crate) fn parse_git_conversation_context( + channel: Option<&str>, + source_message: Option<&str>, +) -> Result { + if source_message.is_some() && channel.is_none() { + return Err(CliError::Usage( + "--source-message requires --channel".into(), + )); + } + + let channel_id = channel.map(parse_uuid).transpose()?; + let source_event = source_message.map(parse_event_id).transpose()?; + let source_message = match (channel_id, source_event) { + (Some(channel_id), Some(source_message)) => Some(GitSourceMessage { + channel_id: channel_id.to_string(), + event_id: source_message.to_hex(), + }), + _ => None, + }; + let activity = match (channel_id, source_event) { + (Some(channel_id), Some(source_message)) => Some(ActivityContext { + channel_id, + source_message, + }), + _ => None, + }; + + Ok(GitConversationContext { + channel_id: channel_id.map(|id| id.to_string()), + source_message, + activity, + }) +} + +pub(crate) fn build_git_url( + repo_owner: &str, + repo_id: &str, + entity_type: GitEntityType, + entity_id: Option<&str>, +) -> Result { + validate_hex64(repo_owner)?; + validate_repo_id(repo_id)?; + let entity_id = entity_id + .map(|id| parse_event_id(id).map(|parsed| parsed.to_hex())) + .transpose()?; + if !matches!(entity_type, GitEntityType::Repository) && entity_id.is_none() { + return Err(CliError::Other(format!( + "{} activity link is missing its entity ID", + entity_type.as_str() + ))); + } + + let repo = format!("{repo_owner}:{repo_id}"); + let mut url = format!( + "buzz://git?repo={}&type={}", + percent_encode(&repo), + entity_type.as_str() + ); + if let Some(id) = entity_id { + url.push_str("&id="); + url.push_str(&percent_encode(&id)); + } + Ok(url) +} + +fn markdown_link_label(title: &str) -> String { + title + .replace('\\', "\\\\") + .replace('[', "\\[") + .replace(']', "\\]") +} + +fn build_activity_message( + context: &ActivityContext, + title: &str, + git_url: &str, +) -> Result { + let thread_ref = ThreadRef { + root_event_id: context.source_message, + parent_event_id: context.source_message, + }; + let content = format!("Created [{}]({git_url})", markdown_link_label(title.trim())); + buzz_sdk::build_message( + context.channel_id, + &content, + Some(&thread_ref), + &[], + false, + &[], + ) + .map_err(|error| CliError::Other(format!("failed to build activity message: {error}"))) +} + +fn accepted_write_response(raw: &str, operation: &str) -> Result { + let value: serde_json::Value = serde_json::from_str(raw).map_err(|error| { + CliError::Other(format!( + "{operation} returned invalid JSON: {error} ({raw})" + )) + })?; + if !value + .get("accepted") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { + let message = value + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or("relay rejected the event"); + return Err(CliError::Other(format!("{operation} failed: {message}"))); + } + Ok(value) +} + +pub(crate) async fn publish_git_event( + client: &BuzzClient, + builder: EventBuilder, + context: &GitConversationContext, + entity_type: GitEntityType, + title: &str, + repo_owner: &str, + repo_id: &str, +) -> Result<(), CliError> { + let git_event = client.sign_event(builder)?; + let git_event_id = git_event.id.to_hex(); + let git_response = client.submit_event(git_event).await?; + let mut output = accepted_write_response(&git_response, "git entity publication")?; + + let Some(activity) = context.activity.as_ref() else { + println!("{git_response}"); + return Ok(()); + }; + + let link_id = + (!matches!(entity_type, GitEntityType::Repository)).then_some(git_event_id.as_str()); + let git_url = build_git_url(repo_owner, repo_id, entity_type, link_id)?; + let activity_builder = build_activity_message(activity, title, &git_url)?; + let activity_event = client.sign_event(activity_builder)?; + let activity_event_id = activity_event.id.to_hex(); + let activity_response = client.submit_event(activity_event).await.map_err(|error| { + CliError::Other(format!( + "{} {git_event_id} was created, but its conversation activity card failed: {error}", + entity_type.as_str() + )) + })?; + accepted_write_response(&activity_response, "conversation activity card").map_err(|error| { + CliError::Other(format!( + "{} {git_event_id} was created, but its conversation activity card failed: {error}", + entity_type.as_str() + )) + })?; + + output + .as_object_mut() + .ok_or_else(|| CliError::Other("git publication response must be a JSON object".into()))? + .insert( + "activity_event_id".into(), + serde_json::Value::String(activity_event_id), + ); + println!("{output}"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use nostr::Keys; + + use super::*; + + #[test] + fn git_url_encodes_repository_coordinate_and_uses_entity_id() { + let owner = "a".repeat(64); + let entity = "b".repeat(64); + let url = build_git_url(&owner, "repo.v1", GitEntityType::PullRequest, Some(&entity)) + .expect("valid git URL"); + assert_eq!( + url, + format!("buzz://git?repo={owner}%3Arepo.v1&type=pull-request&id={entity}") + ); + } + + #[test] + fn repository_git_url_omits_entity_id() { + let owner = "a".repeat(64); + assert_eq!( + build_git_url(&owner, "repo", GitEntityType::Repository, None) + .expect("valid repository URL"), + format!("buzz://git?repo={owner}%3Arepo&type=repository") + ); + } + + #[test] + fn conversation_tags_preserve_repository_metadata() { + let source = GitSourceMessage { + channel_id: "11111111-1111-4111-8111-111111111111".into(), + event_id: "b".repeat(64), + }; + let tags = buzz_sdk::build_git_conversation_tags(None, Some(&source)) + .expect("valid conversation tags"); + let event = + buzz_sdk::build_repo_announcement("repo", Some("Repository"), None, &[], None, &[]) + .expect("repository builder") + .tags(tags) + .sign_with_keys(&Keys::generate()) + .expect("signed repository"); + + assert!(event.tags.iter().any(|tag| tag.as_slice() == ["d", "repo"])); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice().first().map(String::as_str) == Some("buzz-source"))); + } + + #[test] + fn source_message_requires_channel_and_valid_values() { + assert!(parse_git_conversation_context(None, Some(&"a".repeat(64))).is_err()); + assert!(parse_git_conversation_context(Some("not-a-uuid"), None).is_err()); + assert!(parse_git_conversation_context( + Some("11111111-1111-4111-8111-111111111111"), + Some("not-an-event") + ) + .is_err()); + } + + #[test] + fn activity_message_is_threaded_to_source_and_escapes_title() { + let source = parse_event_id(&"b".repeat(64)).expect("source event"); + let context = ActivityContext { + channel_id: Uuid::parse_str("11111111-1111-4111-8111-111111111111").expect("channel"), + source_message: source, + }; + let event = build_activity_message( + &context, + r"Fix [parser]", + "buzz://git?repo=owner%3Arepo&type=issue&id=abc", + ) + .expect("activity builder") + .sign_with_keys(&Keys::generate()) + .expect("signed activity"); + + assert_eq!(event.kind.as_u16(), 9); + assert_eq!( + event.content, + r"Created [Fix \[parser\]](buzz://git?repo=owner%3Arepo&type=issue&id=abc)" + ); + assert!(event + .tags + .iter() + .any(|tag| { tag.as_slice() == ["h", "11111111-1111-4111-8111-111111111111",] })); + assert!(event + .tags + .iter() + .any(|tag| { tag.as_slice() == ["e", &"b".repeat(64), "", "reply"] })); + } +} diff --git a/crates/buzz-cli/src/commands/issues.rs b/crates/buzz-cli/src/commands/issues.rs index 3d7d92a1b..0b4ed7ffb 100644 --- a/crates/buzz-cli/src/commands/issues.rs +++ b/crates/buzz-cli/src/commands/issues.rs @@ -1,4 +1,7 @@ use crate::client::BuzzClient; +use crate::commands::git_activity::{ + parse_git_conversation_context, publish_git_event, GitEntityType, +}; use crate::error::CliError; use crate::validate::{read_or_stdin, sdk_err, validate_hex64, validate_repo_id}; use buzz_sdk::{GitIssueMeta, GitRepoCoord, GitStatusMeta}; @@ -11,14 +14,19 @@ pub async fn cmd_create_issue( content: &str, labels: &[String], to: &[String], + channel: Option<&str>, + source_message: Option<&str>, ) -> Result<(), CliError> { validate_hex64(repo_owner)?; validate_repo_id(repo_id)?; let body = read_or_stdin(content)?; + let context = parse_git_conversation_context(channel, source_message)?; let meta = GitIssueMeta { labels: labels.to_vec(), recipients: to.to_vec(), + channel_id: context.channel_id.clone(), + source_message: context.source_message.clone(), }; let repo = GitRepoCoord { @@ -27,10 +35,16 @@ pub async fn cmd_create_issue( }; let builder = 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}"); - Ok(()) + publish_git_event( + client, + builder, + &context, + GitEntityType::Issue, + subject, + repo_owner, + repo_id, + ) + .await } pub async fn cmd_get_issue(client: &BuzzClient, event: &str) -> Result<(), CliError> { @@ -154,7 +168,22 @@ pub async fn dispatch(cmd: crate::IssuesCmd, client: &BuzzClient) -> Result<(), content, label, to, - } => cmd_create_issue(client, &repo_owner, &repo_id, &title, &content, &label, &to).await, + channel, + source_message, + } => { + cmd_create_issue( + client, + &repo_owner, + &repo_id, + &title, + &content, + &label, + &to, + channel.as_deref(), + source_message.as_deref(), + ) + .await + } IssuesCmd::Get { event } => cmd_get_issue(client, &event).await, IssuesCmd::List { repo_owner, diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 869159063..4b7a68f25 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod channels; pub mod dms; pub mod emoji; pub mod feed; +pub mod git_activity; pub mod issues; pub mod mem; pub mod messages; diff --git a/crates/buzz-cli/src/commands/patches.rs b/crates/buzz-cli/src/commands/patches.rs index 13f1714d0..cf5bc9e42 100644 --- a/crates/buzz-cli/src/commands/patches.rs +++ b/crates/buzz-cli/src/commands/patches.rs @@ -1,4 +1,7 @@ use crate::client::BuzzClient; +use crate::commands::git_activity::{ + parse_git_conversation_context, publish_git_event, GitEntityType, +}; use crate::error::CliError; use crate::validate::{ read_file_or_stdin, read_or_stdin, sdk_err, validate_hex64, validate_repo_id, @@ -20,10 +23,13 @@ pub async fn cmd_send_patch( parent_commit: Option<&str>, commit_pgp_sig: Option<&str>, committer: Option<&str>, + channel: Option<&str>, + source_message: Option<&str>, ) -> Result<(), CliError> { validate_hex64(repo_owner)?; validate_repo_id(repo_id)?; let content = read_file_or_stdin(patch)?; + let context = parse_git_conversation_context(channel, source_message)?; let committer = match committer { Some(spec) => Some(parse_committer(spec)?), @@ -33,6 +39,8 @@ pub async fn cmd_send_patch( let meta = GitPatchMeta { euc: euc.map(str::to_string), recipients: to.to_vec(), + channel_id: context.channel_id.clone(), + source_message: context.source_message.clone(), reply_to: reply_to.map(str::to_string), root, root_revision, @@ -48,10 +56,25 @@ pub async fn cmd_send_patch( }; let builder = 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}"); - Ok(()) + let title = patch_activity_title(&content); + publish_git_event( + client, + builder, + &context, + GitEntityType::Patch, + title, + repo_owner, + repo_id, + ) + .await +} + +fn patch_activity_title(content: &str) -> &str { + content + .lines() + .find_map(|line| line.strip_prefix("Subject: ").map(str::trim)) + .filter(|subject| !subject.is_empty()) + .unwrap_or("patch") } /// Parse `--committer 'name|email|timestamp|tz-offset-minutes'`. @@ -219,6 +242,8 @@ pub async fn dispatch(cmd: crate::PatchesCmd, client: &BuzzClient) -> Result<(), parent_commit, commit_pgp_sig, committer, + channel, + source_message, } => { cmd_send_patch( client, @@ -234,6 +259,8 @@ pub async fn dispatch(cmd: crate::PatchesCmd, client: &BuzzClient) -> Result<(), parent_commit.as_deref(), commit_pgp_sig.as_deref(), committer.as_deref(), + channel.as_deref(), + source_message.as_deref(), ) .await } @@ -300,6 +327,15 @@ mod tests { assert!(parse_committer("a|b|c|d|e").is_err()); } + #[test] + fn patch_activity_title_uses_format_patch_subject() { + assert_eq!( + patch_activity_title("From abc\nSubject: [PATCH] Fix parser\n\nbody"), + "[PATCH] Fix parser" + ); + assert_eq!(patch_activity_title("diff --git a/x b/x"), "patch"); + } + #[test] fn parse_status_accepts_known_words() { assert!(matches!(parse_status("open").unwrap(), GitStatus::Open)); diff --git a/crates/buzz-cli/src/commands/pr.rs b/crates/buzz-cli/src/commands/pr.rs index 4272c2bfd..55c0d9d5b 100644 --- a/crates/buzz-cli/src/commands/pr.rs +++ b/crates/buzz-cli/src/commands/pr.rs @@ -1,4 +1,7 @@ use crate::client::BuzzClient; +use crate::commands::git_activity::{ + parse_git_conversation_context, publish_git_event, GitEntityType, +}; use crate::error::CliError; use crate::validate::{ read_file_or_stdin, read_or_stdin, sdk_err, validate_hex64, validate_repo_id, @@ -32,11 +35,13 @@ pub async fn cmd_open_pr( labels: &[String], to: &[String], channel: Option<&str>, + source_message: Option<&str>, revision_of: Option<&str>, ) -> Result<(), CliError> { validate_hex64(repo_owner)?; validate_repo_id(repo_id)?; let content = read_optional_body(body, body_file)?; + let context = parse_git_conversation_context(channel, source_message)?; let repo = GitRepoCoord { owner: repo_owner.to_string(), @@ -45,7 +50,8 @@ pub async fn cmd_open_pr( let meta = GitPullRequestMeta { euc: euc.map(str::to_string), recipients: to.to_vec(), - channel_id: channel.map(str::to_string), + channel_id: context.channel_id.clone(), + source_message: context.source_message.clone(), subject: subject.to_string(), labels: labels.to_vec(), commit: commit.to_string(), @@ -56,10 +62,16 @@ pub async fn cmd_open_pr( }; let builder = 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}"); - Ok(()) + publish_git_event( + client, + builder, + &context, + GitEntityType::PullRequest, + subject, + repo_owner, + repo_id, + ) + .await } #[allow(clippy::too_many_arguments)] @@ -230,6 +242,7 @@ pub async fn dispatch(cmd: crate::PrCmd, client: &BuzzClient) -> Result<(), CliE label, to, channel, + source_message, revision_of, } => { cmd_open_pr( @@ -247,6 +260,7 @@ pub async fn dispatch(cmd: crate::PrCmd, client: &BuzzClient) -> Result<(), CliE &label, &to, channel.as_deref(), + source_message.as_deref(), revision_of.as_deref(), ) .await diff --git a/crates/buzz-cli/src/commands/repos.rs b/crates/buzz-cli/src/commands/repos.rs index 0f570df1a..19cf49f51 100644 --- a/crates/buzz-cli/src/commands/repos.rs +++ b/crates/buzz-cli/src/commands/repos.rs @@ -5,8 +5,11 @@ use buzz_core::{ use nostr::{Event, EventBuilder, Tag, Timestamp}; use crate::client::{normalize_write_response, BuzzClient}; +use crate::commands::git_activity::{ + parse_git_conversation_context, publish_git_event, GitEntityType, +}; use crate::error::CliError; -use crate::validate::validate_repo_id; +use crate::validate::{sdk_err, validate_repo_id}; fn parse_events(json: &str) -> Result, CliError> { serde_json::from_str(json) @@ -207,8 +210,11 @@ pub async fn cmd_create_repo( clone_urls: &[String], web_url: Option<&str>, relays: &[String], + channel: Option<&str>, + source_message: Option<&str>, ) -> Result<(), CliError> { validate_repo_id(repo_id)?; + let context = parse_git_conversation_context(channel, source_message)?; let clone_refs: Vec<&str> = clone_urls.iter().map(|s| s.as_str()).collect(); let relay_refs: Vec<&str> = relays.iter().map(|s| s.as_str()).collect(); @@ -221,12 +227,26 @@ pub async fn cmd_create_repo( web_url, &relay_refs, ) - .map_err(|e| CliError::Other(format!("build_repo_announcement failed: {e}")))?; + .map_err(|e| CliError::Other(format!("build_repo_announcement failed: {e}")))? + .tags( + buzz_sdk::build_git_conversation_tags( + context.channel_id.as_deref(), + context.source_message.as_ref(), + ) + .map_err(sdk_err)?, + ); - let event = client.sign_event(builder)?; - let resp = client.submit_event(event).await?; - println!("{resp}"); - Ok(()) + let owner = client.keys().public_key().to_hex(); + publish_git_event( + client, + builder, + &context, + GitEntityType::Repository, + name.unwrap_or(repo_id), + &owner, + repo_id, + ) + .await } pub async fn cmd_get_repo( @@ -356,6 +376,8 @@ pub async fn dispatch(cmd: crate::ReposCmd, client: &BuzzClient) -> Result<(), C clone_urls, web, relays, + channel, + source_message, } => { cmd_create_repo( client, @@ -365,6 +387,8 @@ pub async fn dispatch(cmd: crate::ReposCmd, client: &BuzzClient) -> Result<(), C &clone_urls, web.as_deref(), &relays, + channel.as_deref(), + source_message.as_deref(), ) .await } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 6ab81a082..ad81bd236 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1113,6 +1113,12 @@ pub enum ReposCmd { /// Preferred Nostr relay(s) for repo discovery — can be specified multiple times #[arg(long = "nostr-relay")] relays: Vec, + /// Buzz channel where this repository was created + #[arg(long)] + channel: Option, + /// Triggering Buzz message event ID; requires --channel + #[arg(long, requires = "channel")] + source_message: Option, }, /// Get a repository announcement Get { @@ -1232,6 +1238,12 @@ pub enum PatchesCmd { /// Committer identity: 'name|email|timestamp|tz-offset-minutes' #[arg(long)] committer: Option, + /// Buzz channel where this patch originated + #[arg(long)] + channel: Option, + /// Triggering Buzz message event ID; requires --channel + #[arg(long, requires = "channel")] + source_message: Option, }, /// Get a patch by event id Get { @@ -1341,6 +1353,9 @@ pub enum PrCmd { /// Channel where this pull request originated (NIP-29 h-tag) #[arg(long)] channel: Option, + /// Triggering Buzz message event ID; requires --channel + #[arg(long, requires = "channel")] + source_message: Option, /// Root patch event id this PR revises #[arg(long)] revision_of: Option, @@ -1461,6 +1476,12 @@ pub enum IssuesCmd { /// Additional recipient pubkey(s) — can be specified multiple times #[arg(long = "to")] to: Vec, + /// Buzz channel where this issue originated + #[arg(long)] + channel: Option, + /// Triggering Buzz message event ID; requires --channel + #[arg(long, requires = "channel")] + source_message: Option, }, /// Get an issue by event id Get { @@ -1803,6 +1824,76 @@ mod tests { Cli::command().debug_assert(); } + #[test] + fn git_source_message_flags_require_channel() { + let owner = "a".repeat(64); + let source = "b".repeat(64); + let commit = "c".repeat(40); + let cases = vec![ + vec![ + "buzz".to_string(), + "repos".into(), + "create".into(), + "--id".into(), + "repo".into(), + "--source-message".into(), + source.clone(), + ], + vec![ + "buzz".into(), + "issues".into(), + "create".into(), + "--repo-owner".into(), + owner.clone(), + "--repo-id".into(), + "repo".into(), + "--title".into(), + "Issue".into(), + "--content".into(), + "Body".into(), + "--source-message".into(), + source.clone(), + ], + vec![ + "buzz".into(), + "pr".into(), + "open".into(), + "--repo-owner".into(), + owner.clone(), + "--repo-id".into(), + "repo".into(), + "--subject".into(), + "PR".into(), + "--commit".into(), + commit, + "--clone".into(), + "https://example.com/repo.git".into(), + "--source-message".into(), + source.clone(), + ], + vec![ + "buzz".into(), + "patches".into(), + "send".into(), + "--repo-owner".into(), + owner, + "--repo-id".into(), + "repo".into(), + "--patch-file".into(), + "-".into(), + "--source-message".into(), + source, + ], + ]; + + for args in cases { + let error = Cli::try_parse_from(args) + .err() + .expect("--channel must be required"); + assert!(error.to_string().contains("--channel")); + } + } + #[test] fn command_inventory_is_stable() { let expected_groups: Vec<&str> = vec![ diff --git a/crates/buzz-cli/src/validate.rs b/crates/buzz-cli/src/validate.rs index 4985b4414..5a28c1889 100644 --- a/crates/buzz-cli/src/validate.rs +++ b/crates/buzz-cli/src/validate.rs @@ -74,8 +74,8 @@ pub fn validate_content_size(content: &str) -> Result<(), CliError> { /// Percent-encode for URL path segments and query parameter values. /// Encodes all bytes except RFC 3986 unreserved: A-Z a-z 0-9 - _ . ~ -#[cfg(test)] -pub fn percent_encode(s: &str) -> String { +pub(crate) fn percent_encode(s: &str) -> String { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; let mut out = String::with_capacity(s.len()); for byte in s.bytes() { match byte { @@ -83,15 +83,9 @@ pub fn percent_encode(s: &str) -> String { out.push(byte as char); } _ => { - let hi = char::from_digit((byte >> 4) as u32, 16) - .unwrap() - .to_ascii_uppercase(); - let lo = char::from_digit((byte & 0xf) as u32, 16) - .unwrap() - .to_ascii_uppercase(); out.push('%'); - out.push(hi); - out.push(lo); + out.push(HEX[(byte >> 4) as usize] as char); + out.push(HEX[(byte & 0x0f) as usize] as char); } } }