feat(cli): post Git activity to source conversations

Accept source message context for Git creation commands, preserve it on NIP-34 events, and publish a threaded internal-link card after successful creation.
This commit is contained in:
Thomas Petersen
2026-07-26 08:20:37 +02:00
parent b922d66515
commit 44a29e4cb0
8 changed files with 513 additions and 30 deletions
@@ -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<String>,
pub(crate) source_message: Option<GitSourceMessage>,
activity: Option<ActivityContext>,
}
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<GitConversationContext, CliError> {
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<String, CliError> {
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<EventBuilder, CliError> {
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<serde_json::Value, CliError> {
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"] }));
}
}
+34 -5
View File
@@ -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,
+1
View File
@@ -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;
+40 -4
View File
@@ -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));
+19 -5
View File
@@ -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
+30 -6
View File
@@ -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<Vec<Event>, 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
}
+91
View File
@@ -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<String>,
/// Buzz channel where this repository was created
#[arg(long)]
channel: Option<String>,
/// Triggering Buzz message event ID; requires --channel
#[arg(long, requires = "channel")]
source_message: Option<String>,
},
/// Get a repository announcement
Get {
@@ -1232,6 +1238,12 @@ pub enum PatchesCmd {
/// Committer identity: 'name|email|timestamp|tz-offset-minutes'
#[arg(long)]
committer: Option<String>,
/// Buzz channel where this patch originated
#[arg(long)]
channel: Option<String>,
/// Triggering Buzz message event ID; requires --channel
#[arg(long, requires = "channel")]
source_message: Option<String>,
},
/// 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<String>,
/// Triggering Buzz message event ID; requires --channel
#[arg(long, requires = "channel")]
source_message: Option<String>,
/// Root patch event id this PR revises
#[arg(long)]
revision_of: Option<String>,
@@ -1461,6 +1476,12 @@ pub enum IssuesCmd {
/// Additional recipient pubkey(s) — can be specified multiple times
#[arg(long = "to")]
to: Vec<String>,
/// Buzz channel where this issue originated
#[arg(long)]
channel: Option<String>,
/// Triggering Buzz message event ID; requires --channel
#[arg(long, requires = "channel")]
source_message: Option<String>,
},
/// 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![
+4 -10
View File
@@ -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);
}
}
}