neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
CLI: open, edit, close, reopen and label pull requests
df92b0a · Gabriel Morell · 2026-10-18 17:07
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