mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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"] }));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user