CLI: open, edit, close, reopen and label pull requests

df92b0a · Gabriel Morell · 2026-10-18 17:07

12 files +619 -5
Message
{commit_body(@commit)}

Files changed

modified cli/README.md
+21 −0
@@ -61,12 +61,25 @@ gg issue close|reopen N
61 61
62 62 gg pulls list [--state open|closed|merged|all]
63 63 gg pull show N
64 +gg pull create --title TEXT [--head BRANCH] [--base BRANCH] [--body TEXT | --body-file PATH]
65 + open one; --head defaults to the checked-out branch
66 + (pushed already), --base to the default branch;
67 + --head-repo OWNER/NAME takes the head from a fork.
68 + Prints the number and URL
69 +gg pull edit N [--title TEXT] [--body TEXT | --body-file PATH]
70 +gg pull close|reopen N
71 +gg pull label N [--add LABEL …] [--remove LABEL …]
72 +gg pull labels the labels a pull request here can be given
64 73 gg pull comment N --body TEXT
65 74 gg pull review N --state approve|request-changes|comment [--body TEXT]
66 75 gg pull merge N [--message TEXT] [--override-rules]
67 76 gg pull approve-runs N let a fork's waiting workflow runs start
68 77 gg pull reject-runs N cancel them instead
69 78
79 +gg labels [list] the repository's labels, own and inherited
80 +gg labels create NAME [--color '#RRGGBB'] [--description TEXT]
81 + repository admins only
82 +
70 83 gg wiki list | gg wiki show SLUG
71 84 gg packages list | gg package show FORMAT NAME | gg package usage FORMAT
72 85
@@ -95,6 +108,14 @@ one that's gone says so. `gg ci watch` keeps watching anything that hasn't
95 108 finished — including a run waiting for approval, which it points out once.
96 109 `gg pull show` lists a fork's runs waiting for approval.
97 110
111 +Opening, editing, closing, reopening and labelling a pull request fire its
112 +`pull_request` workflows (`opened`, `edited`, `closed`, `reopened`,
113 +`labeled`, `unlabeled`) exactly as the web page does, and follow its rules:
114 +anyone who can read the repository may open one; its author or a repository
115 +admin may edit, close, reopen or label it. All of them need a token with
116 +`pulls:write`. Each prints the pull request as `gg pull show --json` would
117 +under `--json`; `gg pull label` with several labels prints the last reply.
118 +
98 119 `gg ci dispatch` takes inputs as strings, `true`/`false` for a boolean;
99 120 the server checks them against what the workflow declares and the CLI
100 121 prints its reason per input when it refuses them.
modified cli/src/api/mod.rs
+180 −0
@@ -172,6 +172,14 @@ impl Client {
172 172 self.send(Method::POST, segments, &[], body).await
173 173 }
174 174
175 + async fn patch<T: DeserializeOwned>(&self, segments: &[&str], body: Value) -> Result<Res<T>> {
176 + self.send(Method::PATCH, segments, &[], Some(body)).await
177 + }
178 +
179 + async fn delete<T: DeserializeOwned>(&self, segments: &[&str]) -> Result<Res<T>> {
180 + self.send(Method::DELETE, segments, &[], None).await
181 + }
182 +
175 183 // ── identity ─────────────────────────────────────────────────────
176 184
177 185 pub async fn me(&self) -> Result<Res<MeResponse>> {
@@ -415,6 +423,117 @@ impl Client {
415 423 .await
416 424 }
417 425
426 + /// Open a pull request. `base` defaults to the default branch on the
427 + /// server; `head_repo` (`owner/name`) takes the head from a fork.
428 + pub async fn create_pull(
429 + &self,
430 + repo: &RepoRef,
431 + new: &NewPull<'_>,
432 + ) -> Result<Res<PullDetailResponse>> {
433 + let mut body = json!({ "head": new.head, "title": new.title });
434 + if let Some(base) = new.base {
435 + body["base"] = json!(base);
436 + }
437 + if let Some(head_repo) = new.head_repo {
438 + body["head_repo"] = json!(head_repo);
439 + }
440 + if let Some(text) = new.body {
441 + body["body"] = json!(text);
442 + }
443 + self.post(&["repos", &repo.owner, &repo.name, "pulls"], Some(body))
444 + .await
445 + }
446 +
447 + /// Change the title and/or the description; whichever is `None` is
448 + /// left alone.
449 + pub async fn edit_pull(
450 + &self,
451 + repo: &RepoRef,
452 + number: i64,
453 + title: Option<&str>,
454 + body: Option<&str>,
455 + ) -> Result<Res<PullDetailResponse>> {
456 + let mut change = json!({});
457 + if let Some(title) = title {
458 + change["title"] = json!(title);
459 + }
460 + if let Some(body) = body {
461 + change["body"] = json!(body);
462 + }
463 + self.patch(
464 + &[
465 + "repos",
466 + &repo.owner,
467 + &repo.name,
468 + "pulls",
469 + &number.to_string(),
470 + ],
471 + change,
472 + )
473 + .await
474 + }
475 +
476 + /// `"closed"` or `"open"`.
477 + pub async fn set_pull_state(
478 + &self,
479 + repo: &RepoRef,
480 + number: i64,
481 + state: &str,
482 + ) -> Result<Res<PullDetailResponse>> {
483 + self.post(
484 + &[
485 + "repos",
486 + &repo.owner,
487 + &repo.name,
488 + "pulls",
489 + &number.to_string(),
490 + "state",
491 + ],
492 + Some(json!({ "state": state })),
493 + )
494 + .await
495 + }
496 +
497 + pub async fn add_pull_label(
498 + &self,
499 + repo: &RepoRef,
500 + number: i64,
501 + label: &str,
502 + ) -> Result<Res<PullDetailResponse>> {
503 + self.post(
504 + &[
505 + "repos",
506 + &repo.owner,
507 + &repo.name,
508 + "pulls",
509 + &number.to_string(),
510 + "labels",
511 + ],
512 + Some(json!({ "label": label })),
513 + )
514 + .await
515 + }
516 +
517 + /// The label goes in the path, percent-encoded like every segment, so
518 + /// one named `area/ui` survives.
519 + pub async fn remove_pull_label(
520 + &self,
521 + repo: &RepoRef,
522 + number: i64,
523 + label: &str,
524 + ) -> Result<Res<PullDetailResponse>> {
525 + self.delete(&[
526 + "repos",
527 + &repo.owner,
528 + &repo.name,
529 + "pulls",
530 + &number.to_string(),
531 + "labels",
532 + label,
533 + ])
534 + .await
535 + }
536 +
418 537 pub async fn comment_on_pull(
419 538 &self,
420 539 repo: &RepoRef,
@@ -520,6 +639,31 @@ impl Client {
520 639 .await
521 640 }
522 641
642 + // ── labels ───────────────────────────────────────────────────────
643 +
644 + pub async fn labels(&self, repo: &RepoRef) -> Result<Res<LabelsResponse>> {
645 + self.get(&["repos", &repo.owner, &repo.name, "labels"], &[])
646 + .await
647 + }
648 +
649 + pub async fn create_label(
650 + &self,
651 + repo: &RepoRef,
652 + name: &str,
653 + color: Option<&str>,
654 + description: Option<&str>,
655 + ) -> Result<Res<LabelResponse>> {
656 + let mut body = json!({ "label": name });
657 + if let Some(color) = color {
658 + body["color"] = json!(color);
659 + }
660 + if let Some(description) = description {
661 + body["description"] = json!(description);
662 + }
663 + self.post(&["repos", &repo.owner, &repo.name, "labels"], Some(body))
664 + .await
665 + }
666 +
523 667 // ── wiki ─────────────────────────────────────────────────────────
524 668
525 669 pub async fn wiki(&self, repo: &RepoRef) -> Result<Res<WikiIndexResponse>> {
@@ -568,6 +712,15 @@ impl Client {
568 712 }
569 713 }
570 714
715 +/// What `gg pull create` sends.
716 +pub struct NewPull<'a> {
717 + pub head: &'a str,
718 + pub base: Option<&'a str>,
719 + pub head_repo: Option<&'a str>,
720 + pub title: &'a str,
721 + pub body: Option<&'a str>,
722 +}
723 +
571 724 fn limit_query(limit: Option<u32>) -> Vec<(&'static str, String)> {
572 725 limit
573 726 .map(|n| vec![("limit", n.to_string())])
@@ -706,6 +859,33 @@ mod tests {
706 859 );
707 860 }
708 861
862 + #[test]
863 + fn label_segments_are_percent_encoded() {
864 + let client = Client::new(&Credentials {
865 + url: "https://forge.example.com/".into(),
866 + token: "ggp_test".into(),
867 + })
868 + .unwrap();
869 + let url = client
870 + .endpoint(
871 + &[
872 + "repos",
873 + "acme",
874 + "widgets",
875 + "pulls",
876 + "3",
877 + "labels",
878 + "area/ui x",
879 + ],
880 + &[],
881 + )
882 + .unwrap();
883 + assert_eq!(
884 + url.as_str(),
885 + "https://forge.example.com/api/mobile/v1/repos/acme/widgets/pulls/3/labels/area%2Fui%20x"
886 + );
887 + }
888 +
709 889 #[test]
710 890 fn rejects_incomplete_refs() {
711 891 for bad in ["widgets", "acme/", "/widgets", "a/b/c"] {
modified cli/src/api/models.rs
+21 −0
@@ -389,6 +389,23 @@ pub struct WorkflowsResponse {
389 389 pub struct Label {
390 390 pub name: String,
391 391 pub color: Option<String>,
392 + /// Only in a repository's label list, not on an issue or pull request.
393 + pub description: Option<String>,
394 + /// Comes from the repository's organization (repository label list
395 + /// only).
396 + #[serde(default)]
397 + pub inherited: bool,
398 +}
399 +
400 +#[derive(Debug, Clone, Deserialize)]
401 +pub struct LabelsResponse {
402 + #[serde(default)]
403 + pub labels: Vec<Label>,
404 +}
405 +
406 +#[derive(Debug, Clone, Deserialize)]
407 +pub struct LabelResponse {
408 + pub label: Label,
392 409 }
393 410
394 411 #[derive(Debug, Clone, Deserialize)]
@@ -513,6 +530,10 @@ pub struct PullDetailResponse {
513 530 /// Whether the viewer may approve (or reject) them: write access.
514 531 #[serde(default)]
515 532 pub can_approve_runs: bool,
533 + /// Whether the viewer may edit, close, reopen and label it: its
534 + /// author, or a repository admin.
535 + #[serde(default)]
536 + pub can_edit: bool,
516 537 }
517 538
518 539 // ── wiki ─────────────────────────────────────────────────────────────
modified cli/src/cli.rs
+175 −1
@@ -4,7 +4,7 @@
4 4 //! pulls, wiki, packages — because the two speak the same API and there's
5 5 //! no reason for the same concept to be called two things.
6 6
7 −use clap::{Args, Parser, Subcommand, ValueEnum};
7 +use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum};
8 8
9 9 #[derive(Debug, Parser)]
10 10 #[command(
@@ -66,6 +66,10 @@ pub enum Command {
66 66 #[command(visible_aliases = ["pulls", "pr"])]
67 67 Pull(PullArgs),
68 68
69 + /// A repository's labels: list them, or create one.
70 + #[command(name = "labels", visible_alias = "label")]
71 + Labels(LabelsArgs),
72 +
69 73 /// Wiki pages.
70 74 Wiki(WikiArgs),
71 75
@@ -333,6 +337,77 @@ pub enum PullCommand {
333 337 limit: Option<u32>,
334 338 },
335 339
340 + /// Open a pull request. Prints its number and URL.
341 + #[command(visible_alias = "new")]
342 + Create {
343 + /// The branch to merge (default: the branch checked out here). It
344 + /// has to be pushed already.
345 + #[arg(long, value_name = "BRANCH")]
346 + head: Option<String>,
347 +
348 + /// The branch to merge it into (default: the repository's default
349 + /// branch).
350 + #[arg(long, value_name = "BRANCH")]
351 + base: Option<String>,
352 +
353 + /// Take the head branch from this repository — a fork, or any
354 + /// repository sharing this one's history that you can read. The
355 + /// pull request opens on --repo.
356 + #[arg(long, value_name = "OWNER/NAME")]
357 + head_repo: Option<String>,
358 +
359 + /// The pull request's title.
360 + #[arg(long, value_name = "TEXT")]
361 + title: String,
362 +
363 + #[command(flatten)]
364 + body: OptionalBodyArgs,
365 + },
366 +
367 + /// Change a pull request's title or description. Its author or a
368 + /// repository admin.
369 + #[command(group(ArgGroup::new("change").required(true).multiple(true).args(["title", "body", "body_file"])))]
370 + Edit {
371 + number: i64,
372 +
373 + /// The new title.
374 + #[arg(long, value_name = "TEXT")]
375 + title: Option<String>,
376 +
377 + /// The new description, replacing the old one. Empty clears it.
378 + #[arg(long, value_name = "TEXT")]
379 + body: Option<String>,
380 +
381 + /// Read the new description from a file, or from stdin with `-`.
382 + #[arg(long, value_name = "PATH", conflicts_with = "body")]
383 + body_file: Option<String>,
384 + },
385 +
386 + /// Close a pull request without merging it. Its author or a
387 + /// repository admin.
388 + Close { number: i64 },
389 +
390 + /// Reopen a closed pull request. A merged one stays merged.
391 + Reopen { number: i64 },
392 +
393 + /// Add or remove labels. Its author or a repository admin; the labels
394 + /// have to exist (see `gg labels`).
395 + #[command(group(ArgGroup::new("change").required(true).multiple(true).args(["add", "remove"])))]
396 + Label {
397 + number: i64,
398 +
399 + /// A label to add. Repeat for more.
400 + #[arg(long, value_name = "LABEL")]
401 + add: Vec<String>,
402 +
403 + /// A label to remove. Repeat for more.
404 + #[arg(long, value_name = "LABEL")]
405 + remove: Vec<String>,
406 + },
407 +
408 + /// The labels a pull request in this repository can be given.
409 + Labels,
410 +
336 411 /// Show one pull request: discussion, reviews and merge blockers.
337 412 Show {
338 413 number: i64,
@@ -421,6 +496,35 @@ impl ReviewState {
421 496 }
422 497 }
423 498
499 +// ── labels ───────────────────────────────────────────────────────────
500 +
501 +#[derive(Debug, Args)]
502 +pub struct LabelsArgs {
503 + /// What to do; lists them when omitted.
504 + #[command(subcommand)]
505 + pub command: Option<LabelCommand>,
506 +}
507 +
508 +#[derive(Debug, Subcommand)]
509 +pub enum LabelCommand {
510 + /// The repository's labels, including those inherited from its
511 + /// organization.
512 + #[command(visible_alias = "ls")]
513 + List,
514 +
515 + /// Create a repository label. Repository admins only.
516 + Create {
517 + name: String,
518 +
519 + /// As #RRGGBB (default: a neutral slate).
520 + #[arg(long, value_name = "#RRGGBB")]
521 + color: Option<String>,
522 +
523 + #[arg(long, value_name = "TEXT")]
524 + description: Option<String>,
525 + },
526 +}
527 +
424 528 // ── wiki ─────────────────────────────────────────────────────────────
425 529
426 530 #[derive(Debug, Args)]
@@ -501,9 +605,11 @@ pub struct BodyArgs {
501 605 /// The same, where the server will take an empty one.
502 606 #[derive(Debug, Args)]
503 607 pub struct OptionalBodyArgs {
608 + /// The text itself (optional).
504 609 #[arg(long, value_name = "TEXT")]
505 610 pub body: Option<String>,
506 611
612 + /// Read it from a file, or from stdin with `-`.
507 613 #[arg(long, value_name = "PATH", conflicts_with = "body")]
508 614 pub body_file: Option<String>,
509 615 }
@@ -560,4 +666,72 @@ mod tests {
560 666 assert_eq!(git_ref.as_deref(), Some("main"));
561 667 assert_eq!(fields.len(), 2);
562 668 }
669 +
670 + #[test]
671 + fn pull_create_defaults_head_and_base_to_none() {
672 + let cli = Cli::try_parse_from(["gg", "pr", "create", "--title", "Fix it"]).unwrap();
673 + let Command::Pull(PullArgs {
674 + command: PullCommand::Create {
675 + head, base, title, ..
676 + },
677 + }) = cli.command
678 + else {
679 + panic!("not a create");
680 + };
681 + assert_eq!(title, "Fix it");
682 + assert!(head.is_none() && base.is_none());
683 +
684 + assert!(
685 + Cli::try_parse_from(["gg", "pull", "create"]).is_err(),
686 + "title is required"
687 + );
688 + }
689 +
690 + #[test]
691 + fn pull_label_takes_repeated_adds_and_removes_but_needs_one() {
692 + let cli = Cli::try_parse_from([
693 + "gg", "pull", "label", "7", "--add", "bug", "--add", "ui", "--remove", "triage",
694 + ])
695 + .unwrap();
696 + let Command::Pull(PullArgs {
697 + command:
698 + PullCommand::Label {
699 + number,
700 + add,
701 + remove,
702 + },
703 + }) = cli.command
704 + else {
705 + panic!("not a label");
706 + };
707 + assert_eq!(number, 7);
708 + assert_eq!(add, ["bug", "ui"]);
709 + assert_eq!(remove, ["triage"]);
710 +
711 + assert!(Cli::try_parse_from(["gg", "pull", "label", "7"]).is_err());
712 + }
713 +
714 + #[test]
715 + fn pull_edit_needs_something_to_change() {
716 + assert!(Cli::try_parse_from(["gg", "pull", "edit", "3"]).is_err());
717 + assert!(Cli::try_parse_from(["gg", "pull", "edit", "3", "--title", "New"]).is_ok());
718 + assert!(Cli::try_parse_from(["gg", "pull", "edit", "3", "--body-file", "-"]).is_ok());
719 + }
720 +
721 + #[test]
722 + fn labels_lists_without_a_subcommand() {
723 + let cli = Cli::try_parse_from(["gg", "labels"]).unwrap();
724 + assert!(matches!(
725 + cli.command,
726 + Command::Labels(LabelsArgs { command: None })
727 + ));
728 + let cli =
729 + Cli::try_parse_from(["gg", "label", "create", "bug", "--color", "#ff0000"]).unwrap();
730 + assert!(matches!(
731 + cli.command,
732 + Command::Labels(LabelsArgs {
733 + command: Some(LabelCommand::Create { .. })
734 + })
735 + ));
736 + }
563 737 }
added cli/src/commands/label.rs
+65 −0
@@ -0,0 +1,65 @@
1 +//! A repository's labels — the ones `gg pull label` can attach.
2 +
3 +use anyhow::Result;
4 +
5 +use crate::api::RepoRef;
6 +use crate::cli::LabelCommand;
7 +use crate::commands::Ctx;
8 +use crate::output::{Table, Tone, note, paint};
9 +
10 +pub async fn run(ctx: &Ctx, command: Option<&LabelCommand>) -> Result<()> {
11 + let repo = ctx.repo()?;
12 +
13 + match command {
14 + None | Some(LabelCommand::List) => list(ctx, &repo).await,
15 +
16 + Some(LabelCommand::Create {
17 + name,
18 + color,
19 + description,
20 + }) => {
21 + let res = ctx
22 + .api
23 + .create_label(&repo, name, color.as_deref(), description.as_deref())
24 + .await?;
25 + if ctx.emitted(&res.raw)? {
26 + return Ok(());
27 + }
28 + println!(
29 + "{} label {} in {repo}.",
30 + paint("Created", Tone::Good),
31 + res.label.name
32 + );
33 + Ok(())
34 + }
35 + }
36 +}
37 +
38 +pub async fn list(ctx: &Ctx, repo: &RepoRef) -> Result<()> {
39 + let res = ctx.api.labels(repo).await?;
40 + if ctx.emitted(&res.raw)? {
41 + return Ok(());
42 + }
43 + if res.labels.is_empty() {
44 + note(&format!(
45 + "No labels in {repo}. A repository admin can add one with `gg labels create NAME`."
46 + ));
47 + return Ok(());
48 + }
49 +
50 + let mut table = Table::new(["NAME", "COLOR", "FROM", "DESCRIPTION"]);
51 + for label in &res.labels {
52 + table.row([
53 + label.name.clone(),
54 + label.color.clone().unwrap_or_default(),
55 + if label.inherited {
56 + "organization".to_string()
57 + } else {
58 + "repository".to_string()
59 + },
60 + label.description.clone().unwrap_or_default(),
61 + ]);
62 + }
63 + table.print();
64 + Ok(())
65 +}
modified cli/src/commands/mod.rs
+7 −0
@@ -3,6 +3,7 @@
3 3 pub mod auth;
4 4 pub mod ci;
5 5 pub mod issue;
6 +pub mod label;
6 7 pub mod package;
7 8 pub mod pull;
8 9 pub mod repo;
@@ -109,6 +110,12 @@ fn repo_from_git_remote(base_url: &str) -> Option<RepoRef> {
109 110 None
110 111 }
111 112
113 +/// The branch checked out in the current directory, or `None` outside a
114 +/// checkout or on a detached HEAD.
115 +pub fn current_branch() -> Option<String> {
116 + git(&["symbolic-ref", "--quiet", "--short", "HEAD"]).filter(|b| !b.is_empty())
117 +}
118 +
112 119 fn git(args: &[&str]) -> Option<String> {
113 120 let out = Proc::new("git").args(args).output().ok()?;
114 121 if !out.status.success() {
modified cli/src/commands/pull.rs
+149 −4
@@ -1,4 +1,12 @@
1 −//! Pull requests: list, read, comment, review and merge.
1 +//! Pull requests: open, list, read, edit, close, label, comment, review
2 +//! and merge.
3 +//!
4 +//! Opening, editing, closing, reopening and labelling go through the
5 +//! same server-side paths as the web page, so they fire the same
6 +//! `pull_request` workflows (`opened`, `edited`, `closed`, `reopened`,
7 +//! `labeled`, `unlabeled`) and follow the same rules: anyone who can read
8 +//! the repository may open one; its author or a repository admin may
9 +//! change it.
2 10 //!
3 11 //! Merging is included for the same reason the phone has it — it's the PR
4 12 //! action you most often want without opening a browser. It enforces the
@@ -9,11 +17,12 @@
9 17 //! without secrets, until someone with write access says the code may
10 18 //! run.
11 19
12 −use anyhow::Result;
20 +use anyhow::{Result, anyhow};
13 21
14 −use crate::api::models::Run;
22 +use crate::api::models::{PullDetailResponse, Run};
23 +use crate::api::{ApiFailure, NewPull, RepoRef, Res};
15 24 use crate::cli::PullCommand;
16 −use crate::commands::{Ctx, read_body};
25 +use crate::commands::{Ctx, current_branch, label, read_body};
17 26 use crate::output::{
18 27 Table, Tone, ago, author_of, bold, heading, indented, labels, note, paint, state_cell,
19 28 status_badge, truncate,
@@ -63,6 +72,112 @@ pub async fn run(ctx: &Ctx, command: &PullCommand) -> Result<()> {
63 72 Ok(())
64 73 }
65 74
75 + PullCommand::Create {
76 + head,
77 + base,
78 + head_repo,
79 + title,
80 + body,
81 + } => {
82 + let head = match head {
83 + Some(head) => head.clone(),
84 + None => current_branch().ok_or_else(|| {
85 + anyhow!("no branch checked out here to use as the head — pass --head BRANCH")
86 + })?,
87 + };
88 + let text = read_body(body.body.as_deref(), body.body_file.as_deref(), false)?;
89 + let new = NewPull {
90 + head: &head,
91 + base: base.as_deref(),
92 + head_repo: head_repo.as_deref(),
93 + title: title.trim(),
94 + body: text.as_deref().map(str::trim).filter(|t| !t.is_empty()),
95 + };
96 + let res = ctx
97 + .api
98 + .create_pull(&repo, &new)
99 + .await
100 + .map_err(|e| unpushed_hint(e, &head))?;
101 + if ctx.emitted(&res.raw)? {
102 + return Ok(());
103 + }
104 + let pull = &res.pull;
105 + println!(
106 + "{} pull request #{}: {} ({})",
107 + paint("Opened", Tone::Good),
108 + pull.number,
109 + pull.title.as_deref().unwrap_or("(untitled)"),
110 + branches(pull.source_ref.as_deref(), pull.target_ref.as_deref())
111 + );
112 + println!(
113 + "{}",
114 + ctx.repo_web_url(&repo, &format!("/pulls/{}", pull.number))
115 + );
116 + Ok(())
117 + }
118 +
119 + PullCommand::Edit {
120 + number,
121 + title,
122 + body,
123 + body_file,
124 + } => {
125 + // Only an explicit --body or --body-file: unlike a comment,
126 + // piped stdin isn't taken as a new description, so a script
127 + // renaming a pull request can't wipe its description by
128 + // accident.
129 + let text = match (body, body_file) {
130 + (None, None) => None,
131 + _ => read_body(body.as_deref(), body_file.as_deref(), true)?,
132 + };
133 + let res = ctx
134 + .api
135 + .edit_pull(&repo, *number, title.as_deref(), text.as_deref())
136 + .await?;
137 + if ctx.emitted(&res.raw)? {
138 + return Ok(());
139 + }
140 + println!(
141 + "Updated pull request #{number}: {}.",
142 + res.pull.title.as_deref().unwrap_or("(untitled)")
143 + );
144 + Ok(())
145 + }
146 +
147 + PullCommand::Close { number } => set_state(ctx, &repo, *number, "closed").await,
148 + PullCommand::Reopen { number } => set_state(ctx, &repo, *number, "open").await,
149 +
150 + PullCommand::Label {
151 + number,
152 + add,
153 + remove,
154 + } => {
155 + // One request per label; each answers with the whole pull
156 + // request, so the last one says where it ended up.
157 + let mut last: Option<Res<PullDetailResponse>> = None;
158 + for name in add {
159 + last = Some(ctx.api.add_pull_label(&repo, *number, name).await?);
160 + }
161 + for name in remove {
162 + last = Some(ctx.api.remove_pull_label(&repo, *number, name).await?);
163 + }
164 + let res = last.expect("clap requires --add or --remove");
165 + if ctx.emitted(&res.raw)? {
166 + return Ok(());
167 + }
168 + if res.pull.labels.is_empty() {
169 + println!("Pull request #{number} has no labels now.");
170 + } else {
171 + println!(
172 + "Pull request #{number} is labelled {}.",
173 + labels(&res.pull.labels)
174 + );
175 + }
176 + Ok(())
177 + }
178 +
179 + PullCommand::Labels => label::list(ctx, &repo).await,
180 +
66 181 PullCommand::Show {
67 182 number,
68 183 no_comments,
@@ -316,6 +431,36 @@ pub async fn run(ctx: &Ctx, command: &PullCommand) -> Result<()> {
316 431 }
317 432 }
318 433
434 +async fn set_state(ctx: &Ctx, repo: &RepoRef, number: i64, state: &str) -> Result<()> {
435 + let res = ctx.api.set_pull_state(repo, number, state).await?;
436 + if ctx.emitted(&res.raw)? {
437 + return Ok(());
438 + }
439 + println!(
440 + "Pull request #{number} is now {}.",
441 + state_cell(&res.pull.state)
442 + );
443 + Ok(())
444 +}
445 +
446 +/// The server can only see branches that have been pushed; say so when
447 +/// the head it couldn't find is the one taken from the checkout.
448 +fn unpushed_hint(error: anyhow::Error, head: &str) -> anyhow::Error {
449 + let missing_head = ApiFailure::of(&error).is_some_and(|f| {
450 + f.status.as_u16() == 422
451 + && f.message
452 + .as_deref()
453 + .is_some_and(|m| m.starts_with("head: no branch"))
454 + });
455 + if missing_head {
456 + error.context(format!(
457 + "is {head} pushed? Try `git push -u origin {head}` first"
458 + ))
459 + } else {
460 + error
461 + }
462 +}
463 +
319 464 /// What approving or rejecting touched — which may be nothing, when no
320 465 /// run was waiting.
321 466 fn report_runs(runs: &[Run], verb: &str, tone: Tone, number: i64) {
modified cli/src/main.rs
+1 −0
@@ -88,6 +88,7 @@ async fn run() -> Result<()> {
88 88 Command::Ci(ci) => commands::ci::run(&ctx, &ci.command).await,
89 89 Command::Issue(issue) => commands::issue::run(&ctx, &issue.command).await,
90 90 Command::Pull(pull) => commands::pull::run(&ctx, &pull.command).await,
91 + Command::Labels(labels) => commands::label::run(&ctx, labels.command.as_ref()).await,
91 92 Command::Wiki(wiki) => commands::wiki::run(&ctx, &wiki.command).await,
92 93 Command::Package(package) => commands::package::run(&ctx, &package.command).await,
93 94 Command::Dashboard(dashboard) => tui::run(&ctx, dashboard).await,

Parents: 24ca95f