├── .github └── workflows │ ├── book.yml │ ├── clippy.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── book.toml ├── docs ├── README.md ├── SUMMARY.md ├── images │ └── patch.svg ├── reference │ └── configuration.md ├── spr.svg └── user │ ├── commit-message.md │ ├── installation.md │ ├── patch.md │ ├── setup.md │ ├── simple.md │ └── stack.md ├── rustfmt.toml └── spr ├── Cargo.toml └── src ├── commands ├── amend.rs ├── close.rs ├── diff.rs ├── format.rs ├── init.rs ├── land.rs ├── list.rs ├── mod.rs └── patch.rs ├── config.rs ├── error.rs ├── git.rs ├── github.rs ├── gql ├── open_reviews.graphql ├── pullrequest_mergeability_query.graphql ├── pullrequest_query.graphql └── schema.docs.graphql ├── lib.rs ├── main.rs ├── message.rs ├── output.rs └── utils.rs /.github/workflows/book.yml: -------------------------------------------------------------------------------- 1 | name: Build and deploy spr documentation 2 | 3 | concurrency: 4 | group: gh-pages 5 | 6 | on: 7 | push: 8 | branches: 9 | - master 10 | paths: 11 | - "book.toml" 12 | - "docs/**" 13 | 14 | jobs: 15 | mdbook: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v2 20 | 21 | - name: Cache mdbook 22 | id: cache-mdbook 23 | uses: actions/cache@v3 24 | with: 25 | path: | 26 | ~/.cargo/bin/mdbook 27 | ~/.cargo/bin/mdbook-mermaid 28 | key: ${{ runner.os }}-mdbook 29 | 30 | - name: Install mdBook 31 | if: steps.cache-mdbook.outputs.cache-hit != 'true' 32 | run: cargo install mdbook mdbook-mermaid 33 | 34 | - name: Run mdBook 35 | run: | 36 | mdbook-mermaid install 37 | mdbook build 38 | 39 | - name: Deploy 40 | uses: peaceiris/actions-gh-pages@v3 41 | with: 42 | github_token: ${{ secrets.GITHUB_TOKEN }} 43 | publish_dir: ./book 44 | publish_branch: gh-pages 45 | -------------------------------------------------------------------------------- /.github/workflows/clippy.yml: -------------------------------------------------------------------------------- 1 | on: push 2 | name: Clippy check 3 | env: 4 | RUSTFLAGS: -D warnings 5 | CARGO_TERM_COLOR: always 6 | 7 | jobs: 8 | clippy_check: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v1 12 | - uses: dtolnay/rust-toolchain@stable 13 | - run: | 14 | cargo clippy --all-features --all-targets 15 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | macos: 10 | name: Build for MacOS 11 | runs-on: macos-10.15 12 | env: 13 | CARGO_TERM_COLOR: always 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | 18 | - name: Get version 19 | id: get_version 20 | run: echo ::set-output name=version::${GITHUB_REF/refs\/tags\/v/} 21 | 22 | - name: Install Rust 23 | uses: actions-rs/toolchain@v1 24 | with: 25 | toolchain: stable 26 | profile: minimal 27 | components: clippy 28 | 29 | - name: Run tests 30 | run: cargo test 31 | 32 | - name: Build Release Mac 33 | run: | 34 | cargo build --release 35 | strip target/release/spr 36 | tar vczC target/release/ spr >spr-${{ steps.get_version.outputs.version }}-macos.tar.gz 37 | ls -lh spr-*.tar.gz 38 | 39 | - name: Extract release notes 40 | id: release_notes 41 | uses: ffurrer2/extract-release-notes@v1 42 | 43 | - name: Release 44 | uses: softprops/action-gh-release@v1 45 | with: 46 | body: ${{ steps.release_notes.outputs.release_notes }} 47 | prerelease: ${{ contains(github.ref, '-') }} 48 | files: | 49 | ./spr-*.tar.gz 50 | 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /book/ 2 | /target 3 | /mermaid.min.js 4 | /mermaid-init.js 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | ## [1.3.5] - 2023-11-02 6 | 7 | ### Fixes 8 | 9 | - don't line-wrap URLs (@keyz) 10 | - fix base branch name for github protected branches (@rockwotj) 11 | - fix clippy warnings (@spacedentist) 12 | 13 | ### Improvements 14 | 15 | - turn repository into Cargo workspace (@spacedentist) 16 | - documentation improvements (@spacedentist) 17 | - add shorthand for `--all` (@rockwotj) 18 | - don't fetch all users/teams to check reviewers (@andrewhamon) 19 | - add refname checking (@cadolphs) 20 | - run post-rewrite hooks (@jwatzman) 21 | 22 | ## [1.3.4] - 2022-07-18 23 | 24 | ### Improvements 25 | 26 | - add config option to make test plan optional (@orausch) 27 | - add comprehensive documentation (@oyamauchi) 28 | - add a `close` command (@joneshf) 29 | - allow `spr format` to be used without GitHub credentials 30 | - don't fail on requesting reviewers (@joneshf) 31 | 32 | ## [1.3.3] - 2022-06-27 33 | 34 | ### Fixes 35 | 36 | - get rid of italics in generated commit messages - they're silly 37 | - fix unneccessary creation of base branches when updating PRs 38 | - when updating an existing PR, merge in master commit if the commit was rebased even if the base tree did not change 39 | - add a final rebase commit to the PR branch when landing and it is necessary to do so to not have changes in the base of this commit, that since have landed on master, displayed as part of this PR 40 | 41 | ### Improvemets 42 | 43 | - add spr version number in PR commit messages 44 | - add `--all` option to `spr diff` for operating on a stack of commits 45 | - updated Rust dependencies 46 | 47 | ## [1.3.2] - 2022-06-16 48 | 49 | ### Fixes 50 | 51 | - fix list of required GitHub permissions in `spr init` message 52 | - fix aborting Pull Request update by entering empty message on prompt 53 | - fix a problem where occasionally `spr diff` would fail because it could not push the base branch to GitHub 54 | 55 | ### Improvements 56 | 57 | - add `spr.requireApprovals` config field to control if spr enforces that only accepted PRs can be landed 58 | - the spr binary no longer depends on openssl 59 | - add documentation to the docs/ folder 60 | - `spr diff` now warns the user if the local commit message differs from the one on GitHub when updating an existing Pull Request 61 | 62 | ## [1.3.1] - 2022-06-10 63 | 64 | ### Fixes 65 | 66 | - register base branch at PR creation time instead of after 67 | - fix `--update-message` option of `spr diff` when invoked without making changes to the commit tree 68 | 69 | ### Security 70 | 71 | - remove dependency on `failure` to fix CVE-2019-25010 72 | 73 | ## [1.3.0] - 2022-06-01 74 | 75 | ### Improvements 76 | 77 | - make land command reject local changes on land 78 | - replace `--base` option with `--cherry-pick` in `spr diff` 79 | - add `--cherry-pick` option to `spr land` 80 | 81 | ## [1.2.4] - 2022-05-26 82 | 83 | ### Fixes 84 | 85 | - fix working with repositories not owned by an organization but by a user 86 | 87 | ## [1.2.3] - 2022-05-24 88 | 89 | ### Fixes 90 | 91 | - fix building with homebrew-installed Rust (currently 1.59) 92 | 93 | ## [1.2.2] - 2022-05-23 94 | 95 | ### Fixes 96 | 97 | - fix clippy warnings 98 | 99 | ### Improvements 100 | 101 | - clean-up `Cargo.toml` and update dependencies 102 | - add to `README.md` 103 | 104 | ## [1.2.1] - 2022-04-21 105 | 106 | ### Fixes 107 | 108 | - fix calculating base of PR for the `spr patch` command 109 | 110 | ## [1.2.0] - 2022-04-21 111 | 112 | ### Improvements 113 | 114 | - remove `--stack` option: spr now bases a diff on master if possible, or otherwise constructs a separate branch for the base of the diff. (This can be forced with `--base`.) 115 | - add new command `spr patch` to locally check out a Pull Request from GitHub 116 | 117 | ## [1.1.0] - 2022-03-18 118 | 119 | ### Fixes 120 | 121 | - set timestamps of PR commits to time of submitting, not the time the local commit was originally authored/committed 122 | 123 | ### Improvements 124 | 125 | - add `spr list` command, which lists the user's Pull Requests with their status 126 | - use `--no-verify` option for all git pushes 127 | 128 | ## [1.0.0] - 2022-02-10 129 | 130 | ### Added 131 | 132 | - Initial release 133 | 134 | [1.0.0]: https://github.com/getcord/spr/releases/tag/v1.0.0 135 | [1.1.0]: https://github.com/getcord/spr/releases/tag/v1.1.0 136 | [1.2.0]: https://github.com/getcord/spr/releases/tag/v1.2.0 137 | [1.2.1]: https://github.com/getcord/spr/releases/tag/v1.2.1 138 | [1.2.2]: https://github.com/getcord/spr/releases/tag/v1.2.2 139 | [1.2.3]: https://github.com/getcord/spr/releases/tag/v1.2.3 140 | [1.2.4]: https://github.com/getcord/spr/releases/tag/v1.2.4 141 | [1.3.0]: https://github.com/getcord/spr/releases/tag/v1.3.0 142 | [1.3.1]: https://github.com/getcord/spr/releases/tag/v1.3.1 143 | [1.3.2]: https://github.com/getcord/spr/releases/tag/v1.3.2 144 | [1.3.3]: https://github.com/getcord/spr/releases/tag/v1.3.3 145 | [1.3.4]: https://github.com/getcord/spr/releases/tag/v1.3.4 146 | [1.3.5]: https://github.com/getcord/spr/releases/tag/v1.3.5 147 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = [ 4 | "spr", 5 | ] 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Radical HQ Limited 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![spr](./docs/spr.svg) 2 | 3 | # spr · [![GitHub](https://img.shields.io/github/license/getcord/spr)](https://img.shields.io/github/license/getcord/spr) [![GitHub release](https://img.shields.io/github/v/release/getcord/spr?include_prereleases)](https://github.com/getcord/spr/releases) [![crates.io](https://img.shields.io/crates/v/spr.svg)](https://crates.io/crates/spr) [![homebrew](https://img.shields.io/homebrew/v/spr.svg)](https://formulae.brew.sh/formula/spr) [![GitHub Repo stars](https://img.shields.io/github/stars/getcord/spr?style=social)](https://github.com/getcord/spr) 4 | 5 | A command-line tool for submitting and updating GitHub Pull Requests from local 6 | Git commits that may be amended and rebased. Pull Requests can be stacked to 7 | allow for a series of code reviews of interdependent code. 8 | 9 | spr is pronounced /ˈsuːpəɹ/, like the English word 'super'. 10 | 11 | ## Documentation 12 | 13 | Comprehensive documentation is available here: https://getcord.github.io/spr/ 14 | 15 | ## Installation 16 | 17 | ### Binary Installation 18 | 19 | #### Using Homebrew 20 | 21 | ```shell 22 | brew install spr 23 | ``` 24 | 25 | #### Using Nix 26 | 27 | ```shell 28 | nix-channel --update && nix-env -i spr 29 | ``` 30 | 31 | #### Using Cargo 32 | 33 | If you have Cargo installed (the Rust build tool), you can install spr by running 34 | 35 | ```shell 36 | cargo install spr 37 | ``` 38 | 39 | ### Install from Source 40 | 41 | spr is written in Rust. You need a Rust toolchain to build from source. See [rustup.rs](https://rustup.rs) for information on how to install Rust if you have not got a Rust toolchain on your system already. 42 | 43 | With Rust all set up, clone this repository and run `cargo build --release`. The spr binary will be in the `target/release` directory. 44 | 45 | ## Quickstart 46 | 47 | To use spr, run `spr init` inside a local checkout of a GitHub-backed git repository. You will be asked for a GitHub PAT (Personal Access Token), which spr will use to make calls to the GitHub API in order to create and merge pull requests. 48 | 49 | To submit a commit for pull request, run `spr diff`. 50 | 51 | If you want to make changes to the pull request, amend your local commit (and/or rebase it) and call `spr diff` again. When updating an existing pull request, spr will ask you for a short message to describe the update. 52 | 53 | To squash-merge an open pull request, run `spr land`. 54 | 55 | For more information on spr commands and options, run `spr help`. For more information on a specific spr command, run `spr help ` (e.g. `spr help diff`). 56 | 57 | ## Contributing 58 | 59 | Feel free to submit an issue on [GitHub](https://github.com/getcord/spr) if you have found a problem. If you can even provide a fix, please raise a pull request! 60 | 61 | If there are larger changes or features that you would like to work on, please raise an issue on GitHub first to discuss. 62 | 63 | ### License 64 | 65 | spr is [MIT licensed](./LICENSE). 66 | -------------------------------------------------------------------------------- /book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | authors = ["Sven Over"] 3 | language = "en" 4 | multilingual = false 5 | src = "docs" 6 | title = "spr Documentation" 7 | 8 | [build] 9 | build-dir = "book" 10 | 11 | [output.html] 12 | git-repository-url = "https://github.com/getcord/spr" 13 | edit-url-template = "https://github.com/getcord/spr/edit/master/{path}" 14 | site-url = "/spr/" 15 | curly-quotes = true 16 | additional-js = ["mermaid.min.js", "mermaid-init.js"] 17 | 18 | [preprocessor] 19 | 20 | [preprocessor.mermaid] 21 | command = "mdbook-mermaid" 22 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | ![spr](./spr.svg) 2 | 3 | # Introduction 4 | 5 | spr is a command line tool for using a stacked-diff workflow with GitHub. 6 | 7 | The idea behind spr is that your local branch management should not be dictated by your code-review tool. You should be able to send out code for review in individual commits, not branches. You make branches only when you want to, not because you _have_ to for every code review. 8 | 9 | If you've used Phabricator and its command-line tool `arc`, you'll find spr very familiar. 10 | 11 | To get started, see the [installation instructions](./user/installation.md), and the [first-time setup](./user/setup.md). (You'll need to go through setup in each repo where you want to use spr.) 12 | 13 | ## Workflow overview 14 | 15 | In spr's workflow, you send out individual commits for review, not entire branches. This is the most basic version: 16 | 17 | 1. Make your change as a single commit, directly on your local `main`[^master] branch. 18 | 19 | 2. Run `spr diff` to send out your commit for review on GitHub. 20 | 21 | 3. If you need to make updates in response to feedback, amend your commit, and run `spr diff` again to send those updates to GitHub. 22 | 23 | Similarly, you can rebase onto newer upstream `main` and run `spr diff` to reflect any resulting changes to your commit. 24 | 25 | 4. Once reviewers have approved, run `spr land`. This will put your commit on top of the latest `main` and push it upstream. 26 | 27 | In practice, you're likely to have more complex situations: multiple commits being reviewed, and possibly in-review commits that depend on others. You may need to make updates to any of these commits, or land them in any order. 28 | 29 | spr can handle all of that, without requiring any particular way of organizing your local repo. See the guides in the "How To" section for instructions on using spr in those situations: 30 | 31 | - [Simple PRs](./user/simple.md): no more than one review in flight on any branch. 32 | - [Stacked PRs](./user/stack.md): multiple reviews in flight at once on your local `main`. 33 | 34 | ## Rationale 35 | 36 | The reason to use spr is that it allows you to use whatever local branching scheme you want, instead of being forced to create a branch for every review. In particular, you can commit everything directly on your local `main`. This greatly simplifies rebasing: rather than rebasing every review branch individually, you can simply rebase your local `main` onto upstream `main`. 37 | 38 | You can make branches locally if you want, and it's not uncommon for spr users to do so. You could even make a branch for every review if you don't want to use the stacked-PR workflow. It doesn't matter to spr. 39 | 40 | One reasonable position is to make small changes directly on `main`, but make branches for larger, more complex changes. The branch keeps the work-in-progress isolated while you get it to a reviewable state, making lots of small commits that aren't individually reviewable. Once the branch as a whole is reviewable, you can squash it down to a single commit, which you can send out for review (either from the branch or cherry-picked onto `main`). 41 | 42 | ### Why Review Commits? 43 | 44 | The principle behind spr is **one commit per logical change**. Each commit should be able to stand on its own: it should have a coherent thesis and be a complete change in and of itself. It should have a clear summary, description, and test plan. It should leave the codebase in a consistent state: building and passing tests, etc. 45 | 46 | In addition, ideally, it shouldn't be possible to further split a commit into multiple commits that each stand on their own. If you _can_ split a commit that way, you should. 47 | 48 | What follows from those principles is the idea that **commits, not branches, should be the unit of code review**. The above description of a commit also describes the ideal code review: a single, well-described change that leaves the codebase in a consistent state, and that cannot be subdivided further. 49 | 50 | If the commit is the unit of code review, then, why should the code review tool require that you make branches? spr's answer is: it shouldn't. 51 | 52 | Following the one-commit-per-change principle maintains the invariant that checking out any commit on `main` gives you a codebase that has been reviewed _in that state_, and that builds and passes tests, etc. This makes it easy to revert changes, and to bisect. 53 | 54 | [^master]: Git's default branch name is `master`, but GitHub's is now `main`, so we'll use `main` throughout this documentation. 55 | -------------------------------------------------------------------------------- /docs/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | [Introduction](README.md) 4 | 5 | # Getting Started 6 | 7 | - [Installation](user/installation.md) 8 | - [Set up spr](user/setup.md) 9 | 10 | # How To 11 | 12 | - [Create and Land a Simple PR](user/simple.md) 13 | - [Stack Multiple PRs](user/stack.md) 14 | - [Format and Update Commit Messages](user/commit-message.md) 15 | - [Check Out Someone Else's PR](user/patch.md) 16 | 17 | # Reference Guide 18 | 19 | - [Configuration](reference/configuration.md) 20 | -------------------------------------------------------------------------------- /docs/images/patch.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
A
A
A
A
upstream main
upstream main
PR creator
PR creator
X
X
Y
Y
Z
Z
You
You
X
X
Y + Z
Y + Z
branch
PR-123
branch...
runs
spr diff
here
runs...
Text is not SVG - cannot display
-------------------------------------------------------------------------------- /docs/reference/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | The recommended way to configure spr is to run `spr init`, rather than setting config values manually. You can rerun `spr init` to update config at any time. 4 | 5 | spr uses the following Git configuration values: 6 | 7 | | config key | CLI flag | description | default[^default] | default in `spr init`[^initdefault] | 8 | | -------------------- | --------------------------------- | ----------------------------------------------------------------------------------- | ----------------- | --------------------------------------------- | 9 | | `githubAuthToken` | `--github-auth-token`[^cli-token] | The GitHub PAT (personal authentication token) to use for accessing the GitHub API. | 10 | | `githubRemoteName` | | Name of the git remote in this local repository that corresponds to GitHub | `origin` | `origin` | 11 | | `githubRepository` | `--github-repository` | Name of repository on github.com in `owner/repo` format | | extracted from the URL of the GitHub remote | 12 | | `githubMasterBranch` | | The name of the centrally shared branch into which the pull requests are merged | `master` | taken from repository configuration on GitHub | 13 | | `branchPrefix` | `--branch-prefix` | String used to prefix autogenerated names of pull request branches | | `spr/GITHUB_USERNAME/` | 14 | | `requireApproval` | | If true, `spr land` will refuse to land a pull request that is not accepted | false | 15 | | `requireTestPlan` | | If true, `spr diff` will refuse to process a commit without a test plan | true | 16 | 17 | 18 | - The config keys are all in the `spr` section; for example, `spr.githubAuthToken`. 19 | 20 | - Values passed on the command line take precedence over values set in Git configuration. 21 | 22 | - Values are read from Git configuration as if by `git config --get`, and thus follow its order of precedence in reading from local and global config files. See the [git-config docs](https://git-scm.com/docs/git-config) for dteails. 23 | 24 | - `spr init` writes configured values into `.git/config` in the local repo. (It must be run inside a Git repo.) 25 | 26 | [^default]: Value used by `spr` if not set in configuration. 27 | 28 | [^initdefault]: Value suggested by `spr init` if not previously configured. 29 | 30 | [^cli-token]: Be careful using this: your auth token will be in your shell history. 31 | -------------------------------------------------------------------------------- /docs/spr.svg: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /docs/user/commit-message.md: -------------------------------------------------------------------------------- 1 | # Format and Update Commit Messages 2 | 3 | You should format your commit messages like this: 4 | 5 | ``` 6 | One-line title 7 | 8 | Then a description, which may be multiple lines long. 9 | This describes the change you are making with this commit. 10 | 11 | Test Plan: how to test the change in this commit. 12 | 13 | The test plan can also be several lines long. 14 | 15 | Reviewers: github-username-a, github-username-b 16 | ``` 17 | 18 | The first line will be the title of the PR created by `spr diff`, and the rest of the lines except for the `Reviewers` line will be the PR description (i.e. the content of the first comment). The GitHub users named on the `Reviewers` line will be added to the PR as reviewers. 19 | 20 | The `Test Plan` section is required to be present by default; `spr diff` will fail with an error if it isn't. 21 | You can disable this in the [configuration](../reference/configuration.md). 22 | 23 | ## Updating the commit message 24 | 25 | When you create a PR with `spr diff`, **the PR becomes the source of truth** for the title and description. When you land a commit with `spr land`, its commit message will be amended to match the PR's title and description, regardless of what is in your local repo. 26 | 27 | If you want to update the title or description, there are two ways to do so: 28 | 29 | - Modify the PR through GitHub's UI. 30 | 31 | - Amend the commit message locally, then run `spr diff --update-message`. _Note that this does not update reviewers_; that must be done in the GitHub UI. If you amend the commit message but don't include the `--update-message` flag, you'll get an error. 32 | 33 | If you want to go the other way --- that is, make your local commit message match the PR's title and description --- you can run `spr amend`. 34 | 35 | ## Further information 36 | 37 | ### Fields added by spr 38 | 39 | At various stages of a commit's lifecycle, `spr` will add lines to the commit message: 40 | 41 | - After first creating a PR, `spr diff` will amend the commit message to include a line like this at the end: 42 | 43 | ``` 44 | Pull Request: https://github.com/example/project/pull/123 45 | ``` 46 | 47 | The presence or absence of this line is how `spr diff` knows whether a commit already has a PR created for it, and thus whether it should create a new PR or update an existing one. 48 | 49 | - `spr land` will amend the commit message to exactly match the title/description of the PR (just as `spr amend` does), as well as adding a line like this: 50 | ``` 51 | Reviewed By: github-username-a 52 | ``` 53 | This line names the GitHub users who approved the PR. 54 | 55 | ### Example commit message lifecycle 56 | 57 | This is what a commit message should look like when you first commit it, before running `spr` at all: 58 | 59 | ``` 60 | Add feature 61 | 62 | This is a really cool feature! It's going to be great. 63 | 64 | Test Plan: 65 | - Run tests 66 | - Use the feature 67 | 68 | Reviewers: user-a, coworker-b 69 | ``` 70 | 71 | After running `spr diff` to create a PR, the local commit message will be amended to include a link to the PR: 72 | 73 | ``` 74 | Add feature 75 | 76 | This is a really cool feature! It's going to be great. 77 | 78 | Test Plan: 79 | - Run tests 80 | - Use the feature 81 | 82 | Reviewers: user-a, coworker-b 83 | 84 | Pull Request: https://github.com/example/my-thing/pull/123 85 | ``` 86 | 87 | In this state, running `spr diff` again will update PR 123. 88 | 89 | Running `spr land` will amend the commit message to have the exact title/description of PR 123, add the list of users who approved the PR, then land the commit. In this case, suppose only `coworker-b` approved: 90 | 91 | ``` 92 | Add feature 93 | 94 | This is a really cool feature! It's going to be great. 95 | 96 | Test Plan: 97 | - Run tests 98 | - Use the feature 99 | 100 | Reviewers: user-a, coworker-b 101 | 102 | Reviewed By: coworker-b 103 | 104 | Pull Request: https://github.com/example/my-thing/pull/123 105 | ``` 106 | 107 | ### Reformatting the commit message 108 | 109 | spr is fairly permissive in parsing your commit message: it is case-insensitive, and it mostly ignores whitespace. You can run `spr format` to rewrite your HEAD commit's message to be in a canonical format. 110 | 111 | This command does not touch GitHub; it doesn't matter whether the commit has a PR created for it or not. 112 | 113 | Note that `spr land` will write the message of the commit it lands in the canonical format; you don't need to do so yourself before landing. 114 | -------------------------------------------------------------------------------- /docs/user/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | ## Binary Installation 4 | 5 | ### Using Homebrew 6 | 7 | ```shell 8 | brew install spr 9 | ``` 10 | 11 | ### Using Nix 12 | 13 | ```shell 14 | nix-channel --update && nix-env -i spr 15 | ``` 16 | 17 | ### Using Cargo 18 | 19 | If you have Cargo installed (the Rust build tool), you can install spr by running `cargo install spr`. 20 | 21 | ## Install from Source 22 | 23 | spr is written in Rust. You need a Rust toolchain to build from source. See [rustup.rs](https://rustup.rs) for information on how to install Rust if you have not got a Rust toolchain on your system already. 24 | 25 | With Rust all set up, clone this repository and run `cargo build --release`. The spr binary will be in the `target/release` directory. 26 | -------------------------------------------------------------------------------- /docs/user/patch.md: -------------------------------------------------------------------------------- 1 | # Check Out Someone Else's PR 2 | 3 | While reviewing someone else's pull request, it may be useful to pull their changes to your local repo, so you can run their code, or view it in your editor/IDE, etc. 4 | 5 | To do so, get the number of the PR you want to pull, and run `spr patch `. This creates a local branch named `PR-`, and checks it out. 6 | 7 | The head of this new local branch is the PR commit itself. The branch is based on the `main` commit that was closest to the PR commit in the creator's local repo. In between: 8 | 9 | - If the PR commit was directly on top of a `main` commit, then the PR commit will be the only one on the branch. 10 | 11 | - If there were commits between the PR commit and the nearest `main` commit, they will be squashed into a single commit in your new local branch. 12 | 13 | Thus, the new local branch always has either one or two commits on it, before joining `main`. 14 | 15 | ![Diagram of the branching scheme](../images/patch.svg) 16 | 17 | ## Updating the PR 18 | 19 | You can amend the head commit of the `PR-` branch locally, and run `spr diff` to update the PR; it doesn't matter that you didn't create the PR. However, doing so will overwrite the contents of the PR on GitHub with what you have locally. You should coordinate with the PR creator before doing so. 20 | -------------------------------------------------------------------------------- /docs/user/setup.md: -------------------------------------------------------------------------------- 1 | # Set up spr 2 | 3 | In the repo you want to use spr in, run `spr init`; this will ask you several questions. 4 | 5 | You'll need to provide a GitHub personal access token (PAT) as the first step. [See the GitHub docs](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token) on how to create one. `spr init` will tell you which scopes the token must have; make sure to set them correctly when creating the token. 6 | 7 | The rest of the settings that `spr init` asks for have sensible defaults, so almost all users can simply accept the defaults. The most common situation where you would need to diverge from the defaults is if the remote representing GitHub is not called `origin`. 8 | 9 | See the [Configuration](../reference/configuration.md) reference page for full details about the available settings. 10 | 11 | After initial setup, you can update your settings in several ways: 12 | 13 | - Simply rerun `spr init`. The defaults it suggests will be your existing settings, so you can easily change only what you need to. 14 | 15 | - Use `git config --set` ([docs here](https://git-scm.com/docs/git-config)). 16 | 17 | - Edit the `[spr]` section of `.git/config` directly. 18 | -------------------------------------------------------------------------------- /docs/user/simple.md: -------------------------------------------------------------------------------- 1 | # Create and Land a Simple PR 2 | 3 | This section details the process of putting a single commit up for review, and landing it (pushing it upstream). It assumes you don't have multiple reviews in flight at the same time. That situation is covered in [another guide](./stack.md), but you should be familiar with this single-review workflow before reading that one. 4 | 5 | 1. Pull `main` from upstream, and check it out. 6 | 7 | 2. Make your change, and run `git commit`. See [this guide](./commit-message.md) for what to put in your commit message. 8 | 9 | 3. Run `spr diff`. This will create a PR for your HEAD commit. 10 | 11 | 4. Wait for reviewers to approve. If you need to make changes: 12 | 13 | 1. Make whatever changes you need in your working copy. 14 | 2. Amend them into your HEAD commit with `git commit --amend`. 15 | 3. Run `spr diff`. If you changed the commit message in the previous step, you will need to add the flag `--update-message`; see [this guide](./commit-message.md) for more detail. 16 | 17 | This will update the PR with the new version of your HEAD commit. spr will prompt you for a short message that describes what you changed. You can also pass the update message on the command line using the `--message`/`-m` flag of `spr diff`. 18 | 19 | 5. Once your PR is approved, run `spr land` to push it upstream. 20 | 21 | The above instructions have you committing directly to your local `main`. Doing so will keep things simpler when you have multiple reviews in flight. However, spr does not require that you commit directly to `main`. You can make branches if you prefer. `spr land` will always push your commit to upstream `main`, regardless of which local branch it was on. Note that `spr land` won't delete your feature branch. 22 | 23 | ## When you update 24 | 25 | When you run `spr diff` to update an existing PR, your update will be added to the PR as a new commit, so that reviewers can see exactly what changed. The new commit's message will be what you entered in step 4.3 of the instructions above. 26 | 27 | The individual commits that you see in the PR are solely for the benefit of reviewers; they will not be reflected in the commit history when the PR is landed. The commit that eventually lands on upstream `main` will always be a single commit, whose message is the title and description from the PR. 28 | 29 | ## Updating before landing 30 | 31 | If you amend your local commit before landing, you must run `spr diff` to update the PR before landing, or else `spr land` will fail. 32 | 33 | This is because `spr land` checks to make sure that the following two operations result in exactly the same tree: 34 | 35 | - Merging the PR directly into upstream `main`. 36 | - Cherry-picking your HEAD commit onto upstream `main`. 37 | 38 | This check prevents `spr land` from either landing or silently dropping unreviewed changes. 39 | 40 | ## Conflicts on landing 41 | 42 | `spr land` may fail with conflicts; for example, there may have been new changes pushed to upstream `main` since you last rebased, and those changes conflict with your PR. In this case: 43 | 44 | 1. Rebase your PR onto latest upstream `main`, resolving conflicts in the process. 45 | 46 | 2. Run `spr diff` to update the PR. 47 | 48 | 3. Run `spr land` again. 49 | 50 | Note that even if your local commit (and your PR) is not based on the latest upstream `main`, landing will still succeed as long as there are no conflicts with the actual latest upstream `main`. 51 | -------------------------------------------------------------------------------- /docs/user/stack.md: -------------------------------------------------------------------------------- 1 | # Stack Multiple PRs 2 | 3 | The differences between spr's commit-based workflow and GitHub's default branch-based workflow are most apparent when you have multiple reviews in flight at the same time. 4 | 5 | This guide assumes you're already familiar with the workflow for [simple, non-stacked PRs](./simple.md). 6 | 7 | You'll use Git's [interactive rebase](https://git-scm.com/docs/git-rebase#_interactive_mode) quite often in managing stacked-PR situations. It's a very powerful tool for reordering and combining commits in a series. 8 | 9 | This is the workflow for creating multiple PRs at the same time. This example only creates two, but the workflow works for arbitrarily deep stacks. 10 | 11 | 1. Make a change and commit it on `main`. We'll call this commit A. 12 | 13 | 2. Make another change and commit it on top of commit A. We'll call this commit B. 14 | 15 | 3. Run `spr diff --all`. This is equivalent to calling `spr diff` on each commit starting from `HEAD` and going to back to the first commit that is part of upstream `main`. Thus, it will create a PR for each of commits A and B. 16 | 17 | 4. Suppose you need to update commit A in response to review feedback. You would: 18 | 19 | 1. Make the change and commit it on top of commit B, with a throwaway message. 20 | 21 | 2. Run `git rebase --interactive`. This will bring up an editor that looks like this: 22 | 23 | ``` 24 | pick 0a0a0a Commit A 25 | pick 1b1b1b Commit B 26 | pick 2c2c2c throwaway 27 | ``` 28 | 29 | Modify it to look like this[^rebase-cmds]: 30 | 31 | ``` 32 | pick 0a0a0a Commit A 33 | fixup 2c2c2c throwaway 34 | exec spr diff 35 | pick 1b1b1b Commit B 36 | ``` 37 | 38 | This will (1) amend your latest commit into commit A, discarding the throwaway message and using commit A's message for the combined result; (2) run `spr diff` on the combined result; and (3) put commit B on top of the combined result. 39 | 40 | 5. You must land commit A before commit B. (See [the next section](#cherry-picking) for what to do if you want to be able to land B first.) To land commit A, you would: 41 | 42 | 1. Run `git rebase --interactive`. The editor will start with this: 43 | 44 | ``` 45 | pick 3a3a3a Commit A 46 | pick 4b4b4b Commit B 47 | ``` 48 | 49 | Modify it to look like this: 50 | 51 | ``` 52 | pick 3a3a3a Commit A 53 | exec spr land 54 | pick 4b4b4b Commit B 55 | ``` 56 | 57 | 6. Now you're left with just commit B on top of upstream `main`, and you can use the non-stacked workflow to update and land it. 58 | 59 | There are a few possible variations to note: 60 | 61 | - Instead of a single run of `spr diff --all` at the beginning, you could run plain `spr diff` right after making each commit. 62 | 63 | - Instead of step 4, you could use interactive rebase to swap the order of commits A and B (as long as B doesn't depend on A), and then simply use the non-stacked workflow to amend A and update the PR. 64 | 65 | - In step 4.2, if you want to update the commit message of commit A, you could instead do the following interactive rebase: 66 | 67 | ``` 68 | pick 0a0a0a Commit A 69 | squash 2c2c2c throwaway 70 | exec spr diff --update-message 71 | pick 1b1b1b Commit B 72 | ``` 73 | 74 | The `squash` command will open an editor, where you can edit the message of the combined commit. The `--update-message` flag on the next line is important; see [this guide](./commit-message.md) for more detail. 75 | 76 | ## Cherry-picking 77 | 78 | In the above example, you would not be able to land commit B before landing commit A, even if they were totally independent of each other. 79 | 80 | First, some behind-the-scenes explanation. When you create the PR for commit B, `spr diff` will create a PR whose base branch is not `main`, but rather a synthetic branch that contains the difference between `main` and B's parent. This is so that the PR for B only shows the changes in B itself, rather than the entire difference between `main` and B. 81 | 82 | When you run `spr land`, it checks that each of these two operations would produce _exactly the same tree_: 83 | 84 | - Merging the PR directly into upstream `main`. 85 | - Cherry-picking the local commit onto upstream `main`. 86 | 87 | If those operations wouldn't result in the same tree, `spr land` fails. This is to prevent you from landing a commit whose contents aren't the same as what reviewers have seen. 88 | 89 | In the above example, then, the PR for commit B has a synthetic base branch that contains the changes in commit A. Thus, if you tried to land B before A, `spr land`'s "merge PR vs. cherry-pick" check would fail. 90 | 91 | If you want to be able to land commit B before A, do this: 92 | 93 | 1. Make commit A on top of `main` as before, and run `spr diff`. 94 | 95 | 2. Make commit B on top of A as before, and run `spr diff --cherry-pick`. The flag causes `spr diff` to create the PR as if B were cherry-picked onto upstream `main`, rather than creating the synthetic base branch. (This step will fail if B does not cherry-pick cleanly onto upstream `main`, which would imply that A and B are not truly independent.) 96 | 97 | 3. Once B is ready to land, you can do one of two things: 98 | 99 | - Run `spr land --cherry-pick`. (By default, `spr land` refuses to land a commit whose parent is not on upstream `main`; the flag makes it skip that check.) 100 | 101 | - Do an interactive rebase that puts B directly on top of upstream `main`, then runs `spr land`, then puts A on top of B. 102 | 103 | ## Rebasing the whole stack 104 | 105 | One of the major advantages of committing everything to local `main` is that rebasing your work onto new upstream `main` commits is much simpler than if you had a branch for every in-flight review. The difference is especially pronounced if some of your reviews depend on others, which would entail dependent feature branches in a branch-based workflow. 106 | 107 | Rebasing all your in-flight reviews and updating their PRs is as simple as: 108 | 109 | 1. Run `git pull --rebase` on `main`, resolving conflicts along the way as needed. 110 | 111 | 2. Run `spr diff --all`. 112 | 113 | [^rebase-cmds]: You can shorten `exec` to `x`, `fixup` to `f`, and `squash` to `s`; they are spelled out here for clarity. 114 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 80 2 | -------------------------------------------------------------------------------- /spr/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "spr" 3 | version = "1.3.6-beta.1" 4 | authors = ["Sven Over ", "Jozef Mokry "] 5 | description = "Submit pull requests for individual, amendable, rebaseable commits to GitHub" 6 | repository = "https://github.com/getcord/spr" 7 | homepage = "https://github.com/getcord/spr" 8 | license = "MIT" 9 | edition = "2021" 10 | exclude = [".github", ".gitignore"] 11 | 12 | [dependencies] 13 | clap = { version = "^3.2.6", features = ["derive", "wrap_help"] } 14 | console = "^0.15.0" 15 | debug-ignore = "1.0.5" 16 | dialoguer = "^0.10.1" 17 | futures = "^0.3.21" 18 | futures-lite = "^1.12.0" 19 | git2 = { version = "^0.17.2", default-features = false } 20 | git2-ext = "0.6.0" 21 | graphql_client = "^0.11.0" 22 | indoc = "^1.0.3" 23 | lazy-regex = "^2.2.2" 24 | octocrab = { version = "^0.16.0", default-features = false, features = ["rustls"] } 25 | reqwest = { version = "^0.11.11", default-features = false, features = ["json", "rustls-tls"] } 26 | serde = "^1.0.136" 27 | textwrap = "0.15.0" 28 | thiserror = "^1.0.30" 29 | tokio = { version = "^1.19.2", features = ["macros", "process", "rt-multi-thread", "time"] } 30 | unicode-normalization = "^0.1.19" 31 | -------------------------------------------------------------------------------- /spr/src/commands/amend.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Radical HQ Limited 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | use crate::{ 9 | error::{Error, Result}, 10 | git::PreparedCommit, 11 | message::validate_commit_message, 12 | output::{output, write_commit_title}, 13 | }; 14 | 15 | #[derive(Debug, clap::Parser)] 16 | pub struct AmendOptions { 17 | /// Amend all commits in branch, not just HEAD 18 | #[clap(long, short = 'a')] 19 | all: bool, 20 | } 21 | 22 | pub async fn amend( 23 | opts: AmendOptions, 24 | git: &crate::git::Git, 25 | gh: &mut crate::github::GitHub, 26 | config: &crate::config::Config, 27 | ) -> Result<()> { 28 | let mut pc = git.lock_and_get_prepared_commits(config)?; 29 | 30 | let len = pc.len(); 31 | if len == 0 { 32 | output("👋", "Branch is empty - nothing to do. Good bye!")?; 33 | return Ok(()); 34 | } 35 | 36 | // The slice of prepared commits we want to operate on. 37 | let slice = if opts.all { 38 | &mut pc[..] 39 | } else { 40 | &mut pc[len - 1..] 41 | }; 42 | 43 | // Request the Pull Request information for each commit (well, those that 44 | // declare to have Pull Requests). This list is in reverse order, so that 45 | // below we can pop from the vector as we iterate. 46 | let mut pull_requests: Vec<_> = slice 47 | .iter() 48 | .rev() 49 | .map(|pc: &PreparedCommit| { 50 | pc.pull_request_number 51 | .map(|number| tokio::spawn(gh.clone().get_pull_request(number))) 52 | }) 53 | .collect(); 54 | 55 | let mut failure = false; 56 | 57 | for commit in slice.iter_mut() { 58 | write_commit_title(commit)?; 59 | let pull_request = pull_requests.pop().flatten(); 60 | if let Some(pull_request) = pull_request { 61 | let pull_request = pull_request.await??; 62 | commit.message = pull_request.sections; 63 | } 64 | failure = validate_commit_message(&commit.message, config).is_err() 65 | || failure; 66 | } 67 | git.lock_and_rewrite_commit_messages(slice, None)?; 68 | 69 | if failure { 70 | Err(Error::empty()) 71 | } else { 72 | Ok(()) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /spr/src/commands/close.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Radical HQ Limited 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | use std::process::Stdio; 9 | 10 | use indoc::formatdoc; 11 | 12 | use crate::{ 13 | error::{add_error, Error, Result}, 14 | git::PreparedCommit, 15 | github::{PullRequestState, PullRequestUpdate}, 16 | message::MessageSection, 17 | output::{output, write_commit_title}, 18 | }; 19 | 20 | #[derive(Debug, clap::Parser)] 21 | pub struct CloseOptions { 22 | /// Close Pull Requests for the whole branch, not just the HEAD commit 23 | #[clap(long, short = 'a')] 24 | all: bool, 25 | } 26 | 27 | pub async fn close( 28 | opts: CloseOptions, 29 | git: &crate::git::Git, 30 | gh: &mut crate::github::GitHub, 31 | config: &crate::config::Config, 32 | ) -> Result<()> { 33 | let mut result = Ok(()); 34 | 35 | let mut prepared_commits = git.lock_and_get_prepared_commits(config)?; 36 | 37 | if prepared_commits.is_empty() { 38 | output("👋", "Branch is empty - nothing to do. Good bye!")?; 39 | return result; 40 | }; 41 | 42 | if !opts.all { 43 | // Remove all prepared commits from the vector but the last. So, if 44 | // `--all` is not given, we only operate on the HEAD commit. 45 | prepared_commits.drain(0..prepared_commits.len() - 1); 46 | } 47 | 48 | for prepared_commit in prepared_commits.iter_mut() { 49 | if result.is_err() { 50 | break; 51 | } 52 | 53 | write_commit_title(prepared_commit)?; 54 | 55 | // The further implementation of the close command is in a separate function. 56 | // This makes it easier to run the code to update the local commit message 57 | // with all the changes that the implementation makes at the end, even if 58 | // the implementation encounters an error or exits early. 59 | result = close_impl(gh, config, prepared_commit).await; 60 | } 61 | 62 | // This updates the commit message in the local Git repository (if it was 63 | // changed by the implementation) 64 | add_error( 65 | &mut result, 66 | git.lock_and_rewrite_commit_messages( 67 | prepared_commits.as_mut_slice(), 68 | None, 69 | ), 70 | ); 71 | 72 | result 73 | } 74 | 75 | async fn close_impl( 76 | gh: &mut crate::github::GitHub, 77 | config: &crate::config::Config, 78 | prepared_commit: &mut PreparedCommit, 79 | ) -> Result<()> { 80 | let pull_request_number = 81 | if let Some(number) = prepared_commit.pull_request_number { 82 | output("#️⃣ ", &format!("Pull Request #{}", number))?; 83 | number 84 | } else { 85 | return Err(Error::new( 86 | "This commit does not refer to a Pull Request.", 87 | )); 88 | }; 89 | 90 | // Load Pull Request information 91 | let pull_request = gh.clone().get_pull_request(pull_request_number).await?; 92 | 93 | if pull_request.state != PullRequestState::Open { 94 | return Err(Error::new(formatdoc!( 95 | "This Pull Request is already closed!", 96 | ))); 97 | } 98 | 99 | output("📖", "Getting started...")?; 100 | 101 | let base_is_master = pull_request.base.is_master_branch(); 102 | 103 | let result = gh 104 | .update_pull_request( 105 | pull_request_number, 106 | PullRequestUpdate { 107 | state: Some(PullRequestState::Closed), 108 | ..Default::default() 109 | }, 110 | ) 111 | .await; 112 | 113 | match result { 114 | Ok(()) => (), 115 | Err(error) => { 116 | output("❌", "GitHub Pull Request close failed")?; 117 | 118 | return Err(error); 119 | } 120 | }; 121 | 122 | output("📕", "Closed!")?; 123 | 124 | // Remove sections from commit that are not relevant after closing. 125 | prepared_commit.message.remove(&MessageSection::PullRequest); 126 | prepared_commit.message.remove(&MessageSection::ReviewedBy); 127 | 128 | let mut remove_old_branch_child_process = 129 | tokio::process::Command::new("git") 130 | .arg("push") 131 | .arg("--no-verify") 132 | .arg("--delete") 133 | .arg("--") 134 | .arg(&config.remote_name) 135 | .arg(pull_request.head.on_github()) 136 | .stdout(Stdio::null()) 137 | .stderr(Stdio::null()) 138 | .spawn()?; 139 | 140 | let remove_old_base_branch_child_process = if base_is_master { 141 | None 142 | } else { 143 | Some( 144 | tokio::process::Command::new("git") 145 | .arg("push") 146 | .arg("--no-verify") 147 | .arg("--delete") 148 | .arg("--") 149 | .arg(&config.remote_name) 150 | .arg(pull_request.base.on_github()) 151 | .stdout(Stdio::null()) 152 | .stderr(Stdio::null()) 153 | .spawn()?, 154 | ) 155 | }; 156 | 157 | // Wait for the "git push" to delete the old Pull Request branch to finish, 158 | // but ignore the result. 159 | // GitHub may be configured to delete the branch automatically, 160 | // in which case it's gone already and this command fails. 161 | remove_old_branch_child_process.wait().await?; 162 | if let Some(mut proc) = remove_old_base_branch_child_process { 163 | proc.wait().await?; 164 | } 165 | 166 | Ok(()) 167 | } 168 | -------------------------------------------------------------------------------- /spr/src/commands/diff.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Radical HQ Limited 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | use std::iter::zip; 9 | 10 | use crate::{ 11 | error::{add_error, Error, Result, ResultExt}, 12 | git::PreparedCommit, 13 | github::{ 14 | GitHub, PullRequest, PullRequestRequestReviewers, PullRequestState, 15 | PullRequestUpdate, 16 | }, 17 | message::{validate_commit_message, MessageSection}, 18 | output::{output, write_commit_title}, 19 | utils::{parse_name_list, remove_all_parens, run_command}, 20 | }; 21 | use git2::Oid; 22 | use indoc::{formatdoc, indoc}; 23 | 24 | #[derive(Debug, clap::Parser)] 25 | pub struct DiffOptions { 26 | /// Create/update pull requests for the whole branch, not just the HEAD commit 27 | #[clap(long, short = 'a')] 28 | all: bool, 29 | 30 | /// Update the pull request title and description on GitHub from the local 31 | /// commit message 32 | #[clap(long)] 33 | update_message: bool, 34 | 35 | /// Submit any new Pull Request as a draft 36 | #[clap(long)] 37 | draft: bool, 38 | 39 | /// Message to be used for commits updating existing pull requests (e.g. 40 | /// 'rebase' or 'review comments') 41 | #[clap(long, short = 'm')] 42 | message: Option, 43 | 44 | /// Submit this commit as if it was cherry-picked on master. Do not base it 45 | /// on any intermediate changes between the master branch and this commit. 46 | #[clap(long)] 47 | cherry_pick: bool, 48 | } 49 | 50 | pub async fn diff( 51 | opts: DiffOptions, 52 | git: &crate::git::Git, 53 | gh: &mut crate::github::GitHub, 54 | config: &crate::config::Config, 55 | ) -> Result<()> { 56 | // Abort right here if the local Git repository is not clean 57 | git.lock_and_check_no_uncommitted_changes()?; 58 | 59 | let mut result = Ok(()); 60 | 61 | // Look up the commits on the local branch 62 | let mut prepared_commits = git.lock_and_get_prepared_commits(config)?; 63 | 64 | // The parent of the first commit in the list is the commit on master that 65 | // the local branch is based on 66 | let master_base_oid = if let Some(first_commit) = prepared_commits.first() { 67 | first_commit.parent_oid 68 | } else { 69 | output("👋", "Branch is empty - nothing to do. Good bye!")?; 70 | return result; 71 | }; 72 | 73 | if !opts.all { 74 | // Remove all prepared commits from the vector but the last. So, if 75 | // `--all` is not given, we only operate on the HEAD commit. 76 | prepared_commits.drain(0..prepared_commits.len() - 1); 77 | } 78 | 79 | #[allow(clippy::needless_collect)] 80 | let pull_request_tasks: Vec<_> = prepared_commits 81 | .iter() 82 | .map(|pc: &PreparedCommit| { 83 | pc.pull_request_number 84 | .map(|number| tokio::spawn(gh.clone().get_pull_request(number))) 85 | }) 86 | .collect(); 87 | 88 | let mut message_on_prompt = "".to_string(); 89 | 90 | for (prepared_commit, pull_request_task) in 91 | zip(prepared_commits.iter_mut(), pull_request_tasks.into_iter()) 92 | { 93 | if result.is_err() { 94 | break; 95 | } 96 | 97 | let pull_request = if let Some(task) = pull_request_task { 98 | Some(task.await??) 99 | } else { 100 | None 101 | }; 102 | 103 | write_commit_title(prepared_commit)?; 104 | 105 | // The further implementation of the diff command is in a separate function. 106 | // This makes it easier to run the code to update the local commit message 107 | // with all the changes that the implementation makes at the end, even if 108 | // the implementation encounters an error or exits early. 109 | result = diff_impl( 110 | &opts, 111 | &mut message_on_prompt, 112 | git, 113 | gh, 114 | config, 115 | prepared_commit, 116 | master_base_oid, 117 | pull_request, 118 | ) 119 | .await; 120 | } 121 | 122 | // This updates the commit message in the local Git repository (if it was 123 | // changed by the implementation) 124 | add_error( 125 | &mut result, 126 | git.lock_and_rewrite_commit_messages( 127 | prepared_commits.as_mut_slice(), 128 | None, 129 | ), 130 | ); 131 | 132 | result 133 | } 134 | 135 | #[allow(clippy::too_many_arguments)] 136 | async fn diff_impl( 137 | opts: &DiffOptions, 138 | message_on_prompt: &mut String, 139 | git: &crate::git::Git, 140 | gh: &mut crate::github::GitHub, 141 | config: &crate::config::Config, 142 | local_commit: &mut PreparedCommit, 143 | master_base_oid: Oid, 144 | pull_request: Option, 145 | ) -> Result<()> { 146 | // Parsed commit message of the local commit 147 | let message = &mut local_commit.message; 148 | 149 | // Check if the local commit is based directly on the master branch. 150 | let directly_based_on_master = local_commit.parent_oid == master_base_oid; 151 | 152 | // Determine the trees the Pull Request branch and the base branch should 153 | // have when we're done here. 154 | let (new_head_tree, new_base_tree) = 155 | if !opts.cherry_pick || directly_based_on_master { 156 | // Unless the user tells us to --cherry-pick, these should be the trees 157 | // of the current commit and its parent. 158 | // If the current commit is directly based on master (i.e. 159 | // directly_based_on_master is true), then we can do this here even when 160 | // the user tells us to --cherry-pick, because we would cherry pick the 161 | // current commit onto its parent, which gives us the same tree as the 162 | // current commit has, and the master base is the same as this commit's 163 | // parent. 164 | let head_tree = 165 | git.lock_and_get_tree_oid_for_commit(local_commit.oid)?; 166 | let base_tree = 167 | git.lock_and_get_tree_oid_for_commit(local_commit.parent_oid)?; 168 | 169 | (head_tree, base_tree) 170 | } else { 171 | // Cherry-pick the current commit onto master 172 | let index = 173 | git.lock_and_cherrypick(local_commit.oid, master_base_oid)?; 174 | 175 | if index.has_conflicts() { 176 | return Err(Error::new(formatdoc!( 177 | "This commit cannot be cherry-picked on {master}.", 178 | master = config.master_ref.branch_name(), 179 | ))); 180 | } 181 | 182 | // This is the tree we are getting from cherrypicking the local commit 183 | // on master. 184 | let cherry_pick_tree = git.lock_and_write_index(index)?; 185 | let master_tree = 186 | git.lock_and_get_tree_oid_for_commit(master_base_oid)?; 187 | 188 | (cherry_pick_tree, master_tree) 189 | }; 190 | 191 | if let Some(number) = local_commit.pull_request_number { 192 | output( 193 | "#️⃣ ", 194 | &format!( 195 | "Pull Request #{}: {}", 196 | number, 197 | config.pull_request_url(number) 198 | ), 199 | )?; 200 | } 201 | 202 | if local_commit.pull_request_number.is_none() || opts.update_message { 203 | validate_commit_message(message, config)?; 204 | } 205 | 206 | if let Some(ref pull_request) = pull_request { 207 | if pull_request.state == PullRequestState::Closed { 208 | return Err(Error::new(formatdoc!( 209 | "Pull request is closed. If you want to open a new one, \ 210 | remove the 'Pull Request' section from the commit message." 211 | ))); 212 | } 213 | 214 | if !opts.update_message { 215 | let mut pull_request_updates: PullRequestUpdate = 216 | Default::default(); 217 | pull_request_updates.update_message(pull_request, message); 218 | 219 | if !pull_request_updates.is_empty() { 220 | output( 221 | "⚠️", 222 | indoc!( 223 | "The Pull Request's title/message differ from the \ 224 | local commit's message. 225 | Use `spr diff --update-message` to overwrite the \ 226 | title and message on GitHub with the local message, \ 227 | or `spr amend` to go the other way (rewrite the local \ 228 | commit message with what is on GitHub)." 229 | ), 230 | )?; 231 | } 232 | } 233 | } 234 | 235 | // Parse "Reviewers" section, if this is a new Pull Request 236 | let mut requested_reviewers = PullRequestRequestReviewers::default(); 237 | 238 | if local_commit.pull_request_number.is_none() { 239 | if let Some(reviewers) = message.get(&MessageSection::Reviewers) { 240 | let reviewers = parse_name_list(reviewers); 241 | let mut checked_reviewers = Vec::new(); 242 | 243 | for reviewer in reviewers { 244 | // Teams are indicated with a leading # 245 | if let Some(slug) = reviewer.strip_prefix('#') { 246 | if let Ok(team) = GitHub::get_github_team( 247 | (&config.owner).into(), 248 | slug.into(), 249 | ) 250 | .await 251 | { 252 | requested_reviewers 253 | .team_reviewers 254 | .push(team.slug.to_string()); 255 | 256 | checked_reviewers.push(reviewer); 257 | } else { 258 | return Err(Error::new(format!( 259 | "Reviewers field contains unknown team '{}'", 260 | reviewer 261 | ))); 262 | } 263 | } else if let Ok(user) = 264 | GitHub::get_github_user(reviewer.clone()).await 265 | { 266 | requested_reviewers.reviewers.push(user.login); 267 | if let Some(name) = user.name { 268 | checked_reviewers.push(format!( 269 | "{} ({})", 270 | reviewer.clone(), 271 | remove_all_parens(&name) 272 | )); 273 | } else { 274 | checked_reviewers.push(reviewer); 275 | } 276 | } else { 277 | return Err(Error::new(format!( 278 | "Reviewers field contains unknown user '{}'", 279 | reviewer 280 | ))); 281 | } 282 | } 283 | 284 | message.insert( 285 | MessageSection::Reviewers, 286 | checked_reviewers.join(", "), 287 | ); 288 | } 289 | } 290 | 291 | // Get the name of the existing Pull Request branch, or constuct one if 292 | // there is none yet. 293 | 294 | let title = message 295 | .get(&MessageSection::Title) 296 | .map(|t| &t[..]) 297 | .unwrap_or(""); 298 | 299 | let pull_request_branch = match &pull_request { 300 | Some(pr) => pr.head.clone(), 301 | None => config.new_github_branch( 302 | &config 303 | .get_new_branch_name(&git.lock_and_get_all_ref_names()?, title), 304 | ), 305 | }; 306 | 307 | // Get the tree ids of the current head of the Pull Request, as well as the 308 | // base, and the commit id of the master commit this PR is currently based 309 | // on. 310 | // If there is no pre-existing Pull Request, we fill in the equivalent 311 | // values. 312 | let (pr_head_oid, pr_head_tree, pr_base_oid, pr_base_tree, pr_master_base) = 313 | if let Some(pr) = &pull_request { 314 | let pr_head_tree = 315 | git.lock_and_get_tree_oid_for_commit(pr.head_oid)?; 316 | 317 | let current_master_oid = 318 | git.lock_and_resolve_reference(config.master_ref.local())?; 319 | let pr_base_oid = 320 | git.lock_repo().merge_base(pr.head_oid, pr.base_oid)?; 321 | let pr_base_tree = 322 | git.lock_and_get_tree_oid_for_commit(pr_base_oid)?; 323 | 324 | let pr_master_base = git 325 | .lock_repo() 326 | .merge_base(pr.head_oid, current_master_oid)?; 327 | 328 | ( 329 | pr.head_oid, 330 | pr_head_tree, 331 | pr_base_oid, 332 | pr_base_tree, 333 | pr_master_base, 334 | ) 335 | } else { 336 | let master_base_tree = 337 | git.lock_and_get_tree_oid_for_commit(master_base_oid)?; 338 | ( 339 | master_base_oid, 340 | master_base_tree, 341 | master_base_oid, 342 | master_base_tree, 343 | master_base_oid, 344 | ) 345 | }; 346 | let needs_merging_master = pr_master_base != master_base_oid; 347 | 348 | // At this point we can check if we can exit early because no update to the 349 | // existing Pull Request is necessary 350 | if let Some(ref pull_request) = pull_request { 351 | // So there is an existing Pull Request... 352 | if !needs_merging_master 353 | && pr_head_tree == new_head_tree 354 | && pr_base_tree == new_base_tree 355 | { 356 | // ...and it does not need a rebase, and the trees of both Pull 357 | // Request branch and base are all the right ones. 358 | output("✅", "No update necessary")?; 359 | 360 | if opts.update_message { 361 | // However, the user requested to update the commit message on 362 | // GitHub 363 | 364 | let mut pull_request_updates: PullRequestUpdate = 365 | Default::default(); 366 | pull_request_updates.update_message(pull_request, message); 367 | 368 | if !pull_request_updates.is_empty() { 369 | // ...and there are actual changes to the message 370 | gh.update_pull_request( 371 | pull_request.number, 372 | pull_request_updates, 373 | ) 374 | .await?; 375 | output("✍", "Updated commit message on GitHub")?; 376 | } 377 | } 378 | 379 | return Ok(()); 380 | } 381 | } 382 | 383 | // Check if there is a base branch on GitHub already. That's the case when 384 | // there is an existing Pull Request, and its base is not the master branch. 385 | let base_branch = if let Some(ref pr) = pull_request { 386 | if pr.base.is_master_branch() { 387 | None 388 | } else { 389 | Some(pr.base.clone()) 390 | } 391 | } else { 392 | None 393 | }; 394 | 395 | // We are going to construct `pr_base_parent: Option`. 396 | // The value will be the commit we have to merge into the new Pull Request 397 | // commit to reflect changes in the parent of the local commit (by rebasing 398 | // or changing commits between master and this one, although technically 399 | // that's also rebasing). 400 | // If it's `None`, then we will not merge anything into the new Pull Request 401 | // commit. 402 | // If we are updating an existing PR, then there are three cases here: 403 | // (1) the parent tree of this commit is unchanged and we do not need to 404 | // merge in master, which means that the local commit was amended, but 405 | // not rebased. We don't need to merge anything into the Pull Request 406 | // branch. 407 | // (2) the parent tree has changed, but the parent of the local commit is on 408 | // master (or we are cherry-picking) and we are not already using a base 409 | // branch: in this case we can merge the master commit we are based on 410 | // into the PR branch, without going via a base branch. Thus, we don't 411 | // introduce a base branch here and the PR continues to target the 412 | // master branch. 413 | // (3) the parent tree has changed, and we need to use a base branch (either 414 | // because one was already created earlier, or we find that we are not 415 | // directly based on master now): we need to construct a new commit for 416 | // the base branch. That new commit's tree is always that of that local 417 | // commit's parent (thus making sure that the difference between base 418 | // branch and pull request branch are exactly the changes made by the 419 | // local commit, thus the changes we want to have reviewed). The new 420 | // commit may have one or two parents. The previous base is always a 421 | // parent (that's either the current commit on an existing base branch, 422 | // or the previous master commit the PR was based on if there isn't a 423 | // base branch already). In addition, if the master commit this commit 424 | // is based on has changed, (i.e. the local commit got rebased on newer 425 | // master in the meantime) then we have to merge in that master commit, 426 | // which will be the second parent. 427 | // If we are creating a new pull request then `pr_base_tree` (the current 428 | // base of the PR) was set above to be the tree of the master commit the 429 | // local commit is based one, whereas `new_base_tree` is the tree of the 430 | // parent of the local commit. So if the local commit for this new PR is on 431 | // master, those two are the same (and we want to apply case 1). If the 432 | // commit is not directly based on master, we have to create this new PR 433 | // with a base branch, so that is case 3. 434 | 435 | let (pr_base_parent, base_branch) = 436 | if pr_base_tree == new_base_tree && !needs_merging_master { 437 | // Case 1 438 | (None, base_branch) 439 | } else if base_branch.is_none() 440 | && (directly_based_on_master || opts.cherry_pick) 441 | { 442 | // Case 2 443 | (Some(master_base_oid), None) 444 | } else { 445 | // Case 3 446 | 447 | // We are constructing a base branch commit. 448 | // One parent of the new base branch commit will be the current base 449 | // commit, that could be either the top commit of an existing base 450 | // branch, or a commit on master. 451 | let mut parents = vec![pr_base_oid]; 452 | 453 | // If we need to rebase on master, make the master commit also a 454 | // parent (except if the first parent is that same commit, we don't 455 | // want duplicates in `parents`). 456 | if needs_merging_master && pr_base_oid != master_base_oid { 457 | parents.push(master_base_oid); 458 | } 459 | 460 | let new_base_branch_commit = git.lock_and_create_derived_commit( 461 | local_commit.parent_oid, 462 | &format!( 463 | "[spr] {}\n\nCreated using spr {}\n\n[skip ci]", 464 | if pull_request.is_some() { 465 | "changes introduced through rebase".to_string() 466 | } else { 467 | format!( 468 | "changes to {} this commit is based on", 469 | config.master_ref.branch_name() 470 | ) 471 | }, 472 | env!("CARGO_PKG_VERSION"), 473 | ), 474 | new_base_tree, 475 | &parents[..], 476 | )?; 477 | 478 | // If `base_branch` is `None` (which means a base branch does not exist 479 | // yet), then make a `GitHubBranch` with a new name for a base branch 480 | let base_branch = if let Some(base_branch) = base_branch { 481 | base_branch 482 | } else { 483 | config.new_github_branch(&config.get_base_branch_name( 484 | &git.lock_and_get_all_ref_names()?, 485 | title, 486 | )) 487 | }; 488 | 489 | (Some(new_base_branch_commit), Some(base_branch)) 490 | }; 491 | 492 | let mut github_commit_message = opts.message.clone(); 493 | if pull_request.is_some() && github_commit_message.is_none() { 494 | let input = { 495 | let message_on_prompt = message_on_prompt.clone(); 496 | 497 | tokio::task::spawn_blocking(move || { 498 | dialoguer::Input::::new() 499 | .with_prompt("Message (leave empty to abort)") 500 | .with_initial_text(message_on_prompt) 501 | .allow_empty(true) 502 | .interact_text() 503 | }) 504 | .await?? 505 | }; 506 | 507 | if input.is_empty() { 508 | return Err(Error::new("Aborted as per user request".to_string())); 509 | } 510 | 511 | *message_on_prompt = input.clone(); 512 | github_commit_message = Some(input); 513 | } 514 | 515 | // Construct the new commit for the Pull Request branch. First parent is the 516 | // current head commit of the Pull Request (we set this to the master base 517 | // commit earlier if the Pull Request does not yet exist) 518 | let mut pr_commit_parents = vec![pr_head_oid]; 519 | 520 | // If we prepared a commit earlier that needs merging into the Pull Request 521 | // branch, then that commit is a parent of the new Pull Request commit. 522 | if let Some(oid) = pr_base_parent { 523 | // ...unless if that's the same commit as the one we added to 524 | // pr_commit_parents first. 525 | if pr_commit_parents.first() != Some(&oid) { 526 | pr_commit_parents.push(oid); 527 | } 528 | } 529 | 530 | // Create the new commit 531 | let pr_commit = git.lock_and_create_derived_commit( 532 | local_commit.oid, 533 | &format!( 534 | "{}\n\nCreated using spr {}", 535 | github_commit_message 536 | .as_ref() 537 | .map(|s| &s[..]) 538 | .unwrap_or("[spr] initial version"), 539 | env!("CARGO_PKG_VERSION"), 540 | ), 541 | new_head_tree, 542 | &pr_commit_parents[..], 543 | )?; 544 | 545 | let mut cmd = tokio::process::Command::new("git"); 546 | cmd.arg("push") 547 | .arg("--atomic") 548 | .arg("--no-verify") 549 | .arg("--") 550 | .arg(&config.remote_name) 551 | .arg(format!("{}:{}", pr_commit, pull_request_branch.on_github())); 552 | 553 | if let Some(pull_request) = pull_request { 554 | // We are updating an existing Pull Request 555 | 556 | if needs_merging_master { 557 | output( 558 | "⚾", 559 | &format!( 560 | "Commit was rebased - updating Pull Request #{}", 561 | pull_request.number 562 | ), 563 | )?; 564 | } else { 565 | output( 566 | "🔁", 567 | &format!( 568 | "Commit was changed - updating Pull Request #{}", 569 | pull_request.number 570 | ), 571 | )?; 572 | } 573 | 574 | // Things we want to update in the Pull Request on GitHub 575 | let mut pull_request_updates: PullRequestUpdate = Default::default(); 576 | 577 | if opts.update_message { 578 | pull_request_updates.update_message(&pull_request, message); 579 | } 580 | 581 | if let Some(base_branch) = base_branch { 582 | // We are using a base branch. 583 | 584 | if let Some(base_branch_commit) = pr_base_parent { 585 | // ...and we prepared a new commit for it, so we need to push an 586 | // update of the base branch. 587 | cmd.arg(format!( 588 | "{}:{}", 589 | base_branch_commit, 590 | base_branch.on_github() 591 | )); 592 | } 593 | 594 | // Push the new commit onto the Pull Request branch (and also the 595 | // new base commit, if we added that to cmd above). 596 | run_command(&mut cmd) 597 | .await 598 | .reword("git push failed".to_string())?; 599 | 600 | // If the Pull Request's base is not set to the base branch yet, 601 | // change that now. 602 | if pull_request.base.branch_name() != base_branch.branch_name() { 603 | pull_request_updates.base = 604 | Some(base_branch.branch_name().to_string()); 605 | } 606 | } else { 607 | // The Pull Request is against the master branch. In that case we 608 | // only need to push the update to the Pull Request branch. 609 | run_command(&mut cmd) 610 | .await 611 | .reword("git push failed".to_string())?; 612 | } 613 | 614 | if !pull_request_updates.is_empty() { 615 | gh.update_pull_request(pull_request.number, pull_request_updates) 616 | .await?; 617 | } 618 | } else { 619 | // We are creating a new Pull Request. 620 | 621 | // If there's a base branch, add it to the push 622 | if let (Some(base_branch), Some(base_branch_commit)) = 623 | (&base_branch, pr_base_parent) 624 | { 625 | cmd.arg(format!( 626 | "{}:{}", 627 | base_branch_commit, 628 | base_branch.on_github() 629 | )); 630 | } 631 | // Push the pull request branch and the base branch if present 632 | run_command(&mut cmd) 633 | .await 634 | .reword("git push failed".to_string())?; 635 | 636 | // Then call GitHub to create the Pull Request. 637 | let pull_request_number = gh 638 | .create_pull_request( 639 | message, 640 | base_branch 641 | .as_ref() 642 | .unwrap_or(&config.master_ref) 643 | .branch_name() 644 | .to_string(), 645 | pull_request_branch.branch_name().to_string(), 646 | opts.draft, 647 | ) 648 | .await?; 649 | 650 | let pull_request_url = config.pull_request_url(pull_request_number); 651 | 652 | output( 653 | "✨", 654 | &format!( 655 | "Created new Pull Request #{}: {}", 656 | pull_request_number, &pull_request_url, 657 | ), 658 | )?; 659 | 660 | message.insert(MessageSection::PullRequest, pull_request_url); 661 | 662 | let result = gh 663 | .request_reviewers(pull_request_number, requested_reviewers) 664 | .await; 665 | match result { 666 | Ok(()) => (), 667 | Err(error) => { 668 | output("⚠️", "Requesting reviewers failed")?; 669 | for message in error.messages() { 670 | output(" ", message)?; 671 | } 672 | } 673 | } 674 | } 675 | 676 | Ok(()) 677 | } 678 | -------------------------------------------------------------------------------- /spr/src/commands/format.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Radical HQ Limited 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | use crate::{ 9 | error::{Error, Result}, 10 | message::validate_commit_message, 11 | output::{output, write_commit_title}, 12 | }; 13 | 14 | #[derive(Debug, clap::Parser)] 15 | pub struct FormatOptions { 16 | /// format all commits in branch, not just HEAD 17 | #[clap(long, short = 'a')] 18 | all: bool, 19 | } 20 | 21 | pub async fn format( 22 | opts: FormatOptions, 23 | git: &crate::git::Git, 24 | config: &crate::config::Config, 25 | ) -> Result<()> { 26 | let mut pc = git.lock_and_get_prepared_commits(config)?; 27 | 28 | let len = pc.len(); 29 | if len == 0 { 30 | output("👋", "Branch is empty - nothing to do. Good bye!")?; 31 | return Ok(()); 32 | } 33 | 34 | // The slice of prepared commits we want to operate on. 35 | let slice = if opts.all { 36 | &mut pc[..] 37 | } else { 38 | &mut pc[len - 1..] 39 | }; 40 | 41 | let mut failure = false; 42 | 43 | for commit in slice.iter() { 44 | write_commit_title(commit)?; 45 | failure = validate_commit_message(&commit.message, config).is_err() 46 | || failure; 47 | } 48 | git.lock_and_rewrite_commit_messages(slice, None)?; 49 | 50 | if failure { 51 | Err(Error::empty()) 52 | } else { 53 | Ok(()) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /spr/src/commands/init.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Radical HQ Limited 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | use indoc::formatdoc; 9 | use lazy_regex::regex; 10 | 11 | use crate::{ 12 | error::{Error, Result, ResultExt}, 13 | output::output, 14 | }; 15 | 16 | pub async fn init() -> Result<()> { 17 | output("👋", "Welcome to spr!")?; 18 | 19 | let path = std::env::current_dir()?; 20 | let repo = git2::Repository::discover(path.clone()).reword(formatdoc!( 21 | "Could not open a Git repository in {:?}. Please run 'spr' from within \ 22 | a Git repository.", 23 | path 24 | ))?; 25 | let mut config = repo.config()?; 26 | 27 | // GitHub Personal Access Token 28 | 29 | console::Term::stdout().write_line("")?; 30 | 31 | let github_auth_token = config 32 | .get_string("spr.githubAuthToken") 33 | .ok() 34 | .and_then(|value| if value.is_empty() { None } else { Some(value) }); 35 | 36 | output( 37 | "🔑", 38 | &formatdoc!( 39 | "Okay, let's get started. First we need a 'Personal Access Token' \ 40 | from GitHub. This will authorise spr to open/update/merge Pull \ 41 | Requests etc. on behalf of your GitHub user. 42 | You can get one by going to https://github.com/settings/tokens \ 43 | and clicking on 'Generate new token'. The token needs the 'repo', \ 44 | 'user' and 'read:org' permissions, so please tick those three boxes \ 45 | in the 'Select scopes' section. 46 | You might want to set the 'Expiration' to 'No expiration', as \ 47 | otherwise you will have to repeat this procedure soon. Even \ 48 | if the token does not expire, you can always revoke it in case \ 49 | you fear someone got hold of it. 50 | {}", 51 | if github_auth_token.is_some() { 52 | "Actually, you have set up a PAT already. Just press enter to keep that one, or enter a new one!" 53 | } else { 54 | "Please paste in your PAT and press enter. (The input will not be displayed.)" 55 | } 56 | ), 57 | )?; 58 | 59 | let pat = dialoguer::Password::new() 60 | .with_prompt(if github_auth_token.is_some() { 61 | "GitHub PAT (leave empty to keep using existing one)" 62 | } else { 63 | "GitHub Personal Access Token" 64 | }) 65 | .allow_empty_password(github_auth_token.is_some()) 66 | .interact()?; 67 | 68 | let pat = if pat.is_empty() { 69 | github_auth_token.unwrap_or_default() 70 | } else { 71 | pat 72 | }; 73 | 74 | if pat.is_empty() { 75 | return Err(Error::new("Cannot continue without an access token.")); 76 | } 77 | 78 | let octocrab = octocrab::OctocrabBuilder::new() 79 | .personal_token(pat.clone()) 80 | .build()?; 81 | let github_user = octocrab.current().user().await?; 82 | 83 | output("👋", &formatdoc!("Hello {}!", github_user.login))?; 84 | 85 | config.set_str("spr.githubAuthToken", &pat)?; 86 | 87 | // Name of remote 88 | 89 | console::Term::stdout().write_line("")?; 90 | 91 | output( 92 | "❓", 93 | &formatdoc!( 94 | "What's the name of the Git remote pointing to GitHub? Usually it's 95 | 'origin'." 96 | ), 97 | )?; 98 | 99 | let remote = dialoguer::Input::::new() 100 | .with_prompt("Name of remote for GitHub") 101 | .with_initial_text( 102 | config 103 | .get_string("spr.githubRemoteName") 104 | .ok() 105 | .unwrap_or_else(|| "origin".to_string()), 106 | ) 107 | .interact_text()?; 108 | config.set_str("spr.githubRemoteName", &remote)?; 109 | 110 | // Name of the GitHub repo 111 | 112 | console::Term::stdout().write_line("")?; 113 | 114 | output( 115 | "❓", 116 | &formatdoc!( 117 | "What's the name of the GitHub repository. Please enter \ 118 | 'OWNER/REPOSITORY' (basically the bit that follow \ 119 | 'github.com/' in the address.)" 120 | ), 121 | )?; 122 | 123 | let url = repo.find_remote(&remote)?.url().map(String::from); 124 | let regex = 125 | lazy_regex::regex!(r#"github\.com[/:]([\w\-\.]+/[\w\-\.]+?)(.git)?$"#); 126 | let github_repo = config 127 | .get_string("spr.githubRepository") 128 | .ok() 129 | .and_then(|value| if value.is_empty() { None } else { Some(value) }) 130 | .or_else(|| { 131 | url.as_ref() 132 | .and_then(|url| regex.captures(url)) 133 | .and_then(|caps| caps.get(1)) 134 | .map(|m| m.as_str().to_string()) 135 | }) 136 | .unwrap_or_default(); 137 | 138 | let github_repo = dialoguer::Input::::new() 139 | .with_prompt("GitHub repository") 140 | .with_initial_text(github_repo) 141 | .interact_text()?; 142 | config.set_str("spr.githubRepository", &github_repo)?; 143 | 144 | // Master branch name (just query GitHub) 145 | 146 | let github_repo_info = octocrab 147 | .get::( 148 | format!("repos/{}", &github_repo), 149 | None::<&()>, 150 | ) 151 | .await?; 152 | 153 | config.set_str( 154 | "spr.githubMasterBranch", 155 | github_repo_info 156 | .default_branch 157 | .as_ref() 158 | .map(|s| &s[..]) 159 | .unwrap_or("master"), 160 | )?; 161 | 162 | // Pull Request branch prefix 163 | 164 | console::Term::stdout().write_line("")?; 165 | 166 | let branch_prefix = config 167 | .get_string("spr.branchPrefix") 168 | .ok() 169 | .and_then(|value| if value.is_empty() { None } else { Some(value) }) 170 | .unwrap_or_else(|| format!("spr/{}/", &github_user.login)); 171 | 172 | output( 173 | "❓", 174 | &formatdoc!( 175 | "What prefix should be used when naming Pull Request branches? 176 | Good practice is to begin with 'spr/' as a general namespace \ 177 | for spr-managed Pull Request branches. Continuing with the \ 178 | GitHub user name is a good idea, so there is no danger of names \ 179 | clashing with those of other users. 180 | The prefix should end with a good separator character (like '/' \ 181 | or '-'), since commit titles will be appended to this prefix." 182 | ), 183 | )?; 184 | 185 | let branch_prefix = dialoguer::Input::::new() 186 | .with_prompt("Branch prefix") 187 | .with_initial_text(branch_prefix) 188 | .validate_with(|input: &String| -> Result<()> { 189 | validate_branch_prefix(input) 190 | }) 191 | .interact_text()?; 192 | 193 | config.set_str("spr.branchPrefix", &branch_prefix)?; 194 | 195 | Ok(()) 196 | } 197 | 198 | fn validate_branch_prefix(branch_prefix: &str) -> Result<()> { 199 | // They can include slash / for hierarchical (directory) grouping, but no slash-separated component can begin with a dot . or end with the sequence .lock. 200 | if branch_prefix.contains("/.") 201 | || branch_prefix.contains(".lock/") 202 | || branch_prefix.ends_with(".lock") 203 | || branch_prefix.starts_with('.') 204 | { 205 | return Err(Error::new("Branch prefix cannot have slash-separated component beginning with a dot . or ending with the sequence .lock")); 206 | } 207 | 208 | if branch_prefix.contains("..") { 209 | return Err(Error::new( 210 | "Branch prefix cannot contain two consecutive dots anywhere.", 211 | )); 212 | } 213 | 214 | if branch_prefix.chars().any(|c| c.is_ascii_control()) { 215 | return Err(Error::new( 216 | "Branch prefix cannot contain ASCII control sequence", 217 | )); 218 | } 219 | 220 | let forbidden_chars_re = regex!(r"[ \~\^:?*\[\\]"); 221 | if forbidden_chars_re.is_match(branch_prefix) { 222 | return Err(Error::new( 223 | "Branch prefix contains one or more forbidden characters.", 224 | )); 225 | } 226 | 227 | if branch_prefix.contains("//") || branch_prefix.starts_with('/') { 228 | return Err(Error::new("Branch prefix contains multiple consecutive slashes or starts with slash.")); 229 | } 230 | 231 | if branch_prefix.contains("@{") { 232 | return Err(Error::new("Branch prefix cannot contain the sequence @{")); 233 | } 234 | 235 | Ok(()) 236 | } 237 | 238 | #[cfg(test)] 239 | mod tests { 240 | use super::validate_branch_prefix; 241 | 242 | #[test] 243 | fn test_branch_prefix_rules() { 244 | // Rules taken from https://git-scm.com/docs/git-check-ref-format 245 | // Note: Some rules don't need to be checked because the prefix is 246 | // always embedded into a larger context. For example, rule 9 in the 247 | // reference states that a _refname_ cannot be the single character @. 248 | // This rule is impossible to break purely via the branch prefix. 249 | let bad_prefixes: Vec<(&str, &str)> = vec![ 250 | ( 251 | "spr/.bad", 252 | "Cannot start slash-separated component with dot", 253 | ), 254 | (".bad", "Cannot start slash-separated component with dot"), 255 | ("spr/bad.lock", "Cannot end with .lock"), 256 | ( 257 | "spr/bad.lock/some_more", 258 | "Cannot end slash-separated component with .lock", 259 | ), 260 | ( 261 | "spr/b..ad/bla", 262 | "They cannot contain two consecutive dots anywhere", 263 | ), 264 | ("spr/bad//bla", "They cannot contain consecutive slashes"), 265 | ("/bad", "Prefix should not start with slash"), 266 | ("/bad@{stuff", "Prefix cannot contain sequence @{"), 267 | ]; 268 | 269 | for (branch_prefix, reason) in bad_prefixes { 270 | assert!( 271 | validate_branch_prefix(branch_prefix).is_err(), 272 | "{}", 273 | reason 274 | ); 275 | } 276 | 277 | let ok_prefix = "spr/some.lockprefix/with-stuff/foo"; 278 | assert!(validate_branch_prefix(ok_prefix).is_ok()); 279 | } 280 | 281 | #[test] 282 | fn test_branch_prefix_rejects_forbidden_characters() { 283 | // Here I'm mostly concerned about escaping / not escaping in the regex :p 284 | assert!(validate_branch_prefix("bad\x1F").is_err()); 285 | assert!(validate_branch_prefix("notbad!").is_ok()); 286 | assert!( 287 | validate_branch_prefix("bad /space").is_err(), 288 | "Reject space in prefix" 289 | ); 290 | assert!(validate_branch_prefix("bad~").is_err(), "Reject tilde"); 291 | assert!(validate_branch_prefix("bad^").is_err(), "Reject caret"); 292 | assert!(validate_branch_prefix("bad:").is_err(), "Reject colon"); 293 | assert!(validate_branch_prefix("bad?").is_err(), "Reject ?"); 294 | assert!(validate_branch_prefix("bad*").is_err(), "Reject *"); 295 | assert!(validate_branch_prefix("bad[").is_err(), "Reject ["); 296 | assert!(validate_branch_prefix(r"bad\").is_err(), "Reject \\"); 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /spr/src/commands/land.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Radical HQ Limited 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | use indoc::formatdoc; 9 | use std::{io::Write, process::Stdio, time::Duration}; 10 | 11 | use crate::{ 12 | error::{Error, Result, ResultExt}, 13 | github::{PullRequestState, PullRequestUpdate, ReviewStatus}, 14 | message::build_github_body_for_merging, 15 | output::{output, write_commit_title}, 16 | utils::run_command, 17 | }; 18 | 19 | #[derive(Debug, clap::Parser)] 20 | pub struct LandOptions { 21 | /// Merge a Pull Request that was created or updated with spr diff 22 | /// --cherry-pick 23 | #[clap(long)] 24 | cherry_pick: bool, 25 | } 26 | 27 | pub async fn land( 28 | opts: LandOptions, 29 | git: &crate::git::Git, 30 | gh: &mut crate::github::GitHub, 31 | config: &crate::config::Config, 32 | ) -> Result<()> { 33 | git.lock_and_check_no_uncommitted_changes()?; 34 | let mut prepared_commits = git.lock_and_get_prepared_commits(config)?; 35 | 36 | let based_on_unlanded_commits = prepared_commits.len() > 1; 37 | 38 | if based_on_unlanded_commits && !opts.cherry_pick { 39 | return Err(Error::new(formatdoc!( 40 | "Cannot land a commit whose parent is not on {master}. To land \ 41 | this commit, rebase it so that it is a direct child of {master}. 42 | Alternatively, if you used the `--cherry-pick` option with `spr \ 43 | diff`, then you can pass it to `spr land`, too.", 44 | master = &config.master_ref.branch_name(), 45 | ))); 46 | } 47 | 48 | let prepared_commit = match prepared_commits.last_mut() { 49 | Some(c) => c, 50 | None => { 51 | output("👋", "Branch is empty - nothing to do. Good bye!")?; 52 | return Ok(()); 53 | } 54 | }; 55 | 56 | write_commit_title(prepared_commit)?; 57 | 58 | let pull_request_number = 59 | if let Some(number) = prepared_commit.pull_request_number { 60 | output("#️⃣ ", &format!("Pull Request #{}", number))?; 61 | number 62 | } else { 63 | return Err(Error::new( 64 | "This commit does not refer to a Pull Request.", 65 | )); 66 | }; 67 | 68 | // Load Pull Request information 69 | let pull_request = gh.clone().get_pull_request(pull_request_number).await?; 70 | 71 | if pull_request.state != PullRequestState::Open { 72 | return Err(Error::new(formatdoc!( 73 | "This Pull Request is already closed!", 74 | ))); 75 | } 76 | 77 | if config.require_approval 78 | && pull_request.review_status != Some(ReviewStatus::Approved) 79 | { 80 | return Err(Error::new( 81 | "This Pull Request has not been approved on GitHub.", 82 | )); 83 | } 84 | 85 | output("🛫", "Getting started...")?; 86 | 87 | // Fetch current master from GitHub. 88 | run_command( 89 | tokio::process::Command::new("git") 90 | .arg("fetch") 91 | .arg("--no-write-fetch-head") 92 | .arg("--") 93 | .arg(&config.remote_name) 94 | .arg(config.master_ref.on_github()), 95 | ) 96 | .await 97 | .reword("git fetch failed".to_string())?; 98 | 99 | let current_master = 100 | git.lock_and_resolve_reference(config.master_ref.local())?; 101 | let base_is_master = pull_request.base.is_master_branch(); 102 | let index = git.lock_and_cherrypick(prepared_commit.oid, current_master)?; 103 | 104 | if index.has_conflicts() { 105 | return Err(Error::new(formatdoc!( 106 | "This commit cannot be applied on top of the '{master}' branch. 107 | Please rebase this commit on top of current \ 108 | '{remote}/{master}'.{unlanded}", 109 | master = &config.master_ref.branch_name(), 110 | remote = &config.remote_name, 111 | unlanded = if based_on_unlanded_commits { 112 | " You may also have to land commits that this commit depends on first." 113 | } else { 114 | "" 115 | }, 116 | ))); 117 | } 118 | 119 | // This is the tree we are getting from cherrypicking the local commit 120 | // on the selected base (master or stacked-on Pull Request). 121 | let our_tree_oid = git.lock_and_write_index(index)?; 122 | 123 | // Now let's predict what merging the PR into the master branch would 124 | // produce. 125 | let merge_index = { 126 | let repo = git.lock_repo(); 127 | let current_master = repo.find_commit(current_master)?; 128 | let pr_head = repo.find_commit(pull_request.head_oid)?; 129 | repo.merge_commits(¤t_master, &pr_head) 130 | }?; 131 | 132 | let merge_matches_cherrypick = if merge_index.has_conflicts() { 133 | false 134 | } else { 135 | let merge_tree_oid = git.lock_and_write_index(merge_index)?; 136 | merge_tree_oid == our_tree_oid 137 | }; 138 | 139 | if !merge_matches_cherrypick { 140 | return Err(Error::new(formatdoc!( 141 | "This commit has been updated and/or rebased since the pull \ 142 | request was last updated. Please run `spr diff` to update the \ 143 | pull request and then try `spr land` again!" 144 | ))); 145 | } 146 | 147 | // Okay, we are confident now that the PR can be merged and the result of 148 | // that merge would be a master commit with the same tree as if we 149 | // cherry-picked the commit onto master. 150 | let mut pr_head_oid = pull_request.head_oid; 151 | 152 | if !base_is_master { 153 | // The base of the Pull Request on GitHub is not set to master. This 154 | // means the Pull Request uses a base branch. We tested above that 155 | // merging the Pull Request branch into the master branch produces the 156 | // intended result (the same as cherry-picking the local commit onto 157 | // master), so what we want to do is actually merge the Pull Request as 158 | // it is into master. Hence, we change the base to the master branch. 159 | // 160 | // Before we do that, there is one more edge case to look out for: if 161 | // the base branch contains changes that have since been landed on 162 | // master, then Git might be able to figure out that these changes 163 | // appear both in the pull request branch (via the merge branch) and in 164 | // master, but are identical in those two so it is not a merge conflict 165 | // but can go ahead. The result of this in master if we merge now is 166 | // correct, but there is one problem: when looking at the Pull Request 167 | // in GitHub after merging, it will show these change as part of the 168 | // Pull Request. So when you look at the changed files of the Pull 169 | // Request, you will see both changes in this commit (great!) and those 170 | // in the base branch (a previous commit that has already been landed on 171 | // master - not great!). This is because the changes shown are the ones 172 | // that happened on this Pull Request branch (now including the base 173 | // branch) since it branched off master. This can include changes in the 174 | // base branch that are already on master, but were added to master 175 | // after the Pull Request branch branched from master. 176 | // The solution is to merge current master into the Pull Request branch. 177 | // Doing that now means that the final changes done by this Pull Request 178 | // are only the changes that are not yet in master. That's what we want. 179 | // This final merge never introduces any changes to the Pull Request. In 180 | // fact, the tree that we use for the merge commit is the one we got 181 | // above from the cherry-picking of this commit on master. 182 | 183 | // The commit on the base branch that the PR branch is currently based on 184 | let pr_base_oid = git 185 | .lock_repo() 186 | .merge_base(pr_head_oid, pull_request.base_oid)?; 187 | let pr_base_tree = git.lock_and_get_tree_oid_for_commit(pr_base_oid)?; 188 | 189 | let pr_master_base = 190 | git.lock_repo().merge_base(pr_base_oid, current_master)?; 191 | let pr_master_base_tree = 192 | git.lock_and_get_tree_oid_for_commit(pr_master_base)?; 193 | 194 | if pr_base_tree != pr_master_base_tree { 195 | // So the current file contents of the base branch are not the same 196 | // as those of the master branch commit that the base branch is 197 | // based on. In other words, the base branch is currently not 198 | // "empty". Or, the base branch has changes in them. These changes 199 | // must all have been landed on master in the meantime (after this 200 | // base branch was branched off) or otherwise we would have aborted 201 | // this whole operation further above. But in order not to show them 202 | // as part of this Pull Request after landing, we have to make clear 203 | // those are changes in master, not in this Pull Request. 204 | // Here comes the additional merge-in-master commit on the Pull 205 | // Request branch that achieves that! 206 | 207 | pr_head_oid = git.lock_and_create_derived_commit( 208 | pr_head_oid, 209 | &format!( 210 | "[spr] landed version\n\nCreated using spr {}", 211 | env!("CARGO_PKG_VERSION"), 212 | ), 213 | our_tree_oid, 214 | &[pr_head_oid, current_master], 215 | )?; 216 | 217 | let mut cmd = tokio::process::Command::new("git"); 218 | cmd.arg("push") 219 | .arg("--atomic") 220 | .arg("--no-verify") 221 | .arg("--") 222 | .arg(&config.remote_name) 223 | .arg(format!( 224 | "{}:{}", 225 | pr_head_oid, 226 | pull_request.head.on_github() 227 | )); 228 | run_command(&mut cmd) 229 | .await 230 | .reword("git push failed".to_string())?; 231 | } 232 | 233 | gh.update_pull_request( 234 | pull_request_number, 235 | PullRequestUpdate { 236 | base: Some(config.master_ref.branch_name().to_string()), 237 | ..Default::default() 238 | }, 239 | ) 240 | .await?; 241 | } 242 | 243 | // Check whether GitHub says this PR is mergeable. This happens in a 244 | // retry-loop because recent changes to the Pull Request can mean that 245 | // GitHub has not finished the mergeability check yet. 246 | let mut attempts = 0; 247 | let result = loop { 248 | attempts += 1; 249 | 250 | let mergeability = gh 251 | .get_pull_request_mergeability(pull_request_number) 252 | .await?; 253 | 254 | if mergeability.head_oid != pr_head_oid { 255 | break Err(Error::new(formatdoc!( 256 | "The Pull Request seems to have been updated externally. 257 | Please try again!" 258 | ))); 259 | } 260 | 261 | if mergeability.base.is_master_branch() 262 | && mergeability.mergeable.is_some() 263 | { 264 | if mergeability.mergeable != Some(true) { 265 | break Err(Error::new(formatdoc!( 266 | "GitHub concluded the Pull Request is not mergeable at \ 267 | this point. Please rebase your changes and try again!" 268 | ))); 269 | } 270 | 271 | if let Some(merge_commit) = mergeability.merge_commit { 272 | git.lock_and_fetch_commits_from_remote( 273 | &[merge_commit], 274 | &config.remote_name, 275 | ) 276 | .await?; 277 | 278 | if git.lock_and_get_tree_oid_for_commit(merge_commit)? 279 | != our_tree_oid 280 | { 281 | return Err(Error::new(formatdoc!( 282 | "This commit has been updated and/or rebased since the pull 283 | request was last updated. Please run `spr diff` to update the pull 284 | request and then try `spr land` again!" 285 | ))); 286 | } 287 | }; 288 | 289 | break Ok(()); 290 | } 291 | 292 | if attempts >= 10 { 293 | // After ten failed attempts we give up. 294 | break Err(Error::new( 295 | "GitHub Pull Request did not update. Please try again!", 296 | )); 297 | } 298 | 299 | // Wait one second before retrying 300 | tokio::time::sleep(Duration::from_secs(1)).await; 301 | }; 302 | 303 | let result = match result { 304 | Ok(()) => { 305 | // We have checked that merging the Pull Request branch into the master 306 | // branch produces the intended result, and that's independent of whether we 307 | // used a base branch with this Pull Request or not. We have made sure the 308 | // target of the Pull Request is set to the master branch. So let GitHub do 309 | // the merge now! 310 | octocrab::instance() 311 | .pulls(&config.owner, &config.repo) 312 | .merge(pull_request_number) 313 | .method(octocrab::params::pulls::MergeMethod::Squash) 314 | .title(pull_request.title) 315 | .message(build_github_body_for_merging(&pull_request.sections)) 316 | .sha(format!("{}", pr_head_oid)) 317 | .send() 318 | .await 319 | .convert() 320 | .and_then(|merge| { 321 | if merge.merged { 322 | Ok(merge) 323 | } else { 324 | Err(Error::new(formatdoc!( 325 | "GitHub Pull Request merge failed: {}", 326 | merge.message.unwrap_or_default() 327 | ))) 328 | } 329 | }) 330 | } 331 | Err(err) => Err(err), 332 | }; 333 | 334 | let merge = match result { 335 | Ok(merge) => merge, 336 | Err(mut error) => { 337 | output("❌", "GitHub Pull Request merge failed")?; 338 | 339 | // If we changed the target branch of the Pull Request earlier, then 340 | // undo this change now. 341 | if !base_is_master { 342 | let result = gh 343 | .update_pull_request( 344 | pull_request_number, 345 | PullRequestUpdate { 346 | base: Some( 347 | pull_request.base.on_github().to_string(), 348 | ), 349 | ..Default::default() 350 | }, 351 | ) 352 | .await; 353 | if let Err(e) = result { 354 | error.push(format!("{}", e)); 355 | } 356 | } 357 | 358 | return Err(error); 359 | } 360 | }; 361 | 362 | output("🛬", "Landed!")?; 363 | 364 | let mut remove_old_branch_child_process = 365 | tokio::process::Command::new("git") 366 | .arg("push") 367 | .arg("--no-verify") 368 | .arg("--delete") 369 | .arg("--") 370 | .arg(&config.remote_name) 371 | .arg(pull_request.head.on_github()) 372 | .stdout(Stdio::null()) 373 | .stderr(Stdio::null()) 374 | .spawn()?; 375 | 376 | let remove_old_base_branch_child_process = if base_is_master { 377 | None 378 | } else { 379 | Some( 380 | tokio::process::Command::new("git") 381 | .arg("push") 382 | .arg("--no-verify") 383 | .arg("--delete") 384 | .arg("--") 385 | .arg(&config.remote_name) 386 | .arg(pull_request.base.on_github()) 387 | .stdout(Stdio::null()) 388 | .stderr(Stdio::null()) 389 | .spawn()?, 390 | ) 391 | }; 392 | 393 | // Rebase us on top of the now-landed commit 394 | if let Some(sha) = merge.sha { 395 | // Try this up to three times, because fetching the very moment after 396 | // the merge might still not find the new commit. 397 | for i in 0..3 { 398 | // Fetch current master and the merge commit from GitHub. 399 | let git_fetch = tokio::process::Command::new("git") 400 | .arg("fetch") 401 | .arg("--no-write-fetch-head") 402 | .arg("--") 403 | .arg(&config.remote_name) 404 | .arg(config.master_ref.on_github()) 405 | .arg(&sha) 406 | .stdout(Stdio::null()) 407 | .stderr(Stdio::piped()) 408 | .output() 409 | .await?; 410 | if git_fetch.status.success() { 411 | break; 412 | } else if i == 2 { 413 | console::Term::stderr().write_all(&git_fetch.stderr)?; 414 | return Err(Error::new("git fetch failed")); 415 | } 416 | } 417 | git.lock_and_rebase_commits( 418 | &mut prepared_commits[..], 419 | git2::Oid::from_str(&sha)?, 420 | ) 421 | .context( 422 | "The automatic rebase failed - please rebase manually!".to_string(), 423 | )?; 424 | } 425 | 426 | // Wait for the "git push" to delete the old Pull Request branch to finish, 427 | // but ignore the result. GitHub may be configured to delete the branch 428 | // automatically, in which case it's gone already and this command fails. 429 | remove_old_branch_child_process.wait().await?; 430 | if let Some(mut proc) = remove_old_base_branch_child_process { 431 | proc.wait().await?; 432 | } 433 | 434 | Ok(()) 435 | } 436 | -------------------------------------------------------------------------------- /spr/src/commands/list.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Radical HQ Limited 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | use crate::error::Error; 9 | use crate::error::Result; 10 | use graphql_client::{GraphQLQuery, Response}; 11 | use reqwest; 12 | 13 | #[allow(clippy::upper_case_acronyms)] 14 | type URI = String; 15 | #[derive(GraphQLQuery)] 16 | #[graphql( 17 | schema_path = "src/gql/schema.docs.graphql", 18 | query_path = "src/gql/open_reviews.graphql", 19 | response_derives = "Debug" 20 | )] 21 | pub struct SearchQuery; 22 | 23 | pub async fn list( 24 | graphql_client: reqwest::Client, 25 | config: &crate::config::Config, 26 | ) -> Result<()> { 27 | let variables = search_query::Variables { 28 | query: format!( 29 | "repo:{}/{} is:open is:pr author:@me archived:false", 30 | config.owner, config.repo 31 | ), 32 | }; 33 | let request_body = SearchQuery::build_query(variables); 34 | let res = graphql_client 35 | .post("https://api.github.com/graphql") 36 | .json(&request_body) 37 | .send() 38 | .await?; 39 | let response_body: Response = 40 | res.json().await?; 41 | 42 | print_pr_info(response_body).ok_or_else(|| Error::new("unexpected error")) 43 | } 44 | 45 | fn print_pr_info( 46 | response_body: Response, 47 | ) -> Option<()> { 48 | let term = console::Term::stdout(); 49 | for pr in response_body.data?.search.nodes? { 50 | let pr = match pr { 51 | Some(crate::commands::list::search_query::SearchQuerySearchNodes::PullRequest(pr)) => pr, 52 | _ => continue, 53 | }; 54 | let dummy: String; 55 | let decision = match pr.review_decision { 56 | Some(search_query::PullRequestReviewDecision::APPROVED) => { 57 | console::style("Accepted").green() 58 | } 59 | Some( 60 | search_query::PullRequestReviewDecision::CHANGES_REQUESTED, 61 | ) => console::style("Changes Requested").red(), 62 | None 63 | | Some(search_query::PullRequestReviewDecision::REVIEW_REQUIRED) => { 64 | console::style("Pending") 65 | } 66 | Some(search_query::PullRequestReviewDecision::Other(d)) => { 67 | dummy = d; 68 | console::style(dummy.as_str()) 69 | } 70 | }; 71 | term.write_line(&format!( 72 | "{} {} {}", 73 | decision, 74 | console::style(&pr.title).bold(), 75 | console::style(&pr.url).dim(), 76 | )) 77 | .ok()?; 78 | } 79 | Some(()) 80 | } 81 | -------------------------------------------------------------------------------- /spr/src/commands/mod.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Radical HQ Limited 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | pub mod amend; 9 | pub mod close; 10 | pub mod diff; 11 | pub mod format; 12 | pub mod init; 13 | pub mod land; 14 | pub mod list; 15 | pub mod patch; 16 | -------------------------------------------------------------------------------- /spr/src/commands/patch.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Radical HQ Limited 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | use crate::{ 9 | error::Result, 10 | message::{build_commit_message, MessageSection}, 11 | output::output, 12 | }; 13 | 14 | #[derive(Debug, clap::Parser)] 15 | pub struct PatchOptions { 16 | /// Pull Request number 17 | pull_request: u64, 18 | 19 | /// Name of the branch to be created. Defaults to `PR-` 20 | #[clap(long)] 21 | branch_name: Option, 22 | 23 | /// If given, create new branch but do not check out 24 | #[clap(long)] 25 | no_checkout: bool, 26 | } 27 | 28 | pub async fn patch( 29 | opts: PatchOptions, 30 | git: &crate::git::Git, 31 | gh: &mut crate::github::GitHub, 32 | config: &crate::config::Config, 33 | ) -> Result<()> { 34 | let pr = gh.clone().get_pull_request(opts.pull_request).await?; 35 | output( 36 | "#️⃣ ", 37 | &format!( 38 | "Pull Request #{}: {}", 39 | pr.number, 40 | pr.sections 41 | .get(&MessageSection::Title) 42 | .map(|s| &s[..]) 43 | .unwrap_or("(no title)") 44 | ), 45 | )?; 46 | 47 | let branch_name = if let Some(name) = opts.branch_name { 48 | name 49 | } else { 50 | git.lock_and_get_pr_patch_branch_name(pr.number)? 51 | }; 52 | 53 | let patch_branch_oid = if let Some(oid) = pr.merge_commit { 54 | output("❗", "Pull Request has been merged")?; 55 | 56 | oid 57 | } else { 58 | // Current oid of the master branch 59 | let current_master_oid = 60 | git.lock_and_resolve_reference(config.master_ref.local())?; 61 | 62 | // The parent commit to base the new PR branch on shall be the master 63 | // commit this PR is based on 64 | let mut pr_master_oid = git 65 | .lock_repo() 66 | .merge_base(pr.head_oid, current_master_oid)?; 67 | 68 | // The PR may be against master or some base branch. `pr.base_oid` 69 | // indicates what the PR base is, but might point to the latest commit 70 | // of the target (i.e. base) branch, and especially if the target branch 71 | // is master, might be different from the commit the PR is actually 72 | // based on. But the merge base of the given `pr.base_oid` and the PR 73 | // head is the right commit. 74 | let pr_base_oid = 75 | git.lock_repo().merge_base(pr.head_oid, pr.base_oid)?; 76 | 77 | if pr_base_oid != pr_master_oid { 78 | // So the commit the PR is based on is not the same as the master 79 | // commit it's based on. This means there must be a base branch that 80 | // contains additional commits. We want to squash those changes into 81 | // one commit that we then title "Base of Pull Reqeust #x". 82 | // Oh, one more thing. The base commit might not be on master, but 83 | // if it, for whatever reason, contains the same tree as the master 84 | // base, the base commit we construct here would turn out to be 85 | // empty. No point in creating an empty commit, so let's first check 86 | // whether base tree and master tree are different. 87 | let pr_base_tree = 88 | git.lock_and_get_tree_oid_for_commit(pr.base_oid)?; 89 | let master_tree = 90 | git.lock_and_get_tree_oid_for_commit(pr_master_oid)?; 91 | 92 | if pr_base_tree != master_tree { 93 | // The base of this PR is not on master. We need to create two 94 | // commits on the new branch we are making. First, a commit that 95 | // represents the base of the PR. And then second, the commit 96 | // that represents the contents of the PR. 97 | 98 | pr_master_oid = git.lock_and_create_derived_commit( 99 | pr_base_oid, 100 | &format!("[spr] Base of Pull Request #{}", pr.number), 101 | pr_base_tree, 102 | &[pr_master_oid], 103 | )?; 104 | } 105 | } 106 | 107 | // Create the main commit for the patch branch. This is based on a 108 | // master commit, or, if the PR can't be based on master directly, on 109 | // the commit we created above to prepare the base of this commit. 110 | git.lock_and_create_derived_commit( 111 | pr.head_oid, 112 | &build_commit_message(&pr.sections), 113 | git.lock_and_get_tree_oid_for_commit(pr.head_oid)?, 114 | &[pr_master_oid], 115 | )? 116 | }; 117 | 118 | let repo = git.lock_repo(); 119 | let patch_branch_commit = repo.find_commit(patch_branch_oid)?; 120 | 121 | // Create the new branch, now that we know the commit it shall point to 122 | repo.force_branch(&branch_name, &patch_branch_commit)?; 123 | 124 | output("🌱", &format!("Created new branch: {}", &branch_name))?; 125 | 126 | if !opts.no_checkout { 127 | // Check out the new branch 128 | repo.checkout_tree(patch_branch_commit.as_object())?; 129 | repo.set_head(&format!("refs/heads/{}", branch_name))?; 130 | output("✅", "Checked out")?; 131 | } 132 | 133 | Ok(()) 134 | } 135 | -------------------------------------------------------------------------------- /spr/src/config.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Radical HQ Limited 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | use std::collections::HashSet; 9 | 10 | use crate::{error::Result, github::GitHubBranch, utils::slugify}; 11 | 12 | #[derive(Clone, Debug)] 13 | pub struct Config { 14 | pub owner: String, 15 | pub repo: String, 16 | pub remote_name: String, 17 | pub master_ref: GitHubBranch, 18 | pub branch_prefix: String, 19 | pub require_approval: bool, 20 | pub require_test_plan: bool, 21 | } 22 | 23 | impl Config { 24 | pub fn new( 25 | owner: String, 26 | repo: String, 27 | remote_name: String, 28 | master_branch: String, 29 | branch_prefix: String, 30 | require_approval: bool, 31 | require_test_plan: bool, 32 | ) -> Self { 33 | let master_ref = GitHubBranch::new_from_branch_name( 34 | &master_branch, 35 | &remote_name, 36 | &master_branch, 37 | ); 38 | Self { 39 | owner, 40 | repo, 41 | remote_name, 42 | master_ref, 43 | branch_prefix, 44 | require_approval, 45 | require_test_plan, 46 | } 47 | } 48 | 49 | pub fn pull_request_url(&self, number: u64) -> String { 50 | format!( 51 | "https://github.com/{owner}/{repo}/pull/{number}", 52 | owner = &self.owner, 53 | repo = &self.repo 54 | ) 55 | } 56 | 57 | pub fn parse_pull_request_field(&self, text: &str) -> Option { 58 | if text.is_empty() { 59 | return None; 60 | } 61 | 62 | let regex = lazy_regex::regex!(r#"^\s*#?\s*(\d+)\s*$"#); 63 | let m = regex.captures(text); 64 | if let Some(caps) = m { 65 | return Some(caps.get(1).unwrap().as_str().parse().unwrap()); 66 | } 67 | 68 | let regex = lazy_regex::regex!( 69 | r#"^\s*https?://github.com/([\w\-\.]+)/([\w\-\.]+)/pull/(\d+)([/?#].*)?\s*$"# 70 | ); 71 | let m = regex.captures(text); 72 | if let Some(caps) = m { 73 | if self.owner == caps.get(1).unwrap().as_str() 74 | && self.repo == caps.get(2).unwrap().as_str() 75 | { 76 | return Some(caps.get(3).unwrap().as_str().parse().unwrap()); 77 | } 78 | } 79 | 80 | None 81 | } 82 | 83 | pub fn get_new_branch_name( 84 | &self, 85 | existing_ref_names: &HashSet, 86 | title: &str, 87 | ) -> String { 88 | self.find_unused_branch_name(existing_ref_names, &slugify(title)) 89 | } 90 | 91 | pub fn get_base_branch_name( 92 | &self, 93 | existing_ref_names: &HashSet, 94 | title: &str, 95 | ) -> String { 96 | self.find_unused_branch_name( 97 | existing_ref_names, 98 | &format!("{}.{}", self.master_ref.branch_name(), &slugify(title)), 99 | ) 100 | } 101 | 102 | fn find_unused_branch_name( 103 | &self, 104 | existing_ref_names: &HashSet, 105 | slug: &str, 106 | ) -> String { 107 | let remote_name = &self.remote_name; 108 | let branch_prefix = &self.branch_prefix; 109 | let mut branch_name = format!("{branch_prefix}{slug}"); 110 | let mut suffix = 0; 111 | 112 | loop { 113 | let remote_ref = 114 | format!("refs/remotes/{remote_name}/{branch_name}"); 115 | 116 | if !existing_ref_names.contains(&remote_ref) { 117 | return branch_name; 118 | } 119 | 120 | suffix += 1; 121 | branch_name = format!("{branch_prefix}{slug}-{suffix}"); 122 | } 123 | } 124 | 125 | pub fn new_github_branch_from_ref( 126 | &self, 127 | ghref: &str, 128 | ) -> Result { 129 | GitHubBranch::new_from_ref( 130 | ghref, 131 | &self.remote_name, 132 | self.master_ref.branch_name(), 133 | ) 134 | } 135 | 136 | pub fn new_github_branch(&self, branch_name: &str) -> GitHubBranch { 137 | GitHubBranch::new_from_branch_name( 138 | branch_name, 139 | &self.remote_name, 140 | self.master_ref.branch_name(), 141 | ) 142 | } 143 | } 144 | 145 | #[cfg(test)] 146 | mod tests { 147 | // Note this useful idiom: importing names from outer (for mod tests) scope. 148 | use super::*; 149 | 150 | fn config_factory() -> Config { 151 | crate::config::Config::new( 152 | "acme".into(), 153 | "codez".into(), 154 | "origin".into(), 155 | "master".into(), 156 | "spr/foo/".into(), 157 | false, 158 | true, 159 | ) 160 | } 161 | 162 | #[test] 163 | fn test_pull_request_url() { 164 | let gh = config_factory(); 165 | 166 | assert_eq!( 167 | &gh.pull_request_url(123), 168 | "https://github.com/acme/codez/pull/123" 169 | ); 170 | } 171 | 172 | #[test] 173 | fn test_parse_pull_request_field_empty() { 174 | let gh = config_factory(); 175 | 176 | assert_eq!(gh.parse_pull_request_field(""), None); 177 | assert_eq!(gh.parse_pull_request_field(" "), None); 178 | assert_eq!(gh.parse_pull_request_field("\n"), None); 179 | } 180 | 181 | #[test] 182 | fn test_parse_pull_request_field_number() { 183 | let gh = config_factory(); 184 | 185 | assert_eq!(gh.parse_pull_request_field("123"), Some(123)); 186 | assert_eq!(gh.parse_pull_request_field(" 123 "), Some(123)); 187 | assert_eq!(gh.parse_pull_request_field("#123"), Some(123)); 188 | assert_eq!(gh.parse_pull_request_field(" # 123"), Some(123)); 189 | } 190 | 191 | #[test] 192 | fn test_parse_pull_request_field_url() { 193 | let gh = config_factory(); 194 | 195 | assert_eq!( 196 | gh.parse_pull_request_field( 197 | "https://github.com/acme/codez/pull/123" 198 | ), 199 | Some(123) 200 | ); 201 | assert_eq!( 202 | gh.parse_pull_request_field( 203 | " https://github.com/acme/codez/pull/123 " 204 | ), 205 | Some(123) 206 | ); 207 | assert_eq!( 208 | gh.parse_pull_request_field( 209 | "https://github.com/acme/codez/pull/123/" 210 | ), 211 | Some(123) 212 | ); 213 | assert_eq!( 214 | gh.parse_pull_request_field( 215 | "https://github.com/acme/codez/pull/123?x=a" 216 | ), 217 | Some(123) 218 | ); 219 | assert_eq!( 220 | gh.parse_pull_request_field( 221 | "https://github.com/acme/codez/pull/123/foo" 222 | ), 223 | Some(123) 224 | ); 225 | assert_eq!( 226 | gh.parse_pull_request_field( 227 | "https://github.com/acme/codez/pull/123#abc" 228 | ), 229 | Some(123) 230 | ); 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /spr/src/error.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Radical HQ Limited 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | #[derive(Clone, Debug)] 9 | pub struct Error { 10 | messages: Vec, 11 | } 12 | 13 | pub type Result = std::result::Result; 14 | 15 | impl Error { 16 | pub fn new(message: S) -> Self 17 | where 18 | S: Into, 19 | { 20 | Self { 21 | messages: vec![message.into()], 22 | } 23 | } 24 | 25 | pub fn empty() -> Self { 26 | Self { 27 | messages: Default::default(), 28 | } 29 | } 30 | 31 | pub fn is_empty(&self) -> bool { 32 | self.messages.is_empty() 33 | } 34 | 35 | pub fn messages(&self) -> &Vec { 36 | &self.messages 37 | } 38 | 39 | pub fn push(&mut self, message: String) { 40 | self.messages.push(message); 41 | } 42 | } 43 | 44 | impl From for Error 45 | where 46 | E: std::error::Error, 47 | { 48 | fn from(error: E) -> Self { 49 | Self { 50 | messages: vec![format!("{}", error)], 51 | } 52 | } 53 | } 54 | 55 | impl std::fmt::Display for Error { 56 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 57 | let message = self.messages.last(); 58 | if let Some(message) = message { 59 | write!(f, "{}", message) 60 | } else { 61 | write!(f, "unknown error") 62 | } 63 | } 64 | } 65 | 66 | pub trait ResultExt { 67 | type Output; 68 | 69 | fn convert(self) -> Self::Output; 70 | fn context(self, message: String) -> Self::Output; 71 | fn reword(self, message: String) -> Self::Output; 72 | } 73 | impl ResultExt for Result { 74 | type Output = Self; 75 | 76 | fn convert(self) -> Self { 77 | self 78 | } 79 | 80 | fn context(mut self, message: String) -> Self { 81 | if let Err(error) = &mut self { 82 | error.push(message); 83 | } 84 | 85 | self 86 | } 87 | 88 | fn reword(mut self, message: String) -> Self { 89 | if let Err(error) = &mut self { 90 | error.messages.pop(); 91 | error.push(message); 92 | } 93 | 94 | self 95 | } 96 | } 97 | 98 | impl ResultExt for std::result::Result 99 | where 100 | E: std::error::Error, 101 | { 102 | type Output = Result; 103 | 104 | fn convert(self) -> Result { 105 | match self { 106 | Ok(v) => Ok(v), 107 | Err(error) => Err(error.into()), 108 | } 109 | } 110 | 111 | fn context(self, message: String) -> Result { 112 | self.convert().context(message) 113 | } 114 | 115 | fn reword(self, message: String) -> Result { 116 | self.convert().reword(message) 117 | } 118 | } 119 | 120 | pub struct Terminator { 121 | error: Error, 122 | } 123 | 124 | impl From for Terminator { 125 | fn from(error: Error) -> Self { 126 | Self { error } 127 | } 128 | } 129 | 130 | impl std::fmt::Debug for Terminator { 131 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 132 | write!(f, "🛑 ")?; 133 | for message in self.error.messages.iter().rev() { 134 | writeln!(f, "{}", message)?; 135 | } 136 | Ok(()) 137 | } 138 | } 139 | 140 | impl From for Terminator 141 | where 142 | E: std::error::Error, 143 | { 144 | fn from(error: E) -> Self { 145 | Self { 146 | error: error.into(), 147 | } 148 | } 149 | } 150 | 151 | pub fn add_error(result: &mut Result, other: Result) -> Option { 152 | match other { 153 | Ok(result) => Some(result), 154 | Err(error) => { 155 | if let Err(e) = result { 156 | e.messages.extend(error.messages); 157 | } else { 158 | *result = Err(error); 159 | } 160 | None 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /spr/src/github.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Radical HQ Limited 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | use graphql_client::{GraphQLQuery, Response}; 9 | use serde::Deserialize; 10 | 11 | use crate::{ 12 | error::{Error, Result, ResultExt}, 13 | git::Git, 14 | message::{ 15 | build_github_body, parse_message, MessageSection, MessageSectionsMap, 16 | }, 17 | }; 18 | use std::collections::{HashMap, HashSet}; 19 | 20 | #[derive(Clone)] 21 | pub struct GitHub { 22 | config: crate::config::Config, 23 | git: crate::git::Git, 24 | graphql_client: reqwest::Client, 25 | } 26 | 27 | #[derive(Debug, Clone)] 28 | pub struct PullRequest { 29 | pub number: u64, 30 | pub state: PullRequestState, 31 | pub title: String, 32 | pub body: Option, 33 | pub sections: MessageSectionsMap, 34 | pub base: GitHubBranch, 35 | pub head: GitHubBranch, 36 | pub base_oid: git2::Oid, 37 | pub head_oid: git2::Oid, 38 | pub merge_commit: Option, 39 | pub reviewers: HashMap, 40 | pub review_status: Option, 41 | } 42 | 43 | #[derive(Debug, Clone, PartialEq, Eq)] 44 | pub enum ReviewStatus { 45 | Requested, 46 | Approved, 47 | Rejected, 48 | } 49 | 50 | #[derive(serde::Serialize, Default, Debug)] 51 | pub struct PullRequestUpdate { 52 | #[serde(skip_serializing_if = "Option::is_none")] 53 | pub title: Option, 54 | #[serde(skip_serializing_if = "Option::is_none")] 55 | pub body: Option, 56 | #[serde(skip_serializing_if = "Option::is_none")] 57 | pub base: Option, 58 | #[serde(skip_serializing_if = "Option::is_none")] 59 | pub state: Option, 60 | } 61 | 62 | impl PullRequestUpdate { 63 | pub fn is_empty(&self) -> bool { 64 | self.title.is_none() 65 | && self.body.is_none() 66 | && self.base.is_none() 67 | && self.state.is_none() 68 | } 69 | 70 | pub fn update_message( 71 | &mut self, 72 | pull_request: &PullRequest, 73 | message: &MessageSectionsMap, 74 | ) { 75 | let title = message.get(&MessageSection::Title); 76 | if title.is_some() && title != Some(&pull_request.title) { 77 | self.title = title.cloned(); 78 | } 79 | 80 | let body = build_github_body(message); 81 | if pull_request.body.as_ref() != Some(&body) { 82 | self.body = Some(body); 83 | } 84 | } 85 | } 86 | 87 | #[derive(serde::Serialize, Default, Debug)] 88 | pub struct PullRequestRequestReviewers { 89 | pub reviewers: Vec, 90 | pub team_reviewers: Vec, 91 | } 92 | 93 | #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] 94 | #[serde(rename_all = "lowercase")] 95 | pub enum PullRequestState { 96 | Open, 97 | Closed, 98 | } 99 | 100 | #[derive(serde::Deserialize, Debug, Clone)] 101 | pub struct UserWithName { 102 | pub login: String, 103 | pub name: Option, 104 | #[serde(default)] 105 | pub is_collaborator: bool, 106 | } 107 | 108 | #[derive(Debug, Clone)] 109 | pub struct PullRequestMergeability { 110 | pub base: GitHubBranch, 111 | pub head_oid: git2::Oid, 112 | pub mergeable: Option, 113 | pub merge_commit: Option, 114 | } 115 | 116 | #[derive(GraphQLQuery)] 117 | #[graphql( 118 | schema_path = "src/gql/schema.docs.graphql", 119 | query_path = "src/gql/pullrequest_query.graphql", 120 | response_derives = "Debug" 121 | )] 122 | pub struct PullRequestQuery; 123 | type GitObjectID = String; 124 | 125 | #[derive(GraphQLQuery)] 126 | #[graphql( 127 | schema_path = "src/gql/schema.docs.graphql", 128 | query_path = "src/gql/pullrequest_mergeability_query.graphql", 129 | response_derives = "Debug" 130 | )] 131 | pub struct PullRequestMergeabilityQuery; 132 | 133 | impl GitHub { 134 | pub fn new( 135 | config: crate::config::Config, 136 | git: crate::git::Git, 137 | graphql_client: reqwest::Client, 138 | ) -> Self { 139 | Self { 140 | config, 141 | git, 142 | graphql_client, 143 | } 144 | } 145 | 146 | pub async fn get_github_user(login: String) -> Result { 147 | octocrab::instance() 148 | .get::(format!("users/{}", login), None::<&()>) 149 | .await 150 | .map_err(Error::from) 151 | } 152 | 153 | pub async fn get_github_team( 154 | owner: String, 155 | team: String, 156 | ) -> Result { 157 | octocrab::instance() 158 | .teams(owner) 159 | .get(team) 160 | .await 161 | .map_err(Error::from) 162 | } 163 | 164 | pub async fn get_pull_request(self, number: u64) -> Result { 165 | let GitHub { 166 | config, 167 | git, 168 | graphql_client, 169 | } = self; 170 | 171 | let variables = pull_request_query::Variables { 172 | name: config.repo.clone(), 173 | owner: config.owner.clone(), 174 | number: number as i64, 175 | }; 176 | let request_body = PullRequestQuery::build_query(variables); 177 | let res = graphql_client 178 | .post("https://api.github.com/graphql") 179 | .json(&request_body) 180 | .send() 181 | .await?; 182 | let response_body: Response = 183 | res.json().await?; 184 | 185 | if let Some(errors) = response_body.errors { 186 | let error = 187 | Err(Error::new(format!("fetching PR #{number} failed"))); 188 | return errors 189 | .into_iter() 190 | .fold(error, |err, e| err.context(e.to_string())); 191 | } 192 | 193 | let pr = response_body 194 | .data 195 | .ok_or_else(|| Error::new("failed to fetch PR"))? 196 | .repository 197 | .ok_or_else(|| Error::new("failed to find repository"))? 198 | .pull_request 199 | .ok_or_else(|| Error::new("failed to find PR"))?; 200 | 201 | let base = config.new_github_branch_from_ref(&pr.base_ref_name)?; 202 | let head = config.new_github_branch_from_ref(&pr.head_ref_name)?; 203 | 204 | Git::fetch_from_remote(&[&head, &base], &config.remote_name).await?; 205 | 206 | let base_oid = git.lock_and_resolve_reference(base.local())?; 207 | let head_oid = git.lock_and_resolve_reference(head.local())?; 208 | 209 | let mut sections = parse_message(&pr.body, MessageSection::Summary); 210 | 211 | let title = pr.title.trim().to_string(); 212 | sections.insert( 213 | MessageSection::Title, 214 | if title.is_empty() { 215 | String::from("(untitled)") 216 | } else { 217 | title 218 | }, 219 | ); 220 | 221 | sections.insert( 222 | MessageSection::PullRequest, 223 | config.pull_request_url(number), 224 | ); 225 | 226 | let reviewers: HashMap = pr 227 | .latest_opinionated_reviews 228 | .iter() 229 | .flat_map(|all_reviews| &all_reviews.nodes) 230 | .flatten() 231 | .flatten() 232 | .flat_map(|review| { 233 | let user_name = review.author.as_ref()?.login.clone(); 234 | let status = match review.state { 235 | pull_request_query::PullRequestReviewState::APPROVED => ReviewStatus::Approved, 236 | pull_request_query::PullRequestReviewState::CHANGES_REQUESTED => ReviewStatus::Rejected, 237 | _ => ReviewStatus::Requested, 238 | }; 239 | Some((user_name, status)) 240 | }) 241 | .collect(); 242 | 243 | let review_status = match pr.review_decision { 244 | Some(pull_request_query::PullRequestReviewDecision::APPROVED) => Some(ReviewStatus::Approved), 245 | Some(pull_request_query::PullRequestReviewDecision::CHANGES_REQUESTED) => Some(ReviewStatus::Rejected), 246 | Some(pull_request_query::PullRequestReviewDecision::REVIEW_REQUIRED) => Some(ReviewStatus::Requested), 247 | _ => None, 248 | }; 249 | 250 | let requested_reviewers: Vec = pr.review_requests 251 | .iter() 252 | .flat_map(|x| &x.nodes) 253 | .flatten() 254 | .flatten() 255 | .flat_map(|x| &x.requested_reviewer) 256 | .flat_map(|reviewer| { 257 | type UserType = pull_request_query::PullRequestQueryRepositoryPullRequestReviewRequestsNodesRequestedReviewer; 258 | match reviewer { 259 | UserType::User(user) => Some(user.login.clone()), 260 | UserType::Team(team) => Some(format!("#{}", team.slug)), 261 | _ => None, 262 | } 263 | }) 264 | .chain(reviewers.keys().cloned()) 265 | .collect::>() // de-duplicate 266 | .into_iter() 267 | .collect(); 268 | 269 | sections.insert( 270 | MessageSection::Reviewers, 271 | requested_reviewers.iter().fold(String::new(), |out, slug| { 272 | if out.is_empty() { 273 | slug.to_string() 274 | } else { 275 | format!("{}, {}", out, slug) 276 | } 277 | }), 278 | ); 279 | 280 | if review_status == Some(ReviewStatus::Approved) { 281 | sections.insert( 282 | MessageSection::ReviewedBy, 283 | reviewers 284 | .iter() 285 | .filter_map(|(k, v)| { 286 | if v == &ReviewStatus::Approved { 287 | Some(k) 288 | } else { 289 | None 290 | } 291 | }) 292 | .fold(String::new(), |out, slug| { 293 | if out.is_empty() { 294 | slug.to_string() 295 | } else { 296 | format!("{}, {}", out, slug) 297 | } 298 | }), 299 | ); 300 | } 301 | 302 | Ok::<_, Error>(PullRequest { 303 | number: pr.number as u64, 304 | state: match pr.state { 305 | pull_request_query::PullRequestState::OPEN => { 306 | PullRequestState::Open 307 | } 308 | _ => PullRequestState::Closed, 309 | }, 310 | title: pr.title, 311 | body: Some(pr.body), 312 | sections, 313 | base, 314 | head, 315 | base_oid, 316 | head_oid, 317 | reviewers, 318 | review_status, 319 | merge_commit: pr 320 | .merge_commit 321 | .and_then(|sha| git2::Oid::from_str(&sha.oid).ok()), 322 | }) 323 | } 324 | 325 | pub async fn create_pull_request( 326 | &self, 327 | message: &MessageSectionsMap, 328 | base_ref_name: String, 329 | head_ref_name: String, 330 | draft: bool, 331 | ) -> Result { 332 | let number = octocrab::instance() 333 | .pulls(self.config.owner.clone(), self.config.repo.clone()) 334 | .create( 335 | message 336 | .get(&MessageSection::Title) 337 | .unwrap_or(&String::new()), 338 | head_ref_name, 339 | base_ref_name, 340 | ) 341 | .body(build_github_body(message)) 342 | .draft(Some(draft)) 343 | .send() 344 | .await? 345 | .number; 346 | 347 | Ok(number) 348 | } 349 | 350 | pub async fn update_pull_request( 351 | &self, 352 | number: u64, 353 | updates: PullRequestUpdate, 354 | ) -> Result<()> { 355 | octocrab::instance() 356 | .patch::( 357 | format!( 358 | "repos/{}/{}/pulls/{}", 359 | self.config.owner, self.config.repo, number 360 | ), 361 | Some(&updates), 362 | ) 363 | .await?; 364 | 365 | Ok(()) 366 | } 367 | 368 | pub async fn request_reviewers( 369 | &self, 370 | number: u64, 371 | reviewers: PullRequestRequestReviewers, 372 | ) -> Result<()> { 373 | #[derive(Deserialize)] 374 | struct Ignore {} 375 | let _: Ignore = octocrab::instance() 376 | .post( 377 | format!( 378 | "repos/{}/{}/pulls/{}/requested_reviewers", 379 | self.config.owner, self.config.repo, number 380 | ), 381 | Some(&reviewers), 382 | ) 383 | .await?; 384 | 385 | Ok(()) 386 | } 387 | 388 | pub async fn get_pull_request_mergeability( 389 | &self, 390 | number: u64, 391 | ) -> Result { 392 | let variables = pull_request_mergeability_query::Variables { 393 | name: self.config.repo.clone(), 394 | owner: self.config.owner.clone(), 395 | number: number as i64, 396 | }; 397 | let request_body = PullRequestMergeabilityQuery::build_query(variables); 398 | let res = self 399 | .graphql_client 400 | .post("https://api.github.com/graphql") 401 | .json(&request_body) 402 | .send() 403 | .await?; 404 | let response_body: Response< 405 | pull_request_mergeability_query::ResponseData, 406 | > = res.json().await?; 407 | 408 | if let Some(errors) = response_body.errors { 409 | let error = Err(Error::new(format!( 410 | "querying PR #{number} mergeability failed" 411 | ))); 412 | return errors 413 | .into_iter() 414 | .fold(error, |err, e| err.context(e.to_string())); 415 | } 416 | 417 | let pr = response_body 418 | .data 419 | .ok_or_else(|| Error::new("failed to fetch PR"))? 420 | .repository 421 | .ok_or_else(|| Error::new("failed to find repository"))? 422 | .pull_request 423 | .ok_or_else(|| Error::new("failed to find PR"))?; 424 | 425 | Ok::<_, Error>(PullRequestMergeability { 426 | base: self.config.new_github_branch_from_ref(&pr.base_ref_name)?, 427 | head_oid: git2::Oid::from_str(&pr.head_ref_oid)?, 428 | mergeable: match pr.mergeable { 429 | pull_request_mergeability_query::MergeableState::CONFLICTING => Some(false), 430 | pull_request_mergeability_query::MergeableState::MERGEABLE => Some(true), 431 | pull_request_mergeability_query::MergeableState::UNKNOWN => None, 432 | _ => None, 433 | }, 434 | merge_commit: pr 435 | .merge_commit 436 | .and_then(|sha| git2::Oid::from_str(&sha.oid).ok()), 437 | }) 438 | } 439 | } 440 | 441 | #[derive(Debug, Clone)] 442 | pub struct GitHubBranch { 443 | ref_on_github: String, 444 | ref_local: String, 445 | is_master_branch: bool, 446 | } 447 | 448 | impl GitHubBranch { 449 | pub fn new_from_ref( 450 | ghref: &str, 451 | remote_name: &str, 452 | master_branch_name: &str, 453 | ) -> Result { 454 | let ref_on_github = if ghref.starts_with("refs/heads/") { 455 | ghref.to_string() 456 | } else if ghref.starts_with("refs/") { 457 | return Err(Error::new(format!( 458 | "Ref '{ghref}' does not refer to a branch" 459 | ))); 460 | } else { 461 | format!("refs/heads/{ghref}") 462 | }; 463 | 464 | // The branch name is `ref_on_github` with the `refs/heads/` prefix 465 | // (length 11) removed 466 | let branch_name = &ref_on_github[11..]; 467 | let ref_local = format!("refs/remotes/{remote_name}/{branch_name}"); 468 | let is_master_branch = branch_name == master_branch_name; 469 | 470 | Ok(Self { 471 | ref_on_github, 472 | ref_local, 473 | is_master_branch, 474 | }) 475 | } 476 | 477 | pub fn new_from_branch_name( 478 | branch_name: &str, 479 | remote_name: &str, 480 | master_branch_name: &str, 481 | ) -> Self { 482 | Self { 483 | ref_on_github: format!("refs/heads/{branch_name}"), 484 | ref_local: format!("refs/remotes/{remote_name}/{branch_name}"), 485 | is_master_branch: branch_name == master_branch_name, 486 | } 487 | } 488 | 489 | pub fn on_github(&self) -> &str { 490 | &self.ref_on_github 491 | } 492 | 493 | pub fn local(&self) -> &str { 494 | &self.ref_local 495 | } 496 | 497 | pub fn is_master_branch(&self) -> bool { 498 | self.is_master_branch 499 | } 500 | 501 | pub fn branch_name(&self) -> &str { 502 | // The branch name is `ref_on_github` with the `refs/heads/` prefix 503 | // (length 11) removed 504 | &self.ref_on_github[11..] 505 | } 506 | } 507 | 508 | #[cfg(test)] 509 | mod tests { 510 | // Note this useful idiom: importing names from outer (for mod tests) scope. 511 | use super::*; 512 | 513 | #[test] 514 | fn test_new_from_ref_with_branch_name() { 515 | let r = 516 | GitHubBranch::new_from_ref("foo", "github-remote", "masterbranch") 517 | .unwrap(); 518 | assert_eq!(r.on_github(), "refs/heads/foo"); 519 | assert_eq!(r.local(), "refs/remotes/github-remote/foo"); 520 | assert_eq!(r.branch_name(), "foo"); 521 | assert!(!r.is_master_branch()); 522 | } 523 | 524 | #[test] 525 | fn test_new_from_ref_with_master_branch_name() { 526 | let r = GitHubBranch::new_from_ref( 527 | "masterbranch", 528 | "github-remote", 529 | "masterbranch", 530 | ) 531 | .unwrap(); 532 | assert_eq!(r.on_github(), "refs/heads/masterbranch"); 533 | assert_eq!(r.local(), "refs/remotes/github-remote/masterbranch"); 534 | assert_eq!(r.branch_name(), "masterbranch"); 535 | assert!(r.is_master_branch()); 536 | } 537 | 538 | #[test] 539 | fn test_new_from_ref_with_ref_name() { 540 | let r = GitHubBranch::new_from_ref( 541 | "refs/heads/foo", 542 | "github-remote", 543 | "masterbranch", 544 | ) 545 | .unwrap(); 546 | assert_eq!(r.on_github(), "refs/heads/foo"); 547 | assert_eq!(r.local(), "refs/remotes/github-remote/foo"); 548 | assert_eq!(r.branch_name(), "foo"); 549 | assert!(!r.is_master_branch()); 550 | } 551 | 552 | #[test] 553 | fn test_new_from_ref_with_master_ref_name() { 554 | let r = GitHubBranch::new_from_ref( 555 | "refs/heads/masterbranch", 556 | "github-remote", 557 | "masterbranch", 558 | ) 559 | .unwrap(); 560 | assert_eq!(r.on_github(), "refs/heads/masterbranch"); 561 | assert_eq!(r.local(), "refs/remotes/github-remote/masterbranch"); 562 | assert_eq!(r.branch_name(), "masterbranch"); 563 | assert!(r.is_master_branch()); 564 | } 565 | 566 | #[test] 567 | fn test_new_from_branch_name() { 568 | let r = GitHubBranch::new_from_branch_name( 569 | "foo", 570 | "github-remote", 571 | "masterbranch", 572 | ); 573 | assert_eq!(r.on_github(), "refs/heads/foo"); 574 | assert_eq!(r.local(), "refs/remotes/github-remote/foo"); 575 | assert_eq!(r.branch_name(), "foo"); 576 | assert!(!r.is_master_branch()); 577 | } 578 | 579 | #[test] 580 | fn test_new_from_master_branch_name() { 581 | let r = GitHubBranch::new_from_branch_name( 582 | "masterbranch", 583 | "github-remote", 584 | "masterbranch", 585 | ); 586 | assert_eq!(r.on_github(), "refs/heads/masterbranch"); 587 | assert_eq!(r.local(), "refs/remotes/github-remote/masterbranch"); 588 | assert_eq!(r.branch_name(), "masterbranch"); 589 | assert!(r.is_master_branch()); 590 | } 591 | 592 | #[test] 593 | fn test_new_from_ref_with_edge_case_ref_name() { 594 | let r = GitHubBranch::new_from_ref( 595 | "refs/heads/refs/heads/foo", 596 | "github-remote", 597 | "masterbranch", 598 | ) 599 | .unwrap(); 600 | assert_eq!(r.on_github(), "refs/heads/refs/heads/foo"); 601 | assert_eq!(r.local(), "refs/remotes/github-remote/refs/heads/foo"); 602 | assert_eq!(r.branch_name(), "refs/heads/foo"); 603 | assert!(!r.is_master_branch()); 604 | } 605 | 606 | #[test] 607 | fn test_new_from_edge_case_branch_name() { 608 | let r = GitHubBranch::new_from_branch_name( 609 | "refs/heads/foo", 610 | "github-remote", 611 | "masterbranch", 612 | ); 613 | assert_eq!(r.on_github(), "refs/heads/refs/heads/foo"); 614 | assert_eq!(r.local(), "refs/remotes/github-remote/refs/heads/foo"); 615 | assert_eq!(r.branch_name(), "refs/heads/foo"); 616 | assert!(!r.is_master_branch()); 617 | } 618 | } 619 | -------------------------------------------------------------------------------- /spr/src/gql/open_reviews.graphql: -------------------------------------------------------------------------------- 1 | query SearchQuery($query: String!) { 2 | search(query: $query, type: ISSUE, first: 100) { 3 | nodes { 4 | __typename 5 | ... on PullRequest { 6 | number 7 | title 8 | url 9 | reviewDecision 10 | } 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /spr/src/gql/pullrequest_mergeability_query.graphql: -------------------------------------------------------------------------------- 1 | query PullRequestMergeabilityQuery( 2 | $name: String! 3 | $owner: String! 4 | $number: Int! 5 | ) { 6 | repository(owner: $owner, name: $name) { 7 | pullRequest(number: $number) { 8 | baseRefName 9 | headRefOid 10 | mergeable 11 | mergeCommit { 12 | oid 13 | } 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /spr/src/gql/pullrequest_query.graphql: -------------------------------------------------------------------------------- 1 | query PullRequestQuery($name: String!, $owner: String!, $number: Int!) { 2 | repository(owner: $owner, name: $name) { 3 | pullRequest(number: $number) { 4 | number 5 | state 6 | reviewDecision 7 | title 8 | body 9 | baseRefName 10 | headRefName 11 | mergeCommit { 12 | oid 13 | } 14 | latestOpinionatedReviews(last: 100) { 15 | nodes { 16 | author { 17 | __typename 18 | login 19 | } 20 | state 21 | } 22 | } 23 | reviewRequests(last: 100) { 24 | nodes { 25 | requestedReviewer { 26 | __typename 27 | ... on Team { 28 | slug 29 | } 30 | ... on User { 31 | login 32 | } 33 | } 34 | } 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /spr/src/lib.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Radical HQ Limited 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | pub mod commands; 9 | pub mod config; 10 | pub mod error; 11 | pub mod git; 12 | pub mod github; 13 | pub mod message; 14 | pub mod output; 15 | pub mod utils; 16 | -------------------------------------------------------------------------------- /spr/src/main.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Radical HQ Limited 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | //! A command-line tool for submitting and updating GitHub Pull Requests from 9 | //! local Git commits that may be amended and rebased. Pull Requests can be 10 | //! stacked to allow for a series of code reviews of interdependent code. 11 | 12 | use clap::{Parser, Subcommand}; 13 | use reqwest::{self, header}; 14 | use spr::{ 15 | commands, 16 | error::{Error, Result, ResultExt}, 17 | output::output, 18 | }; 19 | 20 | #[derive(Parser, Debug)] 21 | #[clap( 22 | name = "spr", 23 | version, 24 | about = "Submit pull requests for individual, amendable, rebaseable commits to GitHub" 25 | )] 26 | pub struct Cli { 27 | /// Change to DIR before performing any operations 28 | #[clap(long, value_name = "DIR")] 29 | cd: Option, 30 | 31 | /// GitHub personal access token (if not given taken from git config 32 | /// spr.githubAuthToken) 33 | #[clap(long)] 34 | github_auth_token: Option, 35 | 36 | /// GitHub repository ('org/name', if not given taken from config 37 | /// spr.githubRepository) 38 | #[clap(long)] 39 | github_repository: Option, 40 | 41 | /// prefix to be used for branches created for pull requests (if not given 42 | /// taken from git config spr.branchPrefix, defaulting to 43 | /// 'spr//') 44 | #[clap(long)] 45 | branch_prefix: Option, 46 | 47 | #[clap(subcommand)] 48 | command: Commands, 49 | } 50 | 51 | #[derive(Subcommand, Debug)] 52 | enum Commands { 53 | /// Interactive assistant for configuring spr in a local GitHub-backed Git 54 | /// repository 55 | Init, 56 | 57 | /// Create a new or update an existing Pull Request on GitHub from the 58 | /// current HEAD commit 59 | Diff(commands::diff::DiffOptions), 60 | 61 | /// Reformat commit message 62 | Format(commands::format::FormatOptions), 63 | 64 | /// Land a reviewed Pull Request 65 | Land(commands::land::LandOptions), 66 | 67 | /// Update local commit message with content on GitHub 68 | Amend(commands::amend::AmendOptions), 69 | 70 | /// List open Pull Requests on GitHub and their review decision 71 | List, 72 | 73 | /// Create a new branch with the contents of an existing Pull Request 74 | Patch(commands::patch::PatchOptions), 75 | 76 | /// Close a Pull request 77 | Close(commands::close::CloseOptions), 78 | } 79 | 80 | #[derive(Debug, thiserror::Error)] 81 | pub enum OptionsError { 82 | #[error( 83 | "GitHub repository must be given as 'OWNER/REPO', but given value was '{0}'" 84 | )] 85 | InvalidRepository(String), 86 | } 87 | 88 | pub async fn spr() -> Result<()> { 89 | let cli = Cli::parse(); 90 | 91 | if let Some(path) = &cli.cd { 92 | if let Err(err) = std::env::set_current_dir(path) { 93 | eprintln!("Could not change directory to {:?}", &path); 94 | return Err(err.into()); 95 | } 96 | } 97 | 98 | if let Commands::Init = cli.command { 99 | return commands::init::init().await; 100 | } 101 | 102 | let repo = git2::Repository::discover(std::env::current_dir()?)?; 103 | 104 | let git_config = repo.config()?; 105 | 106 | let github_repository = match cli.github_repository { 107 | Some(v) => Ok(v), 108 | None => git_config.get_string("spr.githubRepository"), 109 | }?; 110 | 111 | let (github_owner, github_repo) = { 112 | let captures = lazy_regex::regex!(r#"^([\w\-\.]+)/([\w\-\.]+)$"#) 113 | .captures(&github_repository) 114 | .ok_or_else(|| { 115 | OptionsError::InvalidRepository(github_repository.clone()) 116 | })?; 117 | ( 118 | captures.get(1).unwrap().as_str().to_string(), 119 | captures.get(2).unwrap().as_str().to_string(), 120 | ) 121 | }; 122 | 123 | let github_remote_name = git_config 124 | .get_string("spr.githubRemoteName") 125 | .unwrap_or_else(|_| "origin".to_string()); 126 | let github_master_branch = git_config 127 | .get_string("spr.githubMasterBranch") 128 | .unwrap_or_else(|_| "master".to_string()); 129 | let branch_prefix = git_config.get_string("spr.branchPrefix")?; 130 | let require_approval = git_config 131 | .get_bool("spr.requireApproval") 132 | .ok() 133 | .unwrap_or(false); 134 | let require_test_plan = git_config 135 | .get_bool("spr.requireTestPlan") 136 | .ok() 137 | .unwrap_or(true); 138 | 139 | let config = spr::config::Config::new( 140 | github_owner, 141 | github_repo, 142 | github_remote_name, 143 | github_master_branch, 144 | branch_prefix, 145 | require_approval, 146 | require_test_plan, 147 | ); 148 | 149 | let git = spr::git::Git::new(repo) 150 | .context("could not initialize Git backend".to_owned())?; 151 | 152 | if let Commands::Format(opts) = cli.command { 153 | return commands::format::format(opts, &git, &config).await; 154 | } 155 | 156 | let github_auth_token = match cli.github_auth_token { 157 | Some(v) => Ok(v), 158 | None => git_config.get_string("spr.githubAuthToken"), 159 | }?; 160 | 161 | octocrab::initialise( 162 | octocrab::Octocrab::builder().personal_token(github_auth_token.clone()), 163 | )?; 164 | 165 | let mut headers = header::HeaderMap::new(); 166 | headers.insert(header::ACCEPT, "application/json".parse()?); 167 | headers.insert( 168 | header::USER_AGENT, 169 | format!("spr/{}", env!("CARGO_PKG_VERSION")).try_into()?, 170 | ); 171 | headers.insert( 172 | header::AUTHORIZATION, 173 | format!("Bearer {}", github_auth_token).parse()?, 174 | ); 175 | 176 | let graphql_client = reqwest::Client::builder() 177 | .default_headers(headers) 178 | .build()?; 179 | 180 | let mut gh = spr::github::GitHub::new( 181 | config.clone(), 182 | git.clone(), 183 | graphql_client.clone(), 184 | ); 185 | 186 | match cli.command { 187 | Commands::Diff(opts) => { 188 | commands::diff::diff(opts, &git, &mut gh, &config).await? 189 | } 190 | Commands::Land(opts) => { 191 | commands::land::land(opts, &git, &mut gh, &config).await? 192 | } 193 | Commands::Amend(opts) => { 194 | commands::amend::amend(opts, &git, &mut gh, &config).await? 195 | } 196 | Commands::List => commands::list::list(graphql_client, &config).await?, 197 | Commands::Patch(opts) => { 198 | commands::patch::patch(opts, &git, &mut gh, &config).await? 199 | } 200 | Commands::Close(opts) => { 201 | commands::close::close(opts, &git, &mut gh, &config).await? 202 | } 203 | // The following commands are executed above and return from this 204 | // function before it reaches this match. 205 | Commands::Init | Commands::Format(_) => (), 206 | }; 207 | 208 | Ok::<_, Error>(()) 209 | } 210 | 211 | #[tokio::main] 212 | async fn main() -> Result<()> { 213 | if let Err(error) = spr().await { 214 | for message in error.messages() { 215 | output("🛑", message)?; 216 | } 217 | std::process::exit(1); 218 | } 219 | 220 | Ok(()) 221 | } 222 | -------------------------------------------------------------------------------- /spr/src/message.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Radical HQ Limited 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | use crate::{ 9 | error::{Error, Result}, 10 | output::output, 11 | }; 12 | 13 | pub type MessageSectionsMap = 14 | std::collections::BTreeMap; 15 | 16 | #[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Debug)] 17 | pub enum MessageSection { 18 | Title, 19 | Summary, 20 | TestPlan, 21 | Reviewers, 22 | ReviewedBy, 23 | PullRequest, 24 | } 25 | 26 | pub fn message_section_label(section: &MessageSection) -> &'static str { 27 | use MessageSection::*; 28 | 29 | match section { 30 | Title => "Title", 31 | Summary => "Summary", 32 | TestPlan => "Test Plan", 33 | Reviewers => "Reviewers", 34 | ReviewedBy => "Reviewed By", 35 | PullRequest => "Pull Request", 36 | } 37 | } 38 | 39 | pub fn message_section_by_label(label: &str) -> Option { 40 | use MessageSection::*; 41 | 42 | match &label.to_ascii_lowercase()[..] { 43 | "title" => Some(Title), 44 | "summary" => Some(Summary), 45 | "test plan" => Some(TestPlan), 46 | "reviewer" => Some(Reviewers), 47 | "reviewers" => Some(Reviewers), 48 | "reviewed by" => Some(ReviewedBy), 49 | "pull request" => Some(PullRequest), 50 | _ => None, 51 | } 52 | } 53 | 54 | pub fn parse_message( 55 | msg: &str, 56 | top_section: MessageSection, 57 | ) -> MessageSectionsMap { 58 | let regex = lazy_regex::regex!(r#"^\s*([\w\s]+?)\s*:\s*(.*)$"#); 59 | 60 | let mut section = top_section; 61 | let mut lines_in_section = Vec::<&str>::new(); 62 | let mut sections = 63 | std::collections::BTreeMap::::new(); 64 | 65 | for (lineno, line) in msg 66 | .trim() 67 | .split('\n') 68 | .map(|line| line.trim_end()) 69 | .enumerate() 70 | { 71 | if let Some(caps) = regex.captures(line) { 72 | let label = caps.get(1).unwrap().as_str(); 73 | let payload = caps.get(2).unwrap().as_str(); 74 | 75 | if let Some(new_section) = message_section_by_label(label) { 76 | append_to_message_section( 77 | sections.entry(section), 78 | lines_in_section.join("\n").trim(), 79 | ); 80 | section = new_section; 81 | lines_in_section = vec![payload]; 82 | continue; 83 | } 84 | } 85 | 86 | if lineno == 0 && top_section == MessageSection::Title { 87 | sections.insert(top_section, line.to_string()); 88 | section = MessageSection::Summary; 89 | } else { 90 | lines_in_section.push(line); 91 | } 92 | } 93 | 94 | if !lines_in_section.is_empty() { 95 | append_to_message_section( 96 | sections.entry(section), 97 | lines_in_section.join("\n").trim(), 98 | ); 99 | } 100 | 101 | sections 102 | } 103 | 104 | fn append_to_message_section( 105 | entry: std::collections::btree_map::Entry, 106 | text: &str, 107 | ) { 108 | if !text.is_empty() { 109 | entry 110 | .and_modify(|value| { 111 | if value.is_empty() { 112 | *value = text.to_string(); 113 | } else { 114 | *value = format!("{}\n\n{}", value, text); 115 | } 116 | }) 117 | .or_insert_with(|| text.to_string()); 118 | } else { 119 | entry.or_default(); 120 | } 121 | } 122 | 123 | pub fn build_message( 124 | section_texts: &MessageSectionsMap, 125 | sections: &[MessageSection], 126 | ) -> String { 127 | let mut result = String::new(); 128 | let mut display_label = false; 129 | 130 | for section in sections { 131 | let value = section_texts.get(section); 132 | if let Some(text) = value { 133 | if !result.is_empty() { 134 | result.push('\n'); 135 | } 136 | 137 | if section != &MessageSection::Title 138 | && section != &MessageSection::Summary 139 | { 140 | // Once we encounter a section that's neither Title nor Summary, 141 | // we start displaying the labels. 142 | display_label = true; 143 | } 144 | 145 | if display_label { 146 | let label = message_section_label(section); 147 | result.push_str(label); 148 | result.push_str( 149 | if label.len() + text.len() > 76 || text.contains('\n') { 150 | ":\n" 151 | } else { 152 | ": " 153 | }, 154 | ); 155 | } 156 | 157 | result.push_str(text); 158 | result.push('\n'); 159 | } 160 | } 161 | 162 | result 163 | } 164 | 165 | pub fn build_commit_message(section_texts: &MessageSectionsMap) -> String { 166 | build_message( 167 | section_texts, 168 | &[ 169 | MessageSection::Title, 170 | MessageSection::Summary, 171 | MessageSection::TestPlan, 172 | MessageSection::Reviewers, 173 | MessageSection::ReviewedBy, 174 | MessageSection::PullRequest, 175 | ], 176 | ) 177 | } 178 | 179 | pub fn build_github_body(section_texts: &MessageSectionsMap) -> String { 180 | build_message( 181 | section_texts, 182 | &[MessageSection::Summary, MessageSection::TestPlan], 183 | ) 184 | } 185 | 186 | pub fn build_github_body_for_merging( 187 | section_texts: &MessageSectionsMap, 188 | ) -> String { 189 | build_message( 190 | section_texts, 191 | &[ 192 | MessageSection::Summary, 193 | MessageSection::TestPlan, 194 | MessageSection::Reviewers, 195 | MessageSection::ReviewedBy, 196 | MessageSection::PullRequest, 197 | ], 198 | ) 199 | } 200 | 201 | pub fn validate_commit_message( 202 | message: &MessageSectionsMap, 203 | config: &crate::config::Config, 204 | ) -> Result<()> { 205 | if config.require_test_plan 206 | && !message.contains_key(&MessageSection::TestPlan) 207 | { 208 | output("💔", "Commit message does not have a Test Plan!")?; 209 | return Err(Error::empty()); 210 | } 211 | 212 | let title_missing_or_empty = match message.get(&MessageSection::Title) { 213 | None => true, 214 | Some(title) => title.is_empty(), 215 | }; 216 | if title_missing_or_empty { 217 | output("💔", "Commit message does not have a title!")?; 218 | return Err(Error::empty()); 219 | } 220 | 221 | Ok(()) 222 | } 223 | 224 | #[cfg(test)] 225 | mod tests { 226 | // Note this useful idiom: importing names from outer (for mod tests) scope. 227 | use super::*; 228 | 229 | #[test] 230 | fn test_parse_empty() { 231 | assert_eq!( 232 | parse_message("", MessageSection::Title), 233 | [(MessageSection::Title, "".to_string())].into() 234 | ); 235 | } 236 | 237 | #[test] 238 | fn test_parse_title() { 239 | assert_eq!( 240 | parse_message("Hello", MessageSection::Title), 241 | [(MessageSection::Title, "Hello".to_string())].into() 242 | ); 243 | assert_eq!( 244 | parse_message("Hello\n", MessageSection::Title), 245 | [(MessageSection::Title, "Hello".to_string())].into() 246 | ); 247 | assert_eq!( 248 | parse_message("\n\nHello\n\n", MessageSection::Title), 249 | [(MessageSection::Title, "Hello".to_string())].into() 250 | ); 251 | } 252 | 253 | #[test] 254 | fn test_parse_title_and_summary() { 255 | assert_eq!( 256 | parse_message("Hello\nFoo Bar", MessageSection::Title), 257 | [ 258 | (MessageSection::Title, "Hello".to_string()), 259 | (MessageSection::Summary, "Foo Bar".to_string()) 260 | ] 261 | .into() 262 | ); 263 | assert_eq!( 264 | parse_message("Hello\n\nFoo Bar", MessageSection::Title), 265 | [ 266 | (MessageSection::Title, "Hello".to_string()), 267 | (MessageSection::Summary, "Foo Bar".to_string()) 268 | ] 269 | .into() 270 | ); 271 | assert_eq!( 272 | parse_message("Hello\n\n\nFoo Bar", MessageSection::Title), 273 | [ 274 | (MessageSection::Title, "Hello".to_string()), 275 | (MessageSection::Summary, "Foo Bar".to_string()) 276 | ] 277 | .into() 278 | ); 279 | assert_eq!( 280 | parse_message("Hello\n\nSummary:\nFoo Bar", MessageSection::Title), 281 | [ 282 | (MessageSection::Title, "Hello".to_string()), 283 | (MessageSection::Summary, "Foo Bar".to_string()) 284 | ] 285 | .into() 286 | ); 287 | } 288 | 289 | #[test] 290 | fn test_parse_sections() { 291 | assert_eq!( 292 | parse_message( 293 | r#"Hello 294 | 295 | Test plan: testzzz 296 | 297 | Summary: 298 | here is 299 | the 300 | summary (it's not a "Test plan:"!) 301 | 302 | Reviewer: a, b, c"#, 303 | MessageSection::Title 304 | ), 305 | [ 306 | (MessageSection::Title, "Hello".to_string()), 307 | ( 308 | MessageSection::Summary, 309 | "here is\nthe\nsummary (it's not a \"Test plan:\"!)" 310 | .to_string() 311 | ), 312 | (MessageSection::TestPlan, "testzzz".to_string()), 313 | (MessageSection::Reviewers, "a, b, c".to_string()), 314 | ] 315 | .into() 316 | ); 317 | } 318 | } 319 | -------------------------------------------------------------------------------- /spr/src/output.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Radical HQ Limited 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | use crate::{error::Result, git::PreparedCommit, message::MessageSection}; 9 | 10 | pub fn output(icon: &str, text: &str) -> Result<()> { 11 | let term = console::Term::stdout(); 12 | 13 | let bullet = format!(" {} ", icon); 14 | let indent = console::measure_text_width(&bullet); 15 | let indent_string = " ".repeat(indent); 16 | let options = textwrap::Options::new((term.size().1 as usize) - indent * 2) 17 | .initial_indent(&bullet) 18 | .subsequent_indent(&indent_string) 19 | .break_words(false) 20 | .word_separator(textwrap::WordSeparator::AsciiSpace) 21 | .word_splitter(textwrap::WordSplitter::NoHyphenation); 22 | 23 | term.write_line(&textwrap::wrap(text.trim(), &options).join("\n"))?; 24 | Ok(()) 25 | } 26 | 27 | pub fn write_commit_title(prepared_commit: &PreparedCommit) -> Result<()> { 28 | let term = console::Term::stdout(); 29 | term.write_line(&format!( 30 | "{} {}", 31 | console::style(&prepared_commit.short_id).italic(), 32 | console::style( 33 | prepared_commit 34 | .message 35 | .get(&MessageSection::Title) 36 | .map(|s| &s[..]) 37 | .unwrap_or("(untitled)"), 38 | ) 39 | .yellow() 40 | ))?; 41 | Ok(()) 42 | } 43 | -------------------------------------------------------------------------------- /spr/src/utils.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Radical HQ Limited 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | use crate::error::{Error, Result}; 9 | 10 | use std::{io::Write, process::Stdio}; 11 | use unicode_normalization::UnicodeNormalization; 12 | 13 | pub fn slugify(s: &str) -> String { 14 | s.trim() 15 | .nfd() 16 | .map(|c| if c.is_whitespace() { '-' } else { c }) 17 | .filter(|c| c.is_ascii_alphanumeric() || c == &'_' || c == &'-') 18 | .map(|c| char::to_ascii_lowercase(&c)) 19 | .scan(None, |last_char, char| { 20 | if char == '-' && last_char == &Some('-') { 21 | Some(None) 22 | } else { 23 | *last_char = Some(char); 24 | Some(Some(char)) 25 | } 26 | }) 27 | .flatten() 28 | .collect() 29 | } 30 | 31 | pub fn parse_name_list(text: &str) -> Vec { 32 | lazy_regex::regex!(r#"\(.*?\)"#) 33 | .replace_all(text, ",") 34 | .split(',') 35 | .map(|name| name.trim()) 36 | .filter(|name| !name.is_empty()) 37 | .map(String::from) 38 | .collect() 39 | } 40 | 41 | pub fn remove_all_parens(text: &str) -> String { 42 | lazy_regex::regex!(r#"[()]"#).replace_all(text, "").into() 43 | } 44 | 45 | pub async fn run_command(cmd: &mut tokio::process::Command) -> Result<()> { 46 | let cmd_output = cmd 47 | .stdout(Stdio::null()) 48 | .stderr(Stdio::piped()) 49 | .spawn()? 50 | .wait_with_output() 51 | .await?; 52 | 53 | if !cmd_output.status.success() { 54 | console::Term::stderr().write_all(&cmd_output.stderr)?; 55 | return Err(Error::new("command failed")); 56 | } 57 | 58 | Ok(()) 59 | } 60 | 61 | #[cfg(test)] 62 | mod tests { 63 | // Note this useful idiom: importing names from outer (for mod tests) scope. 64 | use super::*; 65 | 66 | #[test] 67 | fn test_empty() { 68 | assert_eq!(slugify(""), "".to_string()); 69 | } 70 | 71 | #[test] 72 | fn test_hello_world() { 73 | assert_eq!(slugify(" Hello World! "), "hello-world".to_string()); 74 | } 75 | 76 | #[test] 77 | fn test_accents() { 78 | assert_eq!(slugify("ĥêlļō ŵöřľď"), "hello-world".to_string()); 79 | } 80 | 81 | #[test] 82 | fn test_parse_name_list_empty() { 83 | assert!(parse_name_list("").is_empty()); 84 | assert!(parse_name_list(" ").is_empty()); 85 | assert!(parse_name_list(" ").is_empty()); 86 | assert!(parse_name_list(" ").is_empty()); 87 | assert!(parse_name_list("\n").is_empty()); 88 | assert!(parse_name_list(" \n ").is_empty()); 89 | } 90 | 91 | #[test] 92 | fn test_parse_name_single_name() { 93 | assert_eq!(parse_name_list("foo"), vec!["foo".to_string()]); 94 | assert_eq!(parse_name_list("foo "), vec!["foo".to_string()]); 95 | assert_eq!(parse_name_list(" foo"), vec!["foo".to_string()]); 96 | assert_eq!(parse_name_list(" foo "), vec!["foo".to_string()]); 97 | assert_eq!(parse_name_list("foo (Foo Bar)"), vec!["foo".to_string()]); 98 | assert_eq!( 99 | parse_name_list(" foo (Foo Bar) "), 100 | vec!["foo".to_string()] 101 | ); 102 | assert_eq!( 103 | parse_name_list(" () (-)foo (Foo Bar) (xx)"), 104 | vec!["foo".to_string()] 105 | ); 106 | } 107 | 108 | #[test] 109 | fn test_parse_name_multiple_names() { 110 | let expected = 111 | vec!["foo".to_string(), "bar".to_string(), "baz".to_string()]; 112 | assert_eq!(parse_name_list("foo,bar,baz"), expected); 113 | assert_eq!(parse_name_list("foo, bar, baz"), expected); 114 | assert_eq!(parse_name_list("foo , bar , baz"), expected); 115 | assert_eq!( 116 | parse_name_list("foo (Mr Foo), bar (Ms Bar), baz (Dr Baz)"), 117 | expected 118 | ); 119 | assert_eq!( 120 | parse_name_list( 121 | "foo (Mr Foo) bar (Ms Bar) (the other one), baz (Dr Baz)" 122 | ), 123 | expected 124 | ); 125 | } 126 | } 127 | --------------------------------------------------------------------------------