Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 168 additions & 1 deletion crates/buzz-cli/src/commands/pr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ 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};
use buzz_sdk::{
GitDiffSide, GitPrCommentAnchor, GitPrCommentMeta, GitPrReviewDecision, GitPrUpdateMeta,
GitPullRequestMeta, GitRepoCoord, GitStatusMeta,
};

fn read_optional_body(body: Option<&str>, body_file: Option<&str>) -> Result<String, CliError> {
match (body, body_file) {
Expand Down Expand Up @@ -62,6 +65,120 @@ pub async fn cmd_open_pr(
Ok(())
}

#[allow(clippy::too_many_arguments)]
pub async fn cmd_comment_pr(
client: &BuzzClient,
repo_owner: &str,
repo_id: &str,
pr: &str,
body: Option<&str>,
body_file: Option<&str>,
file: Option<&str>,
line: Option<u32>,
side: Option<&str>,
commit: Option<&str>,
to: &[String],
) -> Result<(), CliError> {
validate_hex64(repo_owner)?;
validate_repo_id(repo_id)?;
validate_hex64(pr)?;
for recipient in to {
validate_hex64(recipient)?;
}
if body.is_none() && body_file.is_none() {
return Err(CliError::Usage(
"a comment needs --body or --body-file".into(),
));
}
let content = read_optional_body(body, body_file)?;

// clap's `requires_all` guarantees these arrive together.
let anchor = match (file, line, side) {
(Some(path), Some(line), Some(side)) => Some(GitPrCommentAnchor {
path: path.to_string(),
side: parse_diff_side(side)?,
line,
}),
_ => None,
};

let repo = GitRepoCoord {
owner: repo_owner.to_string(),
id: repo_id.to_string(),
};
let meta = GitPrCommentMeta {
recipients: to.to_vec(),
anchor,
commit: commit.map(str::to_string),
decision: None,
};

let builder = buzz_sdk::build_git_pr_comment(&repo, pr, &content, &meta).map_err(sdk_err)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}

fn parse_diff_side(side: &str) -> Result<GitDiffSide, CliError> {
match side {
"old" => Ok(GitDiffSide::Old),
"new" => Ok(GitDiffSide::New),
other => Err(CliError::Usage(format!(
"--side must be 'old' or 'new' (got {other:?})"
))),
}
}

fn parse_review_decision(decision: &str) -> Result<GitPrReviewDecision, CliError> {
match decision {
"approve" => Ok(GitPrReviewDecision::Approve),
"request-changes" => Ok(GitPrReviewDecision::RequestChanges),
other => Err(CliError::Usage(format!(
"--decision must be 'approve' or 'request-changes' (got {other:?})"
))),
}
}

#[allow(clippy::too_many_arguments)]
pub async fn cmd_review_pr(
client: &BuzzClient,
repo_owner: &str,
repo_id: &str,
pr: &str,
decision: &str,
commit: &str,
body: Option<&str>,
body_file: Option<&str>,
to: &[String],
) -> Result<(), CliError> {
validate_hex64(repo_owner)?;
validate_repo_id(repo_id)?;
validate_hex64(pr)?;
for recipient in to {
validate_hex64(recipient)?;
}
// An empty body is allowed: the SDK substitutes the decision summary.
let content = read_optional_body(body, body_file)?;

let repo = GitRepoCoord {
owner: repo_owner.to_string(),
id: repo_id.to_string(),
};
let meta = GitPrCommentMeta {
recipients: to.to_vec(),
anchor: None,
commit: Some(commit.to_string()),
decision: Some(parse_review_decision(decision)?),
};

let builder = buzz_sdk::build_git_pr_comment(&repo, pr, &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,
Expand Down Expand Up @@ -216,6 +333,56 @@ pub async fn cmd_pr_status(
pub async fn dispatch(cmd: crate::PrCmd, client: &BuzzClient) -> Result<(), CliError> {
use crate::PrCmd;
match cmd {
PrCmd::Comment {
repo_owner,
repo_id,
pr,
body,
body_file,
file,
line,
side,
commit,
to,
} => {
cmd_comment_pr(
client,
&repo_owner,
&repo_id,
&pr,
body.as_deref(),
body_file.as_deref(),
file.as_deref(),
line,
side.as_deref(),
commit.as_deref(),
&to,
)
.await
}
PrCmd::Review {
repo_owner,
repo_id,
pr,
decision,
commit,
body,
body_file,
to,
} => {
cmd_review_pr(
client,
&repo_owner,
&repo_id,
&pr,
&decision,
&commit,
body.as_deref(),
body_file.as_deref(),
&to,
)
.await
}
PrCmd::Open {
repo_owner,
repo_id,
Expand Down
73 changes: 71 additions & 2 deletions crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1381,6 +1381,75 @@ pub enum PrCmd {
#[arg(long = "to")]
to: Vec<String>,
},
/// Comment on a git pull request (kind:1 with NIP-34 project tags)
#[command(
after_help = "Examples:\n buzz pr comment --repo-owner <hex> --repo-id myrepo --pr <event> --body 'Looks good to me'\n buzz pr comment --repo-owner <hex> --repo-id myrepo --pr <event> --body-file - --file src/main.rs --line 42 --side new --commit $(git rev-parse HEAD)"
)]
Comment {
/// Repo owner pubkey (64-char hex)
#[arg(long)]
repo_owner: String,
/// Repo identifier (d-tag)
#[arg(long)]
repo_id: String,
/// Pull request event id (64-char hex)
#[arg(long)]
pr: String,
/// Comment body markdown. Use '-' to read from stdin.
#[arg(
long,
conflicts_with = "body_file",
required_unless_present = "body_file"
)]
body: Option<String>,
/// Path to comment body markdown, or '-' to read from stdin.
#[arg(long, conflicts_with = "body")]
body_file: Option<String>,
/// Repository-relative file path for an inline comment — requires
/// --line, --side and --commit
#[arg(long, requires_all = ["line", "side", "commit"])]
file: Option<String>,
/// 1-based line number within the diff side
#[arg(long, requires = "file")]
line: Option<u32>,
/// Diff side the line belongs to
#[arg(long, requires = "file", value_parser = ["old", "new"])]
side: Option<String>,
/// Reviewed commit id — required for, and only used by, inline comments
#[arg(long, requires = "file")]
commit: Option<String>,
/// Additional recipient pubkey(s) — can be specified multiple times
#[arg(long = "to")]
to: Vec<String>,
},
/// Submit a review decision on a git pull request (kind:1, labeled)
Review {
/// Repo owner pubkey (64-char hex)
#[arg(long)]
repo_owner: String,
/// Repo identifier (d-tag)
#[arg(long)]
repo_id: String,
/// Pull request event id (64-char hex)
#[arg(long)]
pr: String,
/// Review decision
#[arg(long, value_parser = ["approve", "request-changes"])]
decision: String,
/// Reviewed commit id — the pull request tip you reviewed
#[arg(long)]
commit: String,
/// Review body markdown. Defaults to a decision summary. Use '-' to
/// read from stdin.
#[arg(long, conflicts_with = "body_file")]
body: Option<String>,
/// Path to review body markdown, or '-' to read from stdin.
#[arg(long, conflicts_with = "body")]
body_file: 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)
Expand Down Expand Up @@ -1964,7 +2033,7 @@ mod tests {
assert_eq!(protect_names, vec!["list", "remove", "set"]);
assert_eq!(
names(&cmd, "pr"),
vec!["get", "list", "open", "status", "update"]
vec!["comment", "get", "list", "open", "review", "status", "update"]
);
assert_eq!(
names(&cmd, "patches"),
Expand Down Expand Up @@ -2006,7 +2075,7 @@ mod tests {
("messages", 8),
("pack", 2),
("patches", 4),
("pr", 5),
("pr", 7),
("reactions", 3),
("repos", 4),
("social", 7),
Expand Down
Loading