fix(cli): keep Git activity cards in source threads

Carry the original thread root separately from the triggering message so generated repository activity remains visible in the conversation that requested it.
This commit is contained in:
Thomas Petersen
2026-07-26 09:01:38 +02:00
parent 657075aa10
commit c627c8c057
7 changed files with 97 additions and 10 deletions
+49 -4
View File
@@ -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<GitConversationContext, CliError> {
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<EventBuilder, CliError> {
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"]));
}
}
+4 -1
View File
@@ -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
}
+4 -1
View File
@@ -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
}
+4 -1
View File
@@ -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
+4 -1
View File
@@ -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
}
+28
View File
@@ -1119,6 +1119,9 @@ pub enum ReposCmd {
/// Triggering Buzz message event ID; requires --channel
#[arg(long, requires = "channel")]
source_message: Option<String>,
/// Original thread root when the triggering message is already a reply
#[arg(long, requires = "source_message")]
thread_root: Option<String>,
},
/// 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<String>,
/// Original thread root when the triggering message is already a reply
#[arg(long, requires = "source_message")]
thread_root: Option<String>,
},
/// 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<String>,
/// Original thread root when the triggering message is already a reply
#[arg(long, requires = "source_message")]
thread_root: Option<String>,
/// Root patch event id this PR revises
#[arg(long)]
revision_of: Option<String>,
@@ -1482,6 +1491,9 @@ pub enum IssuesCmd {
/// Triggering Buzz message event ID; requires --channel
#[arg(long, requires = "channel")]
source_message: Option<String>,
/// Original thread root when the triggering message is already a reply
#[arg(long, requires = "source_message")]
thread_root: Option<String>,
},
/// 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![
@@ -49,8 +49,10 @@ When a Buzz message triggers `repos create`, `issues create`, `pr open`, or
`patches send`, always pass `--channel <current-channel-uuid>` from `[Context]`
and `--source-message <triggering-event-id>` 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 <event-id>` 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.