1use std::io::Write;
9
10use anyhow::{anyhow, bail, Context, Result};
11use clap::{Args, Subcommand};
12use serde_json::{json, Value};
13
14use crate::client::{self, Client};
15use crate::commands::issue::resolve_repo;
16use crate::commands::parse_slug;
17use crate::editor;
18use crate::output::{render_record, render_table, FormatChoice};
19
20#[derive(Debug, Subcommand)]
21pub enum PrCmd {
22 List(ListArgs),
23 View(ViewArgs),
24 Create(CreateArgs),
25 Comment(CommentArgs),
26 Close(NumberArgs),
27 Reopen(NumberArgs),
28 Checkout(NumberArgs),
30 Merge(MergeArgs),
32 Review(ReviewArgs),
34}
35
36#[derive(Debug, Args)]
37pub struct ListArgs {
38 pub repo: String,
39 #[arg(long, default_value = "open")]
40 pub state: String,
41 #[arg(long, default_value_t = false)]
42 pub json: bool,
43 #[arg(long, default_value_t = false)]
44 pub tsv: bool,
45}
46
47#[derive(Debug, Args)]
48pub struct ViewArgs {
49 pub repo: String,
50 pub number: i64,
51 #[arg(long, default_value_t = false)]
52 pub json: bool,
53 #[arg(long, default_value_t = false)]
54 pub tsv: bool,
55}
56
57#[derive(Debug, Args)]
58pub struct CreateArgs {
59 pub repo: String,
60 #[arg(long, value_parser = clap::builder::NonEmptyStringValueParser::new())]
61 pub title: String,
62 #[arg(long)]
63 pub body: Option<String>,
64 #[arg(long, value_parser = clap::builder::NonEmptyStringValueParser::new())]
66 pub head: String,
67 #[arg(long, value_parser = clap::builder::NonEmptyStringValueParser::new())]
69 pub base: String,
70}
71
72#[derive(Debug, Args)]
73pub struct CommentArgs {
74 pub repo: String,
75 pub number: i64,
76 #[arg(long)]
77 pub body: Option<String>,
78}
79
80#[derive(Debug, Args)]
81pub struct NumberArgs {
82 pub repo: String,
83 pub number: i64,
84}
85
86#[derive(Debug, Args)]
87pub struct MergeArgs {
88 pub repo: String,
89 pub number: i64,
90 #[arg(long, default_value = "merge")]
92 pub strategy: String,
93}
94
95#[derive(Debug, Args)]
96pub struct ReviewArgs {
97 pub repo: String,
98 pub number: i64,
99 #[arg(long, conflicts_with_all = ["request_changes", "comment"])]
100 pub approve: bool,
101 #[arg(long, conflicts_with_all = ["approve", "comment"])]
102 pub request_changes: bool,
103 #[arg(long, conflicts_with_all = ["approve", "request_changes"])]
104 pub comment: bool,
105 #[arg(long, default_value = "")]
106 pub body: String,
107}
108
109pub fn run(cmd: PrCmd) -> Result<()> {
110 match cmd {
111 PrCmd::List(a) => list(a),
112 PrCmd::View(a) => view(a),
113 PrCmd::Create(a) => create(a),
114 PrCmd::Comment(a) => comment(a),
115 PrCmd::Close(_) | PrCmd::Reopen(_) => {
116 bail!("PR close/reopen is not exposed in this API version — use the web UI");
120 }
121 PrCmd::Checkout(a) => checkout(a),
122 PrCmd::Merge(a) => merge(a),
123 PrCmd::Review(a) => review(a),
124 }
125}
126
127fn list(args: ListArgs) -> Result<()> {
128 let (cli, _cfg) = client::authenticated()?;
129 let (repo_id, _owner, _name) = resolve_repo(&cli, &args.repo)?;
130 let data = cli.execute(PR_LIST, json!({ "id": repo_id }))?;
131 let prs = data["pullRequests"].as_array().cloned().unwrap_or_default();
132 let rows = pr_rows(&prs, &args.state);
133 let fmt = FormatChoice {
134 json: args.json,
135 tsv: args.tsv,
136 }
137 .resolve();
138 let out = render_table(&["#", "status", "title", "branches", "author"], &rows, fmt);
139 std::io::stdout().lock().write_all(out.as_bytes())?;
140 Ok(())
141}
142
143fn view(args: ViewArgs) -> Result<()> {
144 let (cli, _cfg) = client::authenticated()?;
145 let pr = resolve_pr(&cli, &args.repo, args.number)?;
146 let pairs = vec![
147 ("PR", format!("#{}", args.number)),
148 ("Title", pr["title"].as_str().unwrap_or("").to_string()),
149 ("Status", pr["status"].as_str().unwrap_or("").to_string()),
150 (
151 "Head",
152 pr["sourceBranch"].as_str().unwrap_or("").to_string(),
153 ),
154 (
155 "Base",
156 pr["targetBranch"].as_str().unwrap_or("").to_string(),
157 ),
158 (
159 "Author",
160 pr["authorUsername"].as_str().unwrap_or("").to_string(),
161 ),
162 ("Body", pr["body"].as_str().unwrap_or("").to_string()),
163 ];
164 let fmt = FormatChoice {
165 json: args.json,
166 tsv: args.tsv,
167 }
168 .resolve();
169 std::io::stdout()
170 .lock()
171 .write_all(render_record(&pairs, fmt).as_bytes())?;
172 Ok(())
173}
174
175fn create(args: CreateArgs) -> Result<()> {
176 let (cli, _cfg) = client::authenticated()?;
177 let (repo_id, owner, name) = resolve_repo(&cli, &args.repo)?;
178 let body = match args.body {
179 Some(b) => b,
180 None => editor::compose("")?,
181 };
182 let data = cli.execute(
183 CREATE_PR,
184 json!({
185 "input": {
186 "repositoryId": repo_id,
187 "title": args.title,
188 "body": body,
189 "sourceBranch": args.head,
190 "targetBranch": args.base,
191 }
192 }),
193 )?;
194 let number = data["createPullRequest"]["number"].as_i64().unwrap_or(0);
195 writeln!(
196 std::io::stdout().lock(),
197 "Created PR #{} in {}/{}",
198 number,
199 owner,
200 name
201 )?;
202 Ok(())
203}
204
205fn comment(args: CommentArgs) -> Result<()> {
206 let (cli, _cfg) = client::authenticated()?;
207 let pr = resolve_pr(&cli, &args.repo, args.number)?;
208 let pr_id = pr["id"].as_str().ok_or_else(|| anyhow!("PR id missing"))?;
209 let body = match args.body {
210 Some(b) => b,
211 None => editor::compose("")?,
212 };
213 cli.execute(
214 ADD_PR_COMMENT,
215 json!({ "input": { "pullRequestId": pr_id, "body": body } }),
216 )?;
217 writeln!(
218 std::io::stdout().lock(),
219 "Comment posted to {}#{}",
220 args.repo,
221 args.number
222 )?;
223 Ok(())
224}
225
226fn checkout(args: NumberArgs) -> Result<()> {
227 let (cli, _cfg) = client::authenticated()?;
228 let pr = resolve_pr(&cli, &args.repo, args.number)?;
229 let head_branch = pr["sourceBranch"]
230 .as_str()
231 .ok_or_else(|| anyhow!("PR has no sourceBranch"))?;
232 let (owner, name) = parse_slug(&args.repo)?;
233 let url = format!("{}/{}/{}.git", cli.host(), owner, name);
234 let status = std::process::Command::new("git")
235 .arg("fetch")
236 .arg(&url)
237 .arg(format!("{head_branch}:{head_branch}"))
238 .status()
239 .context("run `git fetch`")?;
240 if !status.success() {
241 bail!("git fetch exited with status {status}");
242 }
243 let status = std::process::Command::new("git")
244 .arg("checkout")
245 .arg(head_branch)
246 .status()
247 .context("run `git checkout`")?;
248 if !status.success() {
249 bail!("git checkout exited with status {status}");
250 }
251 Ok(())
252}
253
254fn merge(args: MergeArgs) -> Result<()> {
255 let (cli, _cfg) = client::authenticated()?;
256 let pr = resolve_pr(&cli, &args.repo, args.number)?;
257 let pr_id = pr["id"].as_str().ok_or_else(|| anyhow!("PR id missing"))?;
258 let strategy = merge_strategy_arg(&args.strategy)?;
259 cli.execute(
260 MERGE_PR,
261 json!({ "input": { "pullRequestId": pr_id, "strategy": strategy } }),
262 )?;
263 writeln!(
264 std::io::stdout().lock(),
265 "Merged PR {}#{} ({})",
266 args.repo,
267 args.number,
268 strategy
269 )?;
270 Ok(())
271}
272
273fn review(args: ReviewArgs) -> Result<()> {
274 let (cli, _cfg) = client::authenticated()?;
275 let pr = resolve_pr(&cli, &args.repo, args.number)?;
276 let pr_id = pr["id"].as_str().ok_or_else(|| anyhow!("PR id missing"))?;
277 let state = review_state_arg(&args)?;
278 cli.execute(
279 SUBMIT_REVIEW,
280 json!({
281 "input": {
282 "pullRequestId": pr_id,
283 "state": state,
284 "body": args.body,
285 }
286 }),
287 )?;
288 writeln!(
289 std::io::stdout().lock(),
290 "Submitted {} review on {}#{}",
291 state,
292 args.repo,
293 args.number
294 )?;
295 Ok(())
296}
297
298fn merge_strategy_arg(strategy: &str) -> Result<&'static str> {
299 match strategy.to_ascii_lowercase().as_str() {
300 "ff" | "fast-forward" | "fast_forward" => Ok("FAST_FORWARD"),
301 "merge" | "merge-commit" | "merge_commit" => Ok("MERGE_COMMIT"),
302 "squash" => Ok("SQUASH"),
303 "rebase" => Ok("REBASE"),
304 other => bail!("unknown merge strategy `{other}` (expected ff/merge/squash/rebase)"),
305 }
306}
307
308fn review_state_arg(args: &ReviewArgs) -> Result<&'static str> {
309 match (args.approve, args.request_changes, args.comment) {
310 (true, false, false) => Ok("APPROVED"),
311 (false, true, false) => Ok("CHANGES_REQUESTED"),
312 (false, false, true) => Ok("COMMENTED"),
313 (false, false, false) => bail!("pass one of --approve / --request-changes / --comment"),
314 _ => bail!("--approve / --request-changes / --comment are mutually exclusive"),
315 }
316}
317
318fn resolve_pr(cli: &Client, slug: &str, number: i64) -> Result<Value> {
319 let (repo_id, owner, name) = resolve_repo(cli, slug)?;
320 let data = cli.execute(PR_LIST, json!({ "id": repo_id }))?;
321 let prs = data["pullRequests"].as_array().cloned().unwrap_or_default();
322 prs.into_iter()
323 .find(|p| p["number"].as_i64() == Some(number))
324 .ok_or_else(|| anyhow!("no PR #{} in {}/{}", number, owner, name))
325}
326
327fn pr_rows(prs: &[Value], state: &str) -> Vec<Vec<String>> {
328 let state_filter = state.to_ascii_lowercase();
329 prs.iter()
330 .filter(|p| match state_filter.as_str() {
331 "all" => true,
332 "closed" => matches!(p["status"].as_str(), Some("CLOSED")),
333 "merged" => matches!(p["status"].as_str(), Some("MERGED")),
334 _ => matches!(p["status"].as_str(), Some("OPEN")),
335 })
336 .map(|p| {
337 vec![
338 format!("#{}", p["number"].as_i64().unwrap_or(0)),
339 p["status"].as_str().unwrap_or("").to_string(),
340 p["title"].as_str().unwrap_or("").to_string(),
341 format!(
342 "{} → {}",
343 p["sourceBranch"].as_str().unwrap_or(""),
344 p["targetBranch"].as_str().unwrap_or("")
345 ),
346 p["authorUsername"].as_str().unwrap_or("").to_string(),
347 ]
348 })
349 .collect()
350}
351
352const PR_LIST: &str = "query($id: UUID!) { \
353 pullRequests(repositoryId: $id) { \
354 id number title status sourceBranch targetBranch authorUsername body updatedAt \
355 } \
356}";
357
358const CREATE_PR: &str = "mutation($input: CreatePullRequestInput!) { \
359 createPullRequest(input: $input) { id number } \
360}";
361
362const ADD_PR_COMMENT: &str = "mutation($input: AddPullRequestCommentInput!) { \
363 addPullRequestComment(input: $input) { id } \
364}";
365
366const MERGE_PR: &str = "mutation($input: MergePullRequestInput!) { \
367 mergePullRequest(input: $input) { id status } \
368}";
369
370const SUBMIT_REVIEW: &str = "mutation($input: SubmitPullRequestReviewInput!) { \
371 submitPullRequestReview(input: $input) { id state } \
372}";
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 #[test]
379 fn merge_strategy_arg_accepts_documented_aliases() {
380 let cases = [
381 ("ff", "FAST_FORWARD"),
382 ("fast-forward", "FAST_FORWARD"),
383 ("fast_forward", "FAST_FORWARD"),
384 ("merge", "MERGE_COMMIT"),
385 ("merge-commit", "MERGE_COMMIT"),
386 ("merge_commit", "MERGE_COMMIT"),
387 ("squash", "SQUASH"),
388 ("rebase", "REBASE"),
389 ("SQUASH", "SQUASH"),
390 ];
391
392 for (input, expected) in cases {
393 assert_eq!(merge_strategy_arg(input).unwrap(), expected);
394 }
395 }
396
397 #[test]
398 fn merge_strategy_arg_rejects_unknown_values() {
399 let err = merge_strategy_arg("ours").unwrap_err().to_string();
400 assert!(err.contains("unknown merge strategy `ours`"));
401 }
402
403 #[test]
404 fn review_state_arg_requires_exactly_one_state_flag() {
405 let approved = ReviewArgs {
406 repo: "alice/r1".into(),
407 number: 7,
408 approve: true,
409 request_changes: false,
410 comment: false,
411 body: String::new(),
412 };
413 assert_eq!(review_state_arg(&approved).unwrap(), "APPROVED");
414
415 let changes = ReviewArgs {
416 request_changes: true,
417 approve: false,
418 ..approved
419 };
420 assert_eq!(review_state_arg(&changes).unwrap(), "CHANGES_REQUESTED");
421
422 let commented = ReviewArgs {
423 comment: true,
424 request_changes: false,
425 ..changes
426 };
427 assert_eq!(review_state_arg(&commented).unwrap(), "COMMENTED");
428
429 let missing = ReviewArgs {
430 comment: false,
431 ..commented
432 };
433 assert!(review_state_arg(&missing)
434 .unwrap_err()
435 .to_string()
436 .contains("pass one of"));
437
438 let multiple = ReviewArgs {
439 approve: true,
440 comment: true,
441 ..missing
442 };
443 assert!(review_state_arg(&multiple)
444 .unwrap_err()
445 .to_string()
446 .contains("mutually exclusive"));
447 }
448
449 #[test]
450 fn pr_rows_filters_by_state_and_formats_branch_pair() {
451 let prs = vec![
452 serde_json::json!({
453 "number": 1,
454 "status": "OPEN",
455 "title": "Add single-box CI",
456 "sourceBranch": "ci-local",
457 "targetBranch": "main",
458 "authorUsername": "bri"
459 }),
460 serde_json::json!({
461 "number": 2,
462 "status": "CLOSED",
463 "title": "Old deploy spike",
464 "sourceBranch": "deploy-spike",
465 "targetBranch": "main",
466 "authorUsername": "sam"
467 }),
468 serde_json::json!({
469 "number": 3,
470 "status": "MERGED",
471 "title": "Registry auth",
472 "sourceBranch": "registry-auth",
473 "targetBranch": "main",
474 "authorUsername": "alex"
475 }),
476 ];
477
478 assert_eq!(
479 pr_rows(&prs, "open"),
480 vec![vec![
481 "#1",
482 "OPEN",
483 "Add single-box CI",
484 "ci-local → main",
485 "bri"
486 ]]
487 );
488 assert_eq!(
489 pr_rows(&prs, "closed"),
490 vec![vec![
491 "#2",
492 "CLOSED",
493 "Old deploy spike",
494 "deploy-spike → main",
495 "sam"
496 ]]
497 );
498 assert_eq!(
499 pr_rows(&prs, "merged"),
500 vec![vec![
501 "#3",
502 "MERGED",
503 "Registry auth",
504 "registry-auth → main",
505 "alex"
506 ]]
507 );
508 assert_eq!(pr_rows(&prs, "all").len(), 3);
509 }
510
511 #[test]
512 fn pr_rows_defaults_missing_fields_to_empty_values() {
513 let prs = vec![serde_json::json!({})];
514
515 assert_eq!(pr_rows(&prs, "all"), vec![vec!["#0", "", "", " → ", ""]]);
516 }
517}