Skip to content

Commit 5fef9b7

Browse files
tlongwell-blocknpub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dTyler Longwellnpub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
authored
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>
1 parent d05f122 commit 5fef9b7

4 files changed

Lines changed: 805 additions & 3 deletions

File tree

crates/buzz-cli/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pub mod messages;
88
pub mod notes;
99
pub mod pack;
1010
pub mod patches;
11+
pub mod pr;
1112
pub mod reactions;
1213
pub mod repos;
1314
pub mod social;

crates/buzz-cli/src/commands/pr.rs

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
use crate::client::BuzzClient;
2+
use crate::error::CliError;
3+
use crate::validate::{
4+
read_file_or_stdin, read_or_stdin, sdk_err, validate_hex64, validate_repo_id,
5+
};
6+
use buzz_sdk::{GitPrUpdateMeta, GitPullRequestMeta, GitRepoCoord, GitStatusMeta};
7+
8+
fn read_optional_body(body: Option<&str>, body_file: Option<&str>) -> Result<String, CliError> {
9+
match (body, body_file) {
10+
(Some(_), Some(_)) => Err(CliError::Usage(
11+
"--body and --body-file are mutually exclusive".into(),
12+
)),
13+
(Some(value), None) => read_or_stdin(value),
14+
(None, Some(path)) => read_file_or_stdin(path),
15+
(None, None) => Ok(String::new()),
16+
}
17+
}
18+
19+
#[allow(clippy::too_many_arguments)]
20+
pub async fn cmd_open_pr(
21+
client: &BuzzClient,
22+
repo_owner: &str,
23+
repo_id: &str,
24+
subject: &str,
25+
body: Option<&str>,
26+
body_file: Option<&str>,
27+
commit: &str,
28+
clone_urls: &[String],
29+
branch_name: Option<&str>,
30+
merge_base: Option<&str>,
31+
euc: Option<&str>,
32+
labels: &[String],
33+
to: &[String],
34+
revision_of: Option<&str>,
35+
) -> Result<(), CliError> {
36+
validate_hex64(repo_owner)?;
37+
validate_repo_id(repo_id)?;
38+
let content = read_optional_body(body, body_file)?;
39+
40+
let repo = GitRepoCoord {
41+
owner: repo_owner.to_string(),
42+
id: repo_id.to_string(),
43+
};
44+
let meta = GitPullRequestMeta {
45+
euc: euc.map(str::to_string),
46+
recipients: to.to_vec(),
47+
subject: subject.to_string(),
48+
labels: labels.to_vec(),
49+
commit: commit.to_string(),
50+
clone_urls: clone_urls.to_vec(),
51+
branch_name: branch_name.map(str::to_string),
52+
merge_base: merge_base.map(str::to_string),
53+
revision_of: revision_of.map(str::to_string),
54+
};
55+
56+
let builder = buzz_sdk::build_git_pull_request(&repo, &content, &meta).map_err(sdk_err)?;
57+
let event = client.sign_event(builder)?;
58+
let resp = client.submit_event(event).await?;
59+
println!("{resp}");
60+
Ok(())
61+
}
62+
63+
#[allow(clippy::too_many_arguments)]
64+
pub async fn cmd_update_pr(
65+
client: &BuzzClient,
66+
repo_owner: &str,
67+
repo_id: &str,
68+
pr: &str,
69+
pr_author: &str,
70+
commit: &str,
71+
clone_urls: &[String],
72+
body: Option<&str>,
73+
body_file: Option<&str>,
74+
merge_base: Option<&str>,
75+
euc: Option<&str>,
76+
to: &[String],
77+
) -> Result<(), CliError> {
78+
validate_hex64(repo_owner)?;
79+
validate_repo_id(repo_id)?;
80+
validate_hex64(pr)?;
81+
validate_hex64(pr_author)?;
82+
let content = read_optional_body(body, body_file)?;
83+
84+
let repo = GitRepoCoord {
85+
owner: repo_owner.to_string(),
86+
id: repo_id.to_string(),
87+
};
88+
let meta = GitPrUpdateMeta {
89+
euc: euc.map(str::to_string),
90+
recipients: to.to_vec(),
91+
pr_event: pr.to_string(),
92+
pr_author: pr_author.to_string(),
93+
commit: commit.to_string(),
94+
clone_urls: clone_urls.to_vec(),
95+
merge_base: merge_base.map(str::to_string),
96+
};
97+
98+
let builder = buzz_sdk::build_git_pr_update(&repo, &content, &meta).map_err(sdk_err)?;
99+
let event = client.sign_event(builder)?;
100+
let resp = client.submit_event(event).await?;
101+
println!("{resp}");
102+
Ok(())
103+
}
104+
105+
pub async fn cmd_get_pr(client: &BuzzClient, event: &str) -> Result<(), CliError> {
106+
validate_hex64(event)?;
107+
let filter = serde_json::json!({
108+
"kinds": [1618],
109+
"ids": [event]
110+
});
111+
let resp = client.query(&filter).await?;
112+
println!("{resp}");
113+
Ok(())
114+
}
115+
116+
pub async fn cmd_list_prs(
117+
client: &BuzzClient,
118+
repo_owner: &str,
119+
repo_id: &str,
120+
author: Option<&str>,
121+
label: Option<&str>,
122+
limit: Option<u32>,
123+
) -> Result<(), CliError> {
124+
validate_hex64(repo_owner)?;
125+
validate_repo_id(repo_id)?;
126+
127+
let a_value = format!("30617:{repo_owner}:{repo_id}");
128+
let mut filter = serde_json::json!({
129+
"kinds": [1618],
130+
"#a": [a_value]
131+
});
132+
133+
if let Some(pk) = author {
134+
validate_hex64(pk)?;
135+
filter["authors"] = serde_json::json!([pk]);
136+
}
137+
if let Some(l) = label {
138+
filter["#t"] = serde_json::json!([l]);
139+
}
140+
if let Some(n) = limit {
141+
filter["limit"] = serde_json::json!(n);
142+
}
143+
144+
let resp = client.query(&filter).await?;
145+
println!("{resp}");
146+
Ok(())
147+
}
148+
149+
#[allow(clippy::too_many_arguments)]
150+
pub async fn cmd_pr_status(
151+
client: &BuzzClient,
152+
pr: &str,
153+
status: &str,
154+
body: Option<&str>,
155+
body_file: Option<&str>,
156+
repo_owner: Option<&str>,
157+
repo_id: Option<&str>,
158+
euc: Option<&str>,
159+
to: &[String],
160+
merge_commit: Option<&str>,
161+
) -> Result<(), CliError> {
162+
validate_hex64(pr)?;
163+
let status = crate::commands::patches::parse_status(status)?;
164+
let content = read_optional_body(body, body_file)?;
165+
166+
let repo = match (repo_owner, repo_id) {
167+
(Some(owner), Some(id)) => {
168+
validate_hex64(owner)?;
169+
validate_repo_id(id)?;
170+
Some(GitRepoCoord {
171+
owner: owner.to_string(),
172+
id: id.to_string(),
173+
})
174+
}
175+
(None, None) => None,
176+
_ => {
177+
return Err(CliError::Usage(
178+
"--repo-owner and --repo-id must be given together".into(),
179+
))
180+
}
181+
};
182+
183+
// Mirrors patch/issue status: default a `p` tag to the repo owner when
184+
// known; callers can add PR author/reviewers with repeated `--to`.
185+
let mut recipients = Vec::new();
186+
if let Some(ref repo) = repo {
187+
recipients.push(repo.owner.clone());
188+
}
189+
for recipient in to {
190+
validate_hex64(recipient)?;
191+
if !recipients.contains(recipient) {
192+
recipients.push(recipient.clone());
193+
}
194+
}
195+
196+
let meta = GitStatusMeta {
197+
root_event: pr.to_string(),
198+
accepted_revision_root: None,
199+
repo,
200+
euc: euc.map(str::to_string),
201+
recipients,
202+
applied_patches: vec![],
203+
merge_commit: merge_commit.map(str::to_string),
204+
applied_as_commits: vec![],
205+
};
206+
207+
let builder = buzz_sdk::build_git_status(status, &content, &meta).map_err(sdk_err)?;
208+
let event = client.sign_event(builder)?;
209+
let resp = client.submit_event(event).await?;
210+
println!("{resp}");
211+
Ok(())
212+
}
213+
214+
pub async fn dispatch(cmd: crate::PrCmd, client: &BuzzClient) -> Result<(), CliError> {
215+
use crate::PrCmd;
216+
match cmd {
217+
PrCmd::Open {
218+
repo_owner,
219+
repo_id,
220+
subject,
221+
body,
222+
body_file,
223+
commit,
224+
clone,
225+
branch_name,
226+
merge_base,
227+
euc,
228+
label,
229+
to,
230+
revision_of,
231+
} => {
232+
cmd_open_pr(
233+
client,
234+
&repo_owner,
235+
&repo_id,
236+
&subject,
237+
body.as_deref(),
238+
body_file.as_deref(),
239+
&commit,
240+
&clone,
241+
branch_name.as_deref(),
242+
merge_base.as_deref(),
243+
euc.as_deref(),
244+
&label,
245+
&to,
246+
revision_of.as_deref(),
247+
)
248+
.await
249+
}
250+
PrCmd::Update {
251+
repo_owner,
252+
repo_id,
253+
pr,
254+
pr_author,
255+
commit,
256+
clone,
257+
body,
258+
body_file,
259+
merge_base,
260+
euc,
261+
to,
262+
} => {
263+
cmd_update_pr(
264+
client,
265+
&repo_owner,
266+
&repo_id,
267+
&pr,
268+
&pr_author,
269+
&commit,
270+
&clone,
271+
body.as_deref(),
272+
body_file.as_deref(),
273+
merge_base.as_deref(),
274+
euc.as_deref(),
275+
&to,
276+
)
277+
.await
278+
}
279+
PrCmd::Get { event } => cmd_get_pr(client, &event).await,
280+
PrCmd::List {
281+
repo_owner,
282+
repo_id,
283+
author,
284+
label,
285+
limit,
286+
} => {
287+
cmd_list_prs(
288+
client,
289+
&repo_owner,
290+
&repo_id,
291+
author.as_deref(),
292+
label.as_deref(),
293+
limit,
294+
)
295+
.await
296+
}
297+
PrCmd::Status {
298+
pr,
299+
status,
300+
body,
301+
body_file,
302+
repo_owner,
303+
repo_id,
304+
euc,
305+
to,
306+
merge_commit,
307+
} => {
308+
cmd_pr_status(
309+
client,
310+
&pr,
311+
&status,
312+
body.as_deref(),
313+
body_file.as_deref(),
314+
repo_owner.as_deref(),
315+
repo_id.as_deref(),
316+
euc.as_deref(),
317+
&to,
318+
merge_commit.as_deref(),
319+
)
320+
.await
321+
}
322+
}
323+
}
324+
325+
#[cfg(test)]
326+
mod tests {
327+
use super::*;
328+
329+
#[test]
330+
fn read_optional_body_rejects_body_and_body_file_together() {
331+
assert!(read_optional_body(Some("body"), Some("file.md")).is_err());
332+
}
333+
334+
#[test]
335+
fn read_optional_body_defaults_empty() {
336+
assert_eq!(read_optional_body(None, None).unwrap(), "");
337+
}
338+
}

0 commit comments

Comments
 (0)