mirror of
https://github.com/block/buzz.git
synced 2026-11-12 12:22:06 +00:00
Add NIP-34 git pull request CLI support (#1279)
Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -8,6 +8,7 @@ pub mod messages;
|
||||
pub mod notes;
|
||||
pub mod pack;
|
||||
pub mod patches;
|
||||
pub mod pr;
|
||||
pub mod reactions;
|
||||
pub mod repos;
|
||||
pub mod social;
|
||||
|
||||
338
crates/buzz-cli/src/commands/pr.rs
Normal file
338
crates/buzz-cli/src/commands/pr.rs
Normal file
@@ -0,0 +1,338 @@
|
||||
use crate::client::BuzzClient;
|
||||
use crate::error::CliError;
|
||||
use crate::validate::{
|
||||
read_file_or_stdin, read_or_stdin, sdk_err, validate_hex64, validate_repo_id,
|
||||
};
|
||||
use buzz_sdk::{GitPrUpdateMeta, GitPullRequestMeta, GitRepoCoord, GitStatusMeta};
|
||||
|
||||
fn read_optional_body(body: Option<&str>, body_file: Option<&str>) -> Result<String, CliError> {
|
||||
match (body, body_file) {
|
||||
(Some(_), Some(_)) => Err(CliError::Usage(
|
||||
"--body and --body-file are mutually exclusive".into(),
|
||||
)),
|
||||
(Some(value), None) => read_or_stdin(value),
|
||||
(None, Some(path)) => read_file_or_stdin(path),
|
||||
(None, None) => Ok(String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn cmd_open_pr(
|
||||
client: &BuzzClient,
|
||||
repo_owner: &str,
|
||||
repo_id: &str,
|
||||
subject: &str,
|
||||
body: Option<&str>,
|
||||
body_file: Option<&str>,
|
||||
commit: &str,
|
||||
clone_urls: &[String],
|
||||
branch_name: Option<&str>,
|
||||
merge_base: Option<&str>,
|
||||
euc: Option<&str>,
|
||||
labels: &[String],
|
||||
to: &[String],
|
||||
revision_of: Option<&str>,
|
||||
) -> Result<(), CliError> {
|
||||
validate_hex64(repo_owner)?;
|
||||
validate_repo_id(repo_id)?;
|
||||
let content = read_optional_body(body, body_file)?;
|
||||
|
||||
let repo = GitRepoCoord {
|
||||
owner: repo_owner.to_string(),
|
||||
id: repo_id.to_string(),
|
||||
};
|
||||
let meta = GitPullRequestMeta {
|
||||
euc: euc.map(str::to_string),
|
||||
recipients: to.to_vec(),
|
||||
subject: subject.to_string(),
|
||||
labels: labels.to_vec(),
|
||||
commit: commit.to_string(),
|
||||
clone_urls: clone_urls.to_vec(),
|
||||
branch_name: branch_name.map(str::to_string),
|
||||
merge_base: merge_base.map(str::to_string),
|
||||
revision_of: revision_of.map(str::to_string),
|
||||
};
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn cmd_update_pr(
|
||||
client: &BuzzClient,
|
||||
repo_owner: &str,
|
||||
repo_id: &str,
|
||||
pr: &str,
|
||||
pr_author: &str,
|
||||
commit: &str,
|
||||
clone_urls: &[String],
|
||||
body: Option<&str>,
|
||||
body_file: Option<&str>,
|
||||
merge_base: Option<&str>,
|
||||
euc: Option<&str>,
|
||||
to: &[String],
|
||||
) -> Result<(), CliError> {
|
||||
validate_hex64(repo_owner)?;
|
||||
validate_repo_id(repo_id)?;
|
||||
validate_hex64(pr)?;
|
||||
validate_hex64(pr_author)?;
|
||||
let content = read_optional_body(body, body_file)?;
|
||||
|
||||
let repo = GitRepoCoord {
|
||||
owner: repo_owner.to_string(),
|
||||
id: repo_id.to_string(),
|
||||
};
|
||||
let meta = GitPrUpdateMeta {
|
||||
euc: euc.map(str::to_string),
|
||||
recipients: to.to_vec(),
|
||||
pr_event: pr.to_string(),
|
||||
pr_author: pr_author.to_string(),
|
||||
commit: commit.to_string(),
|
||||
clone_urls: clone_urls.to_vec(),
|
||||
merge_base: merge_base.map(str::to_string),
|
||||
};
|
||||
|
||||
let builder = buzz_sdk::build_git_pr_update(&repo, &content, &meta).map_err(sdk_err)?;
|
||||
let event = client.sign_event(builder)?;
|
||||
let resp = client.submit_event(event).await?;
|
||||
println!("{resp}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cmd_get_pr(client: &BuzzClient, event: &str) -> Result<(), CliError> {
|
||||
validate_hex64(event)?;
|
||||
let filter = serde_json::json!({
|
||||
"kinds": [1618],
|
||||
"ids": [event]
|
||||
});
|
||||
let resp = client.query(&filter).await?;
|
||||
println!("{resp}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cmd_list_prs(
|
||||
client: &BuzzClient,
|
||||
repo_owner: &str,
|
||||
repo_id: &str,
|
||||
author: Option<&str>,
|
||||
label: Option<&str>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<(), CliError> {
|
||||
validate_hex64(repo_owner)?;
|
||||
validate_repo_id(repo_id)?;
|
||||
|
||||
let a_value = format!("30617:{repo_owner}:{repo_id}");
|
||||
let mut filter = serde_json::json!({
|
||||
"kinds": [1618],
|
||||
"#a": [a_value]
|
||||
});
|
||||
|
||||
if let Some(pk) = author {
|
||||
validate_hex64(pk)?;
|
||||
filter["authors"] = serde_json::json!([pk]);
|
||||
}
|
||||
if let Some(l) = label {
|
||||
filter["#t"] = serde_json::json!([l]);
|
||||
}
|
||||
if let Some(n) = limit {
|
||||
filter["limit"] = serde_json::json!(n);
|
||||
}
|
||||
|
||||
let resp = client.query(&filter).await?;
|
||||
println!("{resp}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn cmd_pr_status(
|
||||
client: &BuzzClient,
|
||||
pr: &str,
|
||||
status: &str,
|
||||
body: Option<&str>,
|
||||
body_file: Option<&str>,
|
||||
repo_owner: Option<&str>,
|
||||
repo_id: Option<&str>,
|
||||
euc: Option<&str>,
|
||||
to: &[String],
|
||||
merge_commit: Option<&str>,
|
||||
) -> Result<(), CliError> {
|
||||
validate_hex64(pr)?;
|
||||
let status = crate::commands::patches::parse_status(status)?;
|
||||
let content = read_optional_body(body, body_file)?;
|
||||
|
||||
let repo = match (repo_owner, repo_id) {
|
||||
(Some(owner), Some(id)) => {
|
||||
validate_hex64(owner)?;
|
||||
validate_repo_id(id)?;
|
||||
Some(GitRepoCoord {
|
||||
owner: owner.to_string(),
|
||||
id: id.to_string(),
|
||||
})
|
||||
}
|
||||
(None, None) => None,
|
||||
_ => {
|
||||
return Err(CliError::Usage(
|
||||
"--repo-owner and --repo-id must be given together".into(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// Mirrors patch/issue status: default a `p` tag to the repo owner when
|
||||
// known; callers can add PR author/reviewers with repeated `--to`.
|
||||
let mut recipients = Vec::new();
|
||||
if let Some(ref repo) = repo {
|
||||
recipients.push(repo.owner.clone());
|
||||
}
|
||||
for recipient in to {
|
||||
validate_hex64(recipient)?;
|
||||
if !recipients.contains(recipient) {
|
||||
recipients.push(recipient.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let meta = GitStatusMeta {
|
||||
root_event: pr.to_string(),
|
||||
accepted_revision_root: None,
|
||||
repo,
|
||||
euc: euc.map(str::to_string),
|
||||
recipients,
|
||||
applied_patches: vec![],
|
||||
merge_commit: merge_commit.map(str::to_string),
|
||||
applied_as_commits: vec![],
|
||||
};
|
||||
|
||||
let builder = buzz_sdk::build_git_status(status, &content, &meta).map_err(sdk_err)?;
|
||||
let event = client.sign_event(builder)?;
|
||||
let resp = client.submit_event(event).await?;
|
||||
println!("{resp}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn dispatch(cmd: crate::PrCmd, client: &BuzzClient) -> Result<(), CliError> {
|
||||
use crate::PrCmd;
|
||||
match cmd {
|
||||
PrCmd::Open {
|
||||
repo_owner,
|
||||
repo_id,
|
||||
subject,
|
||||
body,
|
||||
body_file,
|
||||
commit,
|
||||
clone,
|
||||
branch_name,
|
||||
merge_base,
|
||||
euc,
|
||||
label,
|
||||
to,
|
||||
revision_of,
|
||||
} => {
|
||||
cmd_open_pr(
|
||||
client,
|
||||
&repo_owner,
|
||||
&repo_id,
|
||||
&subject,
|
||||
body.as_deref(),
|
||||
body_file.as_deref(),
|
||||
&commit,
|
||||
&clone,
|
||||
branch_name.as_deref(),
|
||||
merge_base.as_deref(),
|
||||
euc.as_deref(),
|
||||
&label,
|
||||
&to,
|
||||
revision_of.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
PrCmd::Update {
|
||||
repo_owner,
|
||||
repo_id,
|
||||
pr,
|
||||
pr_author,
|
||||
commit,
|
||||
clone,
|
||||
body,
|
||||
body_file,
|
||||
merge_base,
|
||||
euc,
|
||||
to,
|
||||
} => {
|
||||
cmd_update_pr(
|
||||
client,
|
||||
&repo_owner,
|
||||
&repo_id,
|
||||
&pr,
|
||||
&pr_author,
|
||||
&commit,
|
||||
&clone,
|
||||
body.as_deref(),
|
||||
body_file.as_deref(),
|
||||
merge_base.as_deref(),
|
||||
euc.as_deref(),
|
||||
&to,
|
||||
)
|
||||
.await
|
||||
}
|
||||
PrCmd::Get { event } => cmd_get_pr(client, &event).await,
|
||||
PrCmd::List {
|
||||
repo_owner,
|
||||
repo_id,
|
||||
author,
|
||||
label,
|
||||
limit,
|
||||
} => {
|
||||
cmd_list_prs(
|
||||
client,
|
||||
&repo_owner,
|
||||
&repo_id,
|
||||
author.as_deref(),
|
||||
label.as_deref(),
|
||||
limit,
|
||||
)
|
||||
.await
|
||||
}
|
||||
PrCmd::Status {
|
||||
pr,
|
||||
status,
|
||||
body,
|
||||
body_file,
|
||||
repo_owner,
|
||||
repo_id,
|
||||
euc,
|
||||
to,
|
||||
merge_commit,
|
||||
} => {
|
||||
cmd_pr_status(
|
||||
client,
|
||||
&pr,
|
||||
&status,
|
||||
body.as_deref(),
|
||||
body_file.as_deref(),
|
||||
repo_owner.as_deref(),
|
||||
repo_id.as_deref(),
|
||||
euc.as_deref(),
|
||||
&to,
|
||||
merge_commit.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn read_optional_body_rejects_body_and_body_file_together() {
|
||||
assert!(read_optional_body(Some("body"), Some("file.md")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_optional_body_defaults_empty() {
|
||||
assert_eq!(read_optional_body(None, None).unwrap(), "");
|
||||
}
|
||||
}
|
||||
@@ -201,6 +201,9 @@ enum Cmd {
|
||||
/// Create, get, list, and set status on git issues (NIP-34)
|
||||
#[command(subcommand)]
|
||||
Issues(IssuesCmd),
|
||||
/// Open, update, list, and set status on git pull requests (NIP-34)
|
||||
#[command(subcommand)]
|
||||
Pr(PrCmd),
|
||||
/// Upload files to the relay's Blossom store
|
||||
#[command(subcommand)]
|
||||
Upload(UploadCmd),
|
||||
@@ -1081,6 +1084,147 @@ pub enum PatchesCmd {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum PrCmd {
|
||||
/// Open a git pull request (NIP-34 kind:1618)
|
||||
#[command(
|
||||
after_help = "Examples:\n buzz pr open --repo-owner <hex> --repo-id myrepo --subject 'Fix bug' --body-file - --commit $(git rev-parse HEAD) --clone https://relay/git/owner/myrepo --branch-name fix-bug\n buzz pr update --repo-owner <hex> --repo-id myrepo --pr <event> --pr-author <hex> --commit $(git rev-parse HEAD) --clone https://relay/git/owner/myrepo"
|
||||
)]
|
||||
Open {
|
||||
/// Repo owner pubkey (64-char hex)
|
||||
#[arg(long)]
|
||||
repo_owner: String,
|
||||
/// Repo identifier (d-tag)
|
||||
#[arg(long)]
|
||||
repo_id: String,
|
||||
/// Pull request subject/header
|
||||
#[arg(long, alias = "title")]
|
||||
subject: String,
|
||||
/// Pull request body markdown. Use '-' to read from stdin.
|
||||
#[arg(long, conflicts_with = "body_file")]
|
||||
body: Option<String>,
|
||||
/// Path to pull request body markdown, or '-' to read from stdin.
|
||||
#[arg(long, conflicts_with = "body")]
|
||||
body_file: Option<String>,
|
||||
/// Tip commit of the PR branch
|
||||
#[arg(long)]
|
||||
commit: String,
|
||||
/// Clone URL where the tip commit can be fetched — can be specified multiple times
|
||||
#[arg(long = "clone", required = true)]
|
||||
clone: Vec<String>,
|
||||
/// Recommended branch name
|
||||
#[arg(long)]
|
||||
branch_name: Option<String>,
|
||||
/// Most recent common ancestor with the target branch
|
||||
#[arg(long)]
|
||||
merge_base: Option<String>,
|
||||
/// Earliest-unique-commit of the repo
|
||||
#[arg(long)]
|
||||
euc: Option<String>,
|
||||
/// Label — can be specified multiple times
|
||||
#[arg(long = "label")]
|
||||
label: Vec<String>,
|
||||
/// Additional recipient pubkey(s) — can be specified multiple times
|
||||
#[arg(long = "to")]
|
||||
to: Vec<String>,
|
||||
/// Root patch event id this PR revises
|
||||
#[arg(long)]
|
||||
revision_of: Option<String>,
|
||||
},
|
||||
/// Update a git pull request tip (NIP-34 kind:1619)
|
||||
Update {
|
||||
/// Repo owner pubkey (64-char hex)
|
||||
#[arg(long)]
|
||||
repo_owner: String,
|
||||
/// Repo identifier (d-tag)
|
||||
#[arg(long)]
|
||||
repo_id: String,
|
||||
/// Pull request event id being updated
|
||||
#[arg(long)]
|
||||
pr: String,
|
||||
/// Pull request author's pubkey
|
||||
#[arg(long)]
|
||||
pr_author: String,
|
||||
/// Updated tip commit of the PR branch
|
||||
#[arg(long)]
|
||||
commit: String,
|
||||
/// Clone URL where the updated tip commit can be fetched — can be specified multiple times
|
||||
#[arg(long = "clone", required = true)]
|
||||
clone: Vec<String>,
|
||||
/// Markdown context for the update. Use '-' to read from stdin.
|
||||
#[arg(long, conflicts_with = "body_file")]
|
||||
body: Option<String>,
|
||||
/// Path to markdown context for the update, or '-' to read from stdin.
|
||||
#[arg(long, conflicts_with = "body")]
|
||||
body_file: Option<String>,
|
||||
/// Most recent common ancestor with the target branch
|
||||
#[arg(long)]
|
||||
merge_base: Option<String>,
|
||||
/// Earliest-unique-commit of the repo
|
||||
#[arg(long)]
|
||||
euc: Option<String>,
|
||||
/// Additional recipient pubkey(s) — can be specified multiple times
|
||||
#[arg(long = "to")]
|
||||
to: Vec<String>,
|
||||
},
|
||||
/// Get a PR by event id
|
||||
Get {
|
||||
/// PR event id (64-char hex)
|
||||
#[arg(long)]
|
||||
event: String,
|
||||
},
|
||||
/// List PRs for a repo
|
||||
List {
|
||||
/// Repo owner pubkey (64-char hex)
|
||||
#[arg(long)]
|
||||
repo_owner: String,
|
||||
/// Repo identifier (d-tag)
|
||||
#[arg(long)]
|
||||
repo_id: String,
|
||||
/// Filter by PR author pubkey
|
||||
#[arg(long)]
|
||||
author: Option<String>,
|
||||
/// Filter by label
|
||||
#[arg(long)]
|
||||
label: Option<String>,
|
||||
/// Maximum number of results
|
||||
#[arg(long)]
|
||||
limit: Option<u32>,
|
||||
},
|
||||
/// Set status on a PR (open/merged/closed/draft — NIP-34 kind:1630-1633)
|
||||
Status {
|
||||
/// Pull request event id
|
||||
#[arg(long)]
|
||||
pr: String,
|
||||
/// New status
|
||||
#[arg(long, value_parser = ["open", "merged", "closed", "draft"])]
|
||||
status: String,
|
||||
/// Markdown context for the status change. Use '-' to read from stdin.
|
||||
#[arg(long, conflicts_with = "body_file")]
|
||||
body: Option<String>,
|
||||
/// Path to markdown context for the status change, or '-' to read from stdin.
|
||||
#[arg(long, conflicts_with = "body")]
|
||||
body_file: Option<String>,
|
||||
/// Repo owner pubkey — requires --repo-id
|
||||
#[arg(long, requires = "repo_id")]
|
||||
repo_owner: Option<String>,
|
||||
/// Repo identifier (d-tag) — requires --repo-owner
|
||||
#[arg(long, requires = "repo_owner")]
|
||||
repo_id: Option<String>,
|
||||
/// Earliest-unique-commit of the repo
|
||||
#[arg(long)]
|
||||
euc: Option<String>,
|
||||
/// Additional recipient pubkey(s) for the status event (besides the
|
||||
/// repo owner, which is tagged automatically when --repo-owner is
|
||||
/// given) — e.g. PR author/reviewers. Can be specified multiple times.
|
||||
#[arg(long = "to")]
|
||||
to: Vec<String>,
|
||||
/// Merge commit id (status=merged only)
|
||||
#[arg(long)]
|
||||
merge_commit: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum IssuesCmd {
|
||||
/// Create a git issue (NIP-34 kind:1621)
|
||||
@@ -1316,6 +1460,7 @@ async fn run(cli: Cli) -> Result<(), CliError> {
|
||||
Cmd::Repos(sub) => commands::repos::dispatch(sub, &client).await,
|
||||
Cmd::Patches(sub) => commands::patches::dispatch(sub, &client).await,
|
||||
Cmd::Issues(sub) => commands::issues::dispatch(sub, &client).await,
|
||||
Cmd::Pr(sub) => commands::pr::dispatch(sub, &client).await,
|
||||
Cmd::Upload(sub) => commands::upload::dispatch(sub, &client).await,
|
||||
Cmd::Mem(sub) => commands::mem::dispatch(sub, &client).await,
|
||||
Cmd::Pack(_) => unreachable!("handled above"),
|
||||
@@ -1347,6 +1492,7 @@ mod tests {
|
||||
"notes",
|
||||
"pack",
|
||||
"patches",
|
||||
"pr",
|
||||
"reactions",
|
||||
"repos",
|
||||
"social",
|
||||
@@ -1460,6 +1606,10 @@ mod tests {
|
||||
]
|
||||
);
|
||||
assert_eq!(names(&cmd, "repos"), vec!["create", "get", "list"]);
|
||||
assert_eq!(
|
||||
names(&cmd, "pr"),
|
||||
vec!["get", "list", "open", "status", "update"]
|
||||
);
|
||||
assert_eq!(
|
||||
names(&cmd, "patches"),
|
||||
vec!["get", "list", "send", "status"]
|
||||
@@ -1484,6 +1634,7 @@ mod tests {
|
||||
("messages", 8),
|
||||
("pack", 2),
|
||||
("patches", 4),
|
||||
("pr", 5),
|
||||
("reactions", 3),
|
||||
("repos", 3),
|
||||
("social", 7),
|
||||
|
||||
@@ -7,9 +7,9 @@ use buzz_core::{
|
||||
kind::{
|
||||
KIND_AGENT_OBSERVER_FRAME, KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_DELETION,
|
||||
KIND_DM_ADD_MEMBER, KIND_DM_OPEN, KIND_EMOJI_SET, KIND_GIT_ISSUE, KIND_GIT_PATCH,
|
||||
KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT,
|
||||
KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_PRESENCE_UPDATE, KIND_WORKFLOW_DEF,
|
||||
KIND_WORKFLOW_TRIGGER,
|
||||
KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT,
|
||||
KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED,
|
||||
KIND_GIT_STATUS_OPEN, KIND_PRESENCE_UPDATE, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER,
|
||||
},
|
||||
observer::{
|
||||
content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG,
|
||||
@@ -1228,6 +1228,164 @@ pub fn build_git_status(
|
||||
Ok(EventBuilder::new(Kind::Custom(status.kind()), content).tags(tags))
|
||||
}
|
||||
|
||||
/// Metadata for a git pull-request event (kind:1618, NIP-34).
|
||||
///
|
||||
/// A PR points reviewers at a branch tip they can fetch — `commit` (the tip)
|
||||
/// plus at least one `clone_urls` entry where that commit is reachable. Unlike
|
||||
/// a [`build_git_patch`], the change is *not* inlined; the diff is whatever the
|
||||
/// tip introduces over its merge base. Per NIP-34 the tip SHOULD already be
|
||||
/// pushed to `refs/nostr/<pr-event-id>` (or otherwise reachable) in the clone
|
||||
/// repos before the event is signed — this builder does no network work and
|
||||
/// does not verify reachability, mirroring [`build_git_patch`]'s philosophy.
|
||||
#[derive(Default)]
|
||||
pub struct GitPullRequestMeta {
|
||||
/// Earliest-unique-commit of the repo (`r` tag) — lets clients subscribe
|
||||
/// to all PRs against a local repo.
|
||||
pub euc: Option<String>,
|
||||
/// Additional pubkeys to `p`-tag besides the repo owner.
|
||||
pub recipients: Vec<String>,
|
||||
/// PR subject line (`subject` tag) — required, used as the header.
|
||||
pub subject: String,
|
||||
/// Labels (`t` tags).
|
||||
pub labels: Vec<String>,
|
||||
/// Tip commit id of the PR branch (`c` tag) — required.
|
||||
pub commit: String,
|
||||
/// Clone URL(s) where the tip can be fetched (`clone` tag) — at least one.
|
||||
pub clone_urls: Vec<String>,
|
||||
/// Recommended branch name (`branch-name` tag).
|
||||
pub branch_name: Option<String>,
|
||||
/// Most recent common ancestor with the target branch (`merge-base` tag).
|
||||
pub merge_base: Option<String>,
|
||||
/// Root patch event this PR revises, which should then be closed
|
||||
/// (`e` tag) — optional.
|
||||
pub revision_of: Option<String>,
|
||||
}
|
||||
|
||||
/// Build a git pull-request event (kind:1618, NIP-34). `content` is the
|
||||
/// markdown PR description.
|
||||
pub fn build_git_pull_request(
|
||||
repo: &GitRepoCoord,
|
||||
content: &str,
|
||||
meta: &GitPullRequestMeta,
|
||||
) -> Result<EventBuilder, SdkError> {
|
||||
check_content(content, 64 * 1024)?;
|
||||
if meta.subject.is_empty() {
|
||||
return Err(SdkError::InvalidInput("subject must not be empty".into()));
|
||||
}
|
||||
if meta.subject.len() > 256 {
|
||||
return Err(SdkError::InvalidInput(format!(
|
||||
"subject exceeds 256 characters (got {})",
|
||||
meta.subject.len()
|
||||
)));
|
||||
}
|
||||
check_commit_hex(&meta.commit, "commit")?;
|
||||
if meta.clone_urls.is_empty() {
|
||||
return Err(SdkError::InvalidInput(
|
||||
"a pull request needs at least one --clone url where the tip commit can be fetched"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
let a_value = repo.to_a_tag_value()?;
|
||||
let owner = check_pubkey_hex(&repo.owner, "repo owner")?;
|
||||
|
||||
let mut tags = vec![tag(&["a", &a_value])?];
|
||||
if let Some(ref euc) = meta.euc {
|
||||
check_commit_hex(euc, "euc")?;
|
||||
tags.push(tag(&["r", euc])?);
|
||||
}
|
||||
tags.push(tag(&["p", &owner])?);
|
||||
for recipient in &meta.recipients {
|
||||
let pk = check_pubkey_hex(recipient, "recipient")?;
|
||||
tags.push(tag(&["p", &pk])?);
|
||||
}
|
||||
tags.push(tag(&["subject", &meta.subject])?);
|
||||
for label in &meta.labels {
|
||||
tags.push(tag(&["t", label])?);
|
||||
}
|
||||
tags.push(tag(&["c", &meta.commit])?);
|
||||
let mut clone_tag = vec!["clone"];
|
||||
clone_tag.extend(meta.clone_urls.iter().map(String::as_str));
|
||||
tags.push(tag(&clone_tag)?);
|
||||
if let Some(ref branch) = meta.branch_name {
|
||||
tags.push(tag(&["branch-name", branch])?);
|
||||
}
|
||||
if let Some(ref base) = meta.merge_base {
|
||||
check_commit_hex(base, "merge_base")?;
|
||||
tags.push(tag(&["merge-base", base])?);
|
||||
}
|
||||
if let Some(ref patch) = meta.revision_of {
|
||||
let patch_id = check_hex_exact(patch, 64, "revision_of")?;
|
||||
tags.push(tag(&["e", &patch_id])?);
|
||||
}
|
||||
|
||||
Ok(EventBuilder::new(Kind::Custom(KIND_GIT_PULL_REQUEST as u16), content).tags(tags))
|
||||
}
|
||||
|
||||
/// Metadata for a git pull-request update event (kind:1619, NIP-34). A PR
|
||||
/// update changes the tip commit of an existing PR; it references the PR via
|
||||
/// NIP-22 uppercase root tags (`E`/`P`).
|
||||
#[derive(Default)]
|
||||
pub struct GitPrUpdateMeta {
|
||||
/// Earliest-unique-commit of the repo (`r` tag).
|
||||
pub euc: Option<String>,
|
||||
/// Additional pubkeys to `p`-tag besides the repo owner.
|
||||
pub recipients: Vec<String>,
|
||||
/// The pull-request event being updated (`E` tag, NIP-22 root) — required.
|
||||
pub pr_event: String,
|
||||
/// The pull-request author (`P` tag, NIP-22 root) — required.
|
||||
pub pr_author: String,
|
||||
/// Updated tip commit id (`c` tag) — required.
|
||||
pub commit: String,
|
||||
/// Clone URL(s) where the new tip can be fetched (`clone` tag) — at least one.
|
||||
pub clone_urls: Vec<String>,
|
||||
/// Most recent common ancestor with the target branch (`merge-base` tag).
|
||||
pub merge_base: Option<String>,
|
||||
}
|
||||
|
||||
/// Build a git pull-request update event (kind:1619, NIP-34). `content` is
|
||||
/// optional markdown context for the update.
|
||||
pub fn build_git_pr_update(
|
||||
repo: &GitRepoCoord,
|
||||
content: &str,
|
||||
meta: &GitPrUpdateMeta,
|
||||
) -> Result<EventBuilder, SdkError> {
|
||||
check_content(content, 64 * 1024)?;
|
||||
let pr_event = check_hex_exact(&meta.pr_event, 64, "pr_event")?;
|
||||
let pr_author = check_pubkey_hex(&meta.pr_author, "pr_author")?;
|
||||
check_commit_hex(&meta.commit, "commit")?;
|
||||
if meta.clone_urls.is_empty() {
|
||||
return Err(SdkError::InvalidInput(
|
||||
"a pull request update needs at least one --clone url where the tip commit can be fetched"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
let a_value = repo.to_a_tag_value()?;
|
||||
let owner = check_pubkey_hex(&repo.owner, "repo owner")?;
|
||||
|
||||
let mut tags = vec![tag(&["a", &a_value])?];
|
||||
if let Some(ref euc) = meta.euc {
|
||||
check_commit_hex(euc, "euc")?;
|
||||
tags.push(tag(&["r", euc])?);
|
||||
}
|
||||
tags.push(tag(&["p", &owner])?);
|
||||
for recipient in &meta.recipients {
|
||||
let pk = check_pubkey_hex(recipient, "recipient")?;
|
||||
tags.push(tag(&["p", &pk])?);
|
||||
}
|
||||
tags.push(tag(&["E", &pr_event])?);
|
||||
tags.push(tag(&["P", &pr_author])?);
|
||||
tags.push(tag(&["c", &meta.commit])?);
|
||||
let mut clone_tag = vec!["clone"];
|
||||
clone_tag.extend(meta.clone_urls.iter().map(String::as_str));
|
||||
tags.push(tag(&clone_tag)?);
|
||||
if let Some(ref base) = meta.merge_base {
|
||||
check_commit_hex(base, "merge_base")?;
|
||||
tags.push(tag(&["merge-base", base])?);
|
||||
}
|
||||
|
||||
Ok(EventBuilder::new(Kind::Custom(KIND_GIT_PR_UPDATE as u16), content).tags(tags))
|
||||
}
|
||||
|
||||
/// Build a workflow definition event (kind 30620).
|
||||
///
|
||||
/// - `channel_id`: the channel this workflow belongs to (h-tag)
|
||||
@@ -2822,4 +2980,158 @@ mod tests {
|
||||
let err = build_presence_update("dnd").unwrap_err();
|
||||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||||
}
|
||||
|
||||
// ── build_git_pull_request / build_git_pr_update ──────────────────────────
|
||||
|
||||
fn pr_repo() -> GitRepoCoord {
|
||||
GitRepoCoord {
|
||||
owner: "a".repeat(64),
|
||||
id: "repo".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn full_clone_tag(event: &nostr::Event) -> Vec<String> {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|t| t.as_slice().first().map(|v| v.as_str()) == Some("clone"))
|
||||
.map(|t| t.as_slice()[1..].iter().map(|v| v.to_string()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_pr_happy_path() {
|
||||
let meta = GitPullRequestMeta {
|
||||
subject: "Add feature X".to_string(),
|
||||
commit: "c".repeat(40),
|
||||
clone_urls: vec!["https://example.com/repo.git".to_string()],
|
||||
branch_name: Some("feat/x".to_string()),
|
||||
labels: vec!["enhancement".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
let ev = sign(build_git_pull_request(&pr_repo(), "PR body", &meta).unwrap());
|
||||
assert_eq!(ev.kind.as_u16(), 1618);
|
||||
assert_eq!(ev.content, "PR body");
|
||||
assert!(has_tag(&ev, "a", &format!("30617:{}:repo", "a".repeat(64))));
|
||||
assert!(has_tag(&ev, "p", &"a".repeat(64)));
|
||||
assert!(has_tag(&ev, "subject", "Add feature X"));
|
||||
assert!(has_tag(&ev, "c", &"c".repeat(40)));
|
||||
assert!(has_tag(&ev, "t", "enhancement"));
|
||||
assert!(has_tag(&ev, "branch-name", "feat/x"));
|
||||
assert_eq!(
|
||||
full_clone_tag(&ev),
|
||||
vec!["https://example.com/repo.git".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_pr_emits_multi_url_clone_tag() {
|
||||
let meta = GitPullRequestMeta {
|
||||
subject: "s".to_string(),
|
||||
commit: "c".repeat(40),
|
||||
clone_urls: vec![
|
||||
"https://a.example/repo.git".to_string(),
|
||||
"https://b.example/repo.git".to_string(),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let ev = sign(build_git_pull_request(&pr_repo(), "", &meta).unwrap());
|
||||
assert_eq!(full_clone_tag(&ev).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_pr_rejects_empty_subject() {
|
||||
let meta = GitPullRequestMeta {
|
||||
subject: String::new(),
|
||||
commit: "c".repeat(40),
|
||||
clone_urls: vec!["https://example.com/repo.git".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
let err = build_git_pull_request(&pr_repo(), "body", &meta).unwrap_err();
|
||||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_pr_rejects_missing_clone_url() {
|
||||
let meta = GitPullRequestMeta {
|
||||
subject: "s".to_string(),
|
||||
commit: "c".repeat(40),
|
||||
clone_urls: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
let err = build_git_pull_request(&pr_repo(), "body", &meta).unwrap_err();
|
||||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_pr_rejects_short_commit() {
|
||||
let meta = GitPullRequestMeta {
|
||||
subject: "s".to_string(),
|
||||
commit: "abc".to_string(),
|
||||
clone_urls: vec!["https://example.com/repo.git".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
let err = build_git_pull_request(&pr_repo(), "body", &meta).unwrap_err();
|
||||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_pr_revision_of_emits_e_tag() {
|
||||
let patch = event_id().to_hex();
|
||||
let meta = GitPullRequestMeta {
|
||||
subject: "s".to_string(),
|
||||
commit: "c".repeat(40),
|
||||
clone_urls: vec!["https://example.com/repo.git".to_string()],
|
||||
revision_of: Some(patch.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
let ev = sign(build_git_pull_request(&pr_repo(), "", &meta).unwrap());
|
||||
assert!(has_tag(&ev, "e", &patch));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_pr_update_happy_path() {
|
||||
let pr = event_id().to_hex();
|
||||
let meta = GitPrUpdateMeta {
|
||||
pr_event: pr.clone(),
|
||||
pr_author: "b".repeat(64),
|
||||
commit: "d".repeat(40),
|
||||
clone_urls: vec!["https://example.com/repo.git".to_string()],
|
||||
merge_base: Some("e".repeat(40)),
|
||||
..Default::default()
|
||||
};
|
||||
let ev = sign(build_git_pr_update(&pr_repo(), "rebased", &meta).unwrap());
|
||||
assert_eq!(ev.kind.as_u16(), 1619);
|
||||
assert!(has_tag(&ev, "E", &pr));
|
||||
assert!(has_tag(&ev, "P", &"b".repeat(64)));
|
||||
assert!(has_tag(&ev, "c", &"d".repeat(40)));
|
||||
assert!(has_tag(&ev, "merge-base", &"e".repeat(40)));
|
||||
assert_eq!(full_clone_tag(&ev).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_pr_update_rejects_bad_pr_event() {
|
||||
let meta = GitPrUpdateMeta {
|
||||
pr_event: "not-hex".to_string(),
|
||||
pr_author: "b".repeat(64),
|
||||
commit: "d".repeat(40),
|
||||
clone_urls: vec!["https://example.com/repo.git".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
let err = build_git_pr_update(&pr_repo(), "", &meta).unwrap_err();
|
||||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_pr_update_rejects_missing_clone_url() {
|
||||
let meta = GitPrUpdateMeta {
|
||||
pr_event: event_id().to_hex(),
|
||||
pr_author: "b".repeat(64),
|
||||
commit: "d".repeat(40),
|
||||
clone_urls: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
let err = build_git_pr_update(&pr_repo(), "", &meta).unwrap_err();
|
||||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user