Skip to main content

runtime_cli/commands/
pr.rs

1//! `runtime pr` — list / view / create / comment / close / reopen /
2//! checkout / merge / review.
3//!
4//! `checkout` fetches the PR's source branch via `git fetch` and
5//! checks it out locally; `merge` calls `mergePullRequest`; `review`
6//! calls `submitPullRequestReview` with the appropriate state enum.
7
8use 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    /// Fetch and check out the PR's head branch locally.
29    Checkout(NumberArgs),
30    /// Merge a pull request.
31    Merge(MergeArgs),
32    /// Submit a review on a pull request.
33    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    /// Source branch (head).
65    #[arg(long, value_parser = clap::builder::NonEmptyStringValueParser::new())]
66    pub head: String,
67    /// Target branch (base).
68    #[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    /// One of `ff` (fast-forward), `squash`, `merge`, or `rebase`.
91    #[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            // The API surfaces close/reopen only for issues; PRs are
117            // closed via merge or via the web UI. Surface a clear
118            // error rather than 500.
119            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}