diff --git a/crates/buzz-cli/src/commands/git_activity.rs b/crates/buzz-cli/src/commands/git_activity.rs index 41901d0dd..1382f18f4 100644 --- a/crates/buzz-cli/src/commands/git_activity.rs +++ b/crates/buzz-cli/src/commands/git_activity.rs @@ -17,6 +17,7 @@ pub(crate) struct GitConversationContext { struct ActivityContext { channel_id: Uuid, source_message: EventId, + thread_root: EventId, } #[derive(Clone, Copy)] @@ -41,15 +42,22 @@ impl GitEntityType { pub(crate) fn parse_git_conversation_context( channel: Option<&str>, source_message: Option<&str>, + thread_root: Option<&str>, ) -> Result { if source_message.is_some() && channel.is_none() { return Err(CliError::Usage( "--source-message requires --channel".into(), )); } + if thread_root.is_some() && source_message.is_none() { + return Err(CliError::Usage( + "--thread-root requires --source-message".into(), + )); + } let channel_id = channel.map(parse_uuid).transpose()?; let source_event = source_message.map(parse_event_id).transpose()?; + let thread_root = thread_root.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(), @@ -61,6 +69,7 @@ pub(crate) fn parse_git_conversation_context( (Some(channel_id), Some(source_message)) => Some(ActivityContext { channel_id, source_message, + thread_root: thread_root.unwrap_or(source_message), }), _ => None, }; @@ -116,7 +125,7 @@ fn build_activity_message( git_url: &str, ) -> Result { let thread_ref = ThreadRef { - root_event_id: context.source_message, + root_event_id: context.thread_root, parent_event_id: context.source_message, }; let content = format!("Created [{}]({git_url})", markdown_link_label(title.trim())); @@ -252,11 +261,18 @@ mod tests { #[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(None, Some(&"a".repeat(64)), None).is_err()); + assert!(parse_git_conversation_context(Some("not-a-uuid"), None, None).is_err()); assert!(parse_git_conversation_context( Some("11111111-1111-4111-8111-111111111111"), - Some("not-an-event") + None, + Some(&"c".repeat(64)), + ) + .is_err()); + assert!(parse_git_conversation_context( + Some("11111111-1111-4111-8111-111111111111"), + Some("not-an-event"), + None, ) .is_err()); } @@ -267,6 +283,7 @@ mod tests { let context = ActivityContext { channel_id: Uuid::parse_str("11111111-1111-4111-8111-111111111111").expect("channel"), source_message: source, + thread_root: source, }; let event = build_activity_message( &context, @@ -291,4 +308,32 @@ mod tests { .iter() .any(|tag| { tag.as_slice() == ["e", &"b".repeat(64), "", "reply"] })); } + + #[test] + fn activity_message_preserves_existing_thread_root() { + let source = parse_event_id(&"b".repeat(64)).expect("source event"); + let root = parse_event_id(&"c".repeat(64)).expect("root event"); + let context = ActivityContext { + channel_id: Uuid::parse_str("11111111-1111-4111-8111-111111111111").expect("channel"), + source_message: source, + thread_root: root, + }; + let event = build_activity_message( + &context, + "Repository", + "buzz://git?repo=owner%3Arepo&type=repository", + ) + .expect("activity builder") + .sign_with_keys(&Keys::generate()) + .expect("signed activity"); + + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["e", &"c".repeat(64), "", "root"])); + 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 0b4ed7ffb..76c0f5edc 100644 --- a/crates/buzz-cli/src/commands/issues.rs +++ b/crates/buzz-cli/src/commands/issues.rs @@ -16,11 +16,12 @@ pub async fn cmd_create_issue( to: &[String], channel: Option<&str>, source_message: Option<&str>, + thread_root: 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 context = parse_git_conversation_context(channel, source_message, thread_root)?; let meta = GitIssueMeta { labels: labels.to_vec(), @@ -170,6 +171,7 @@ pub async fn dispatch(cmd: crate::IssuesCmd, client: &BuzzClient) -> Result<(), to, channel, source_message, + thread_root, } => { cmd_create_issue( client, @@ -181,6 +183,7 @@ pub async fn dispatch(cmd: crate::IssuesCmd, client: &BuzzClient) -> Result<(), &to, channel.as_deref(), source_message.as_deref(), + thread_root.as_deref(), ) .await } diff --git a/crates/buzz-cli/src/commands/patches.rs b/crates/buzz-cli/src/commands/patches.rs index cf5bc9e42..2481f133e 100644 --- a/crates/buzz-cli/src/commands/patches.rs +++ b/crates/buzz-cli/src/commands/patches.rs @@ -25,11 +25,12 @@ pub async fn cmd_send_patch( committer: Option<&str>, channel: Option<&str>, source_message: Option<&str>, + thread_root: 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 context = parse_git_conversation_context(channel, source_message, thread_root)?; let committer = match committer { Some(spec) => Some(parse_committer(spec)?), @@ -244,6 +245,7 @@ pub async fn dispatch(cmd: crate::PatchesCmd, client: &BuzzClient) -> Result<(), committer, channel, source_message, + thread_root, } => { cmd_send_patch( client, @@ -261,6 +263,7 @@ pub async fn dispatch(cmd: crate::PatchesCmd, client: &BuzzClient) -> Result<(), committer.as_deref(), channel.as_deref(), source_message.as_deref(), + thread_root.as_deref(), ) .await } diff --git a/crates/buzz-cli/src/commands/pr.rs b/crates/buzz-cli/src/commands/pr.rs index 55c0d9d5b..73977ed14 100644 --- a/crates/buzz-cli/src/commands/pr.rs +++ b/crates/buzz-cli/src/commands/pr.rs @@ -36,12 +36,13 @@ pub async fn cmd_open_pr( to: &[String], channel: Option<&str>, source_message: Option<&str>, + thread_root: 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 context = parse_git_conversation_context(channel, source_message, thread_root)?; let repo = GitRepoCoord { owner: repo_owner.to_string(), @@ -243,6 +244,7 @@ pub async fn dispatch(cmd: crate::PrCmd, client: &BuzzClient) -> Result<(), CliE to, channel, source_message, + thread_root, revision_of, } => { cmd_open_pr( @@ -261,6 +263,7 @@ pub async fn dispatch(cmd: crate::PrCmd, client: &BuzzClient) -> Result<(), CliE &to, channel.as_deref(), source_message.as_deref(), + thread_root.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 19cf49f51..b8f6c480d 100644 --- a/crates/buzz-cli/src/commands/repos.rs +++ b/crates/buzz-cli/src/commands/repos.rs @@ -212,9 +212,10 @@ pub async fn cmd_create_repo( relays: &[String], channel: Option<&str>, source_message: Option<&str>, + thread_root: Option<&str>, ) -> Result<(), CliError> { validate_repo_id(repo_id)?; - let context = parse_git_conversation_context(channel, source_message)?; + let context = parse_git_conversation_context(channel, source_message, thread_root)?; 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(); @@ -378,6 +379,7 @@ pub async fn dispatch(cmd: crate::ReposCmd, client: &BuzzClient) -> Result<(), C relays, channel, source_message, + thread_root, } => { cmd_create_repo( client, @@ -389,6 +391,7 @@ pub async fn dispatch(cmd: crate::ReposCmd, client: &BuzzClient) -> Result<(), C &relays, channel.as_deref(), source_message.as_deref(), + thread_root.as_deref(), ) .await } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index ad81bd236..58a800a70 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1119,6 +1119,9 @@ pub enum ReposCmd { /// Triggering Buzz message event ID; requires --channel #[arg(long, requires = "channel")] source_message: Option, + /// Original thread root when the triggering message is already a reply + #[arg(long, requires = "source_message")] + thread_root: Option, }, /// Get a repository announcement Get { @@ -1244,6 +1247,9 @@ pub enum PatchesCmd { /// Triggering Buzz message event ID; requires --channel #[arg(long, requires = "channel")] source_message: Option, + /// Original thread root when the triggering message is already a reply + #[arg(long, requires = "source_message")] + thread_root: Option, }, /// Get a patch by event id Get { @@ -1356,6 +1362,9 @@ pub enum PrCmd { /// Triggering Buzz message event ID; requires --channel #[arg(long, requires = "channel")] source_message: Option, + /// Original thread root when the triggering message is already a reply + #[arg(long, requires = "source_message")] + thread_root: Option, /// Root patch event id this PR revises #[arg(long)] revision_of: Option, @@ -1482,6 +1491,9 @@ pub enum IssuesCmd { /// Triggering Buzz message event ID; requires --channel #[arg(long, requires = "channel")] source_message: Option, + /// Original thread root when the triggering message is already a reply + #[arg(long, requires = "source_message")] + thread_root: Option, }, /// Get an issue by event id Get { @@ -1894,6 +1906,22 @@ mod tests { } } + #[test] + fn git_thread_root_requires_source_message() { + let error = Cli::try_parse_from([ + "buzz", + "repos", + "create", + "--id", + "repo", + "--thread-root", + &"a".repeat(64), + ]) + .err() + .expect("--source-message must be required"); + assert!(error.to_string().contains("--source-message")); + } + #[test] fn command_inventory_is_stable() { let expected_groups: Vec<&str> = vec![ diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index 1756cc569..d44a8f136 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -49,8 +49,10 @@ When a Buzz message triggers `repos create`, `issues create`, `pr open`, or `patches send`, always pass `--channel ` from `[Context]` and `--source-message ` from `[Event]`. The CLI records the exact source backlink and replies with a clickable Git activity card. Do -not ask for values already present in the event context. `pr open --channel` -still works without `--source-message` when only an origin `h` tag is needed. +not ask for values already present in the event context. When `[Context]` has +`Scope: thread`, also pass its `Thread root` as `--thread-root ` so +the card appears in the current thread. `pr open --channel` still works without +`--source-message` when only an origin `h` tag is needed. Manage your repository's enforced branch and tag rules with `repos protect list|set|remove`. Ref patterns must use full Git names such as `refs/heads/main` or `refs/tags/*`; supported rules are `--push owner|admin|member`, `--no-force-push`, `--no-delete`, and `--require-patch`. `protect set` replaces the complete rule for that exact pattern, so omitted constraints are removed. Protection updates preserve every unrelated metadata tag and return exit code 5 when a newer NIP-33 head wins a concurrent write.