├── rustfmt.toml ├── .gitignore ├── src ├── gql │ ├── open_reviews.graphql │ ├── pullrequest_mergeability_query.graphql │ └── pullrequest_query.graphql ├── commands │ ├── mod.rs │ ├── format.rs │ ├── amend.rs │ ├── list.rs │ ├── close.rs │ ├── patch.rs │ ├── init.rs │ ├── land.rs │ └── diff.rs ├── lib.rs ├── output.rs ├── utils.rs ├── config.rs ├── git_remote.rs ├── main.rs ├── message.rs ├── git.rs └── github.rs ├── shell.nix ├── .github └── workflows │ ├── test.yml │ ├── book.yml │ └── release.yml ├── book.toml ├── docs ├── SUMMARY.md ├── user │ ├── installation.md │ ├── setup.md │ ├── patch.md │ ├── simple.md │ ├── commit-message.md │ └── stack.md ├── reference │ ├── configuration.md │ └── how-it-works-simple.md ├── README.md ├── images │ └── patch.svg └── spr.svg ├── LICENSE ├── Cargo.toml ├── release_checklist.md ├── README.md └── CHANGELOG.md /rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 80 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /book/ 2 | /target/ 3 | /mermaid.min.js 4 | /mermaid-init.js 5 | /.envrc 6 | /.direnv 7 | 8 | *~ 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | # Simple nix shell for developing spr 2 | # 3 | # To load automatically with direnv, do 4 | # ``` 5 | # echo "use nix" >.envrc 6 | # direnv allow 7 | # ``` 8 | 9 | { 10 | pkgs ? import { }, 11 | }: 12 | pkgs.mkShell { 13 | packages = with pkgs; [ 14 | pkg-config 15 | openssl 16 | ]; 17 | } 18 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 git; 11 | pub mod git_remote; 12 | pub mod github; 13 | pub mod message; 14 | pub mod output; 15 | pub mod utils; 16 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: push 2 | name: Test and lint 3 | 4 | # Make sure CI fails on all warnings, including Clippy lints 5 | env: 6 | RUSTFLAGS: "-Dwarnings" 7 | 8 | jobs: 9 | clippy_check: 10 | name: cargo clippy 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v5 14 | - run: cargo clippy --all-targets --all-features 15 | 16 | tests: 17 | name: cargo test 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v5 21 | - run: cargo test 22 | -------------------------------------------------------------------------------- /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/spacedentist/spr" 13 | edit-url-template = "https://github.com/spacedentist/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/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 | - [How it works - Simple PR](reference/how-it-works-simple.md) 21 | -------------------------------------------------------------------------------- /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 | spr is available in nixpkgs 14 | 15 | ```shell 16 | nix run nixpkgs#spr 17 | ``` 18 | 19 | ### Using Cargo 20 | 21 | If you have Cargo installed (the Rust build tool), you can install spr by running `cargo install spr`. 22 | 23 | ## Install from Source 24 | 25 | 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. 26 | 27 | With Rust all set up, clone this repository and run `cargo build --release`. The spr binary will be in the `target/release` directory. 28 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 authorise spr with your GitHub account. `spr init` will guide you through the process. 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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "spr" 3 | version = "1.3.8-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/spacedentist/spr" 7 | homepage = "https://github.com/spacedentist/spr" 8 | license = "MIT" 9 | edition = "2024" 10 | exclude = [".github", ".gitignore"] 11 | 12 | [dependencies] 13 | bytes = "1.10.1" 14 | clap = { version = "^4.5.47", features = ["derive", "wrap_help"] } 15 | color-eyre = "0.6.5" 16 | console = "^0.16.1" 17 | dialoguer = "^0.12.0" 18 | env_logger = "0.11.8" 19 | futures = "^0.3.31" 20 | futures-lite = "^2.6.1" 21 | git2 = { version = "^0.20.2", default-features = false, features = ["https"]} 22 | git2-ext = "^0.6.3" 23 | graphql_client = "^0.14.0" 24 | http = "1.3.1" 25 | http-body = "1.0.1" 26 | indoc = "^2.0.6" 27 | lazy-regex = "^3.4.1" 28 | log = "0.4.28" 29 | octocrab = { version = "^0.45.0", default-features = false, features = ["opentls", "default-client", "tokio"] } 30 | open = "5.3.2" 31 | secrecy = { version = "0.10.3", default-features = false } 32 | serde = "^1.0.225" 33 | textwrap = "^0.16.2" 34 | tokio = { version = "^1.47.1", features = ["macros", "rt", "time"] } 35 | unicode-normalization = "^0.1.24" 36 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 color_eyre::eyre::Result; 9 | 10 | use crate::{git::PreparedCommit, message::MessageSection}; 11 | 12 | pub fn output(icon: &str, text: &str) -> Result<()> { 13 | let term = console::Term::stdout(); 14 | 15 | let bullet = format!(" {} ", icon); 16 | let indent = console::measure_text_width(&bullet); 17 | let indent_string = " ".repeat(indent); 18 | let options = textwrap::Options::new((term.size().1 as usize) - indent * 2) 19 | .initial_indent(&bullet) 20 | .subsequent_indent(&indent_string) 21 | .break_words(false) 22 | .word_separator(textwrap::WordSeparator::AsciiSpace) 23 | .word_splitter(textwrap::WordSplitter::NoHyphenation); 24 | 25 | term.write_line(&textwrap::wrap(text.trim(), &options).join("\n"))?; 26 | Ok(()) 27 | } 28 | 29 | pub fn write_commit_title(prepared_commit: &PreparedCommit) -> Result<()> { 30 | let term = console::Term::stdout(); 31 | term.write_line(&format!( 32 | "{} {}", 33 | console::style(&prepared_commit.short_id).italic(), 34 | console::style( 35 | prepared_commit 36 | .message 37 | .get(&MessageSection::Title) 38 | .map(|s| &s[..]) 39 | .unwrap_or("(untitled)"), 40 | ) 41 | .yellow() 42 | ))?; 43 | Ok(()) 44 | } 45 | -------------------------------------------------------------------------------- /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 color_eyre::eyre::{Result, eyre}; 9 | 10 | use crate::{ 11 | message::validate_commit_message, 12 | output::{output, write_commit_title}, 13 | }; 14 | 15 | #[derive(Debug, clap::Parser)] 16 | pub struct FormatOptions { 17 | /// format all commits in branch, not just HEAD 18 | #[clap(long, short = 'a')] 19 | all: bool, 20 | } 21 | 22 | pub async fn format( 23 | opts: FormatOptions, 24 | git: &crate::git::Git, 25 | gh: &mut crate::github::GitHub, 26 | config: &crate::config::Config, 27 | ) -> Result<()> { 28 | let mut pc = gh.get_prepared_commits()?; 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 | let mut failure = false; 44 | 45 | for commit in slice.iter() { 46 | write_commit_title(commit)?; 47 | failure = validate_commit_message(&commit.message, config).is_err() 48 | || failure; 49 | } 50 | git.rewrite_commit_messages(slice, None)?; 51 | 52 | if failure { 53 | Err(eyre!("format failed")) 54 | } else { 55 | Ok(()) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /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 color_eyre::eyre::{Result, eyre}; 9 | 10 | use crate::{ 11 | git::PreparedCommit, 12 | message::validate_commit_message, 13 | output::{output, write_commit_title}, 14 | }; 15 | 16 | #[derive(Debug, clap::Parser)] 17 | pub struct AmendOptions { 18 | /// Amend all commits in branch, not just HEAD 19 | #[clap(long, short = 'a')] 20 | all: bool, 21 | } 22 | 23 | pub async fn amend( 24 | opts: AmendOptions, 25 | git: &crate::git::Git, 26 | gh: &mut crate::github::GitHub, 27 | config: &crate::config::Config, 28 | ) -> Result<()> { 29 | let mut pc = gh.get_prepared_commits()?; 30 | 31 | let len = pc.len(); 32 | if len == 0 { 33 | output("👋", "Branch is empty - nothing to do. Good bye!")?; 34 | return Ok(()); 35 | } 36 | 37 | // The slice of prepared commits we want to operate on. 38 | let slice = if opts.all { 39 | &mut pc[..] 40 | } else { 41 | &mut pc[len - 1..] 42 | }; 43 | 44 | // Request the Pull Request information for each commit (well, those that 45 | // declare to have Pull Requests). This list is in reverse order, so that 46 | // below we can pop from the vector as we iterate. 47 | let mut pull_requests: Vec<_> = slice 48 | .iter() 49 | .rev() 50 | .map(|pc: &PreparedCommit| { 51 | pc.pull_request_number.map(|number| { 52 | tokio::task::spawn_local(gh.clone().get_pull_request(number)) 53 | }) 54 | }) 55 | .collect(); 56 | 57 | let mut failure = false; 58 | 59 | for commit in slice.iter_mut() { 60 | write_commit_title(commit)?; 61 | let pull_request = pull_requests.pop().flatten(); 62 | if let Some(pull_request) = pull_request { 63 | let pull_request = pull_request.await??; 64 | commit.message = pull_request.sections; 65 | } 66 | failure = validate_commit_message(&commit.message, config).is_err() 67 | || failure; 68 | } 69 | git.rewrite_commit_messages(slice, None)?; 70 | 71 | if failure { 72 | Err(eyre!("amend failed")) 73 | } else { 74 | Ok(()) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /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 authentication token to use for accessing the GitHub API. | 10 | | `githubRepository` | `--github-repository` | Name of repository on github.com in `owner/repo` format | 11 | | `githubMasterBranch` | | The name of the centrally shared branch into which the pull requests are merged | `master` | taken from repository configuration on GitHub | 12 | | `branchPrefix` | `--branch-prefix` | String used to prefix autogenerated names of pull request branches | | `spr/GITHUB_USERNAME/` | 13 | | `requireApproval` | | If true, `spr land` will refuse to land a pull request that is not accepted | false | 14 | | `requireTestPlan` | | If true, `spr diff` will refuse to process a commit without a test plan | true | 15 | 16 | 17 | - The config keys are all in the `spr` section; for example, `spr.githubAuthToken`. 18 | 19 | - Values passed on the command line take precedence over values set in Git configuration. 20 | 21 | - 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. 22 | 23 | - `spr init` writes configured values into `.git/config` in the local repo. (It must be run inside a Git repo.) 24 | 25 | [^default]: Value used by `spr` if not set in configuration. 26 | 27 | [^initdefault]: Value suggested by `spr init` if not previously configured. 28 | 29 | [^cli-token]: Be careful using this: your auth token will be in your shell history. 30 | -------------------------------------------------------------------------------- /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 color_eyre::eyre::{Result, WrapErr as _, eyre}; 9 | use graphql_client::{GraphQLQuery, Response}; 10 | 11 | #[allow(clippy::upper_case_acronyms)] 12 | type URI = String; 13 | #[derive(GraphQLQuery)] 14 | #[graphql( 15 | schema_path = "src/gql/schema.docs.graphql", 16 | query_path = "src/gql/open_reviews.graphql", 17 | response_derives = "Debug" 18 | )] 19 | pub struct SearchQuery; 20 | 21 | pub async fn list(config: &crate::config::Config) -> Result<()> { 22 | let variables = search_query::Variables { 23 | query: format!( 24 | "repo:{}/{} is:open is:pr author:@me archived:false", 25 | config.owner, config.repo 26 | ), 27 | }; 28 | let request_body = SearchQuery::build_query(variables); 29 | let response_body: Response = 30 | octocrab::instance() 31 | .post("/graphql", Some(&request_body)) 32 | .await 33 | .wrap_err("Searching for open PRs".to_string())?; 34 | 35 | print_pr_info(response_body).ok_or_else(|| eyre!("unexpected error")) 36 | } 37 | 38 | fn print_pr_info( 39 | response_body: Response, 40 | ) -> Option<()> { 41 | let term = console::Term::stdout(); 42 | for pr in response_body.data?.search.nodes? { 43 | let pr = match pr { 44 | Some(crate::commands::list::search_query::SearchQuerySearchNodes::PullRequest(pr)) => pr, 45 | _ => continue, 46 | }; 47 | let dummy: String; 48 | let decision = match pr.review_decision { 49 | Some(search_query::PullRequestReviewDecision::APPROVED) => { 50 | console::style("Accepted").green() 51 | } 52 | Some( 53 | search_query::PullRequestReviewDecision::CHANGES_REQUESTED, 54 | ) => console::style("Changes Requested").red(), 55 | None 56 | | Some(search_query::PullRequestReviewDecision::REVIEW_REQUIRED) => { 57 | console::style("Pending") 58 | } 59 | Some(search_query::PullRequestReviewDecision::Other(d)) => { 60 | dummy = d; 61 | console::style(dummy.as_str()) 62 | } 63 | }; 64 | term.write_line(&format!( 65 | "{} {} {}", 66 | decision, 67 | console::style(&pr.title).bold(), 68 | console::style(&pr.url).dim(), 69 | )) 70 | .ok()?; 71 | } 72 | Some(()) 73 | } 74 | -------------------------------------------------------------------------------- /release_checklist.md: -------------------------------------------------------------------------------- 1 | # Release Checklist 2 | 3 | This is internal documentation, listing the step to make a new release. 4 | 5 | ## Release commit 6 | 7 | * Update version number in `Cargo.toml` 8 | * Run `cargo check` to propagate change to `Cargo.lock` 9 | * Update `CHANGELOG.md`: 10 | * Rename the top section from "Unreleased" to "version - date" (see previous releases for how it's supposed to look) 11 | * Make sure significant changes are listed 12 | * Add a reference to the release on GitHub at the bottom of the file (like for all the other releases) 13 | * Make a commit with the above changes named "Release x.y.z" 14 | * Push that commit to master 15 | * Tag the commit - as it is on master - "vx.y.z" 16 | 17 | ## GitHub 18 | 19 | * Make a release on GitHub: https://github.com/spacedentist/spr/releases/new 20 | 21 | ## crates.io 22 | 23 | * Run `cargo publish` (you might need to do `cargo login` first) 24 | 25 | ## nixpkgs 26 | 27 | * Clone/check out current master of https://github.com/NixOS/nixpkgs 28 | * Edit `pkgs/by-name/sp/spr/package.nix`, and update the "version" field. Also, make a random change to the `hash` and `cargoHash` fields, to make sure the following nix build will not used an existing build. 29 | * Run `nix-build -A spr` 30 | * There will be a hash mismatch error. Edit the nix file again and paste in the correct hash from the build error. 31 | * Run `nix-build -A spr` again 32 | * There will be another hash error, this time in the `cargoHash` field. Again, edit the nix file and paste the correct hash as displayed in the build error. 33 | * Run `nix-build -A spr` again 34 | * If there are any more build errors, fix them and build again. 35 | * Once the build completes without errors, continue with the below. 36 | * Make a git commit with the change in the nix file. Commit message: "spr: old-version -> new-version", e.g. "spr 1.3.2 -> 1.3.3". 37 | * Push the commit to GitHub (probably as the master branch of a nixpkgs fork) and submit a pull request to upstream. Example: https://github.com/NixOS/nixpkgs/pull/179332 38 | * Check in with the pull request and make sure it gets merged. 39 | 40 | ## homebrew 41 | 42 | * Example PR: https://github.com/Homebrew/homebrew-core/pull/221792 43 | * Typically, only `url` and `sha256` need to be updated in `Formula/s/spr.rb` - the BrewTestBot will automatically add a commit to the PR updating the "bottle" section 44 | 45 | ## Start next release cycle 46 | 47 | * Bump the version number in `Cargo.toml` and add `-beta.1` suffix 48 | * Run `cargo check` to propagate change to `Cargo.lock` 49 | * Add a new "Unreleased" section at the top of `CHANGELOG.md` 50 | * Make a commit with the above changes named "Start next development cycle" 51 | * Push that commit to master - the last release commit should be the direct parent 52 | 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![spr](./docs/spr.svg) 2 | 3 | # spr · [![GitHub](https://img.shields.io/github/license/spacedentist/spr)](https://img.shields.io/github/license/spacedentist/spr) [![GitHub release](https://img.shields.io/github/v/release/spacedentist/spr?include_prereleases)](https://github.com/spacedentist/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/spacedentist/spr?style=social)](https://github.com/spacedentist/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://spacedentist.github.io/spr/ 14 | 15 | ## Installation 16 | 17 | [![Packaging status](https://repology.org/badge/vertical-allrepos/spr-super-pull-requests.svg)](https://repology.org/project/spr-super-pull-requests/versions) 18 | 19 | ### Binary Installation 20 | 21 | #### Using Homebrew 22 | 23 | ```shell 24 | brew install spr 25 | ``` 26 | 27 | #### Using Nix 28 | 29 | spr is available in nixpkgs 30 | 31 | ```shell 32 | nix run nixpkgs#spr 33 | ``` 34 | 35 | #### Using Cargo 36 | 37 | If you have Cargo installed (the Rust build tool), you can install spr by running 38 | 39 | ```shell 40 | cargo install spr 41 | ``` 42 | 43 | ### Install from Source 44 | 45 | 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. 46 | 47 | With Rust all set up, clone this repository and run `cargo build --release`. The spr binary will be in the `target/release` directory. 48 | 49 | ## Quickstart 50 | 51 | To use spr, run `spr init` inside a local checkout of a GitHub-backed git repository. You will be guided through authorising spr to use the GitHub API in order to create and merge pull requests. 52 | 53 | To submit a commit for pull request, run `spr diff`. 54 | 55 | 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. 56 | 57 | To squash-merge an open pull request, run `spr land`. 58 | 59 | 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`). 60 | 61 | ## Contributing 62 | 63 | Feel free to submit an issue on [GitHub](https://github.com/spacedentist/spr) if you have found a problem. If you can even provide a fix, please raise a pull request! 64 | 65 | If there are larger changes or features that you would like to work on, please raise an issue on GitHub first to discuss. 66 | 67 | ### License 68 | 69 | spr is [MIT licensed](./LICENSE). 70 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | 9 | use unicode_normalization::UnicodeNormalization; 10 | 11 | pub fn slugify(s: &str) -> String { 12 | s.trim() 13 | .nfd() 14 | .map(|c| if c.is_whitespace() { '-' } else { c }) 15 | .filter(|c| c.is_ascii_alphanumeric() || c == &'_' || c == &'-') 16 | .map(|c| char::to_ascii_lowercase(&c)) 17 | .scan(None, |last_char, char| { 18 | if char == '-' && last_char == &Some('-') { 19 | Some(None) 20 | } else { 21 | *last_char = Some(char); 22 | Some(Some(char)) 23 | } 24 | }) 25 | .flatten() 26 | .collect() 27 | } 28 | 29 | pub fn parse_name_list(text: &str) -> Vec { 30 | lazy_regex::regex!(r#"\(.*?\)"#) 31 | .replace_all(text, ",") 32 | .split(',') 33 | .map(|name| name.trim()) 34 | .filter(|name| !name.is_empty()) 35 | .map(String::from) 36 | .collect() 37 | } 38 | 39 | pub fn remove_all_parens(text: &str) -> String { 40 | lazy_regex::regex!(r#"[()]"#).replace_all(text, "").into() 41 | } 42 | 43 | #[cfg(test)] 44 | mod tests { 45 | // Note this useful idiom: importing names from outer (for mod tests) scope. 46 | use super::*; 47 | 48 | #[test] 49 | fn test_empty() { 50 | assert_eq!(slugify(""), "".to_string()); 51 | } 52 | 53 | #[test] 54 | fn test_hello_world() { 55 | assert_eq!(slugify(" Hello World! "), "hello-world".to_string()); 56 | } 57 | 58 | #[test] 59 | fn test_accents() { 60 | assert_eq!(slugify("ĥêlļō ŵöřľď"), "hello-world".to_string()); 61 | } 62 | 63 | #[test] 64 | fn test_parse_name_list_empty() { 65 | assert!(parse_name_list("").is_empty()); 66 | assert!(parse_name_list(" ").is_empty()); 67 | assert!(parse_name_list(" ").is_empty()); 68 | assert!(parse_name_list(" ").is_empty()); 69 | assert!(parse_name_list("\n").is_empty()); 70 | assert!(parse_name_list(" \n ").is_empty()); 71 | } 72 | 73 | #[test] 74 | fn test_parse_name_single_name() { 75 | assert_eq!(parse_name_list("foo"), vec!["foo".to_string()]); 76 | assert_eq!(parse_name_list("foo "), vec!["foo".to_string()]); 77 | assert_eq!(parse_name_list(" foo"), vec!["foo".to_string()]); 78 | assert_eq!(parse_name_list(" foo "), vec!["foo".to_string()]); 79 | assert_eq!(parse_name_list("foo (Foo Bar)"), vec!["foo".to_string()]); 80 | assert_eq!( 81 | parse_name_list(" foo (Foo Bar) "), 82 | vec!["foo".to_string()] 83 | ); 84 | assert_eq!( 85 | parse_name_list(" () (-)foo (Foo Bar) (xx)"), 86 | vec!["foo".to_string()] 87 | ); 88 | } 89 | 90 | #[test] 91 | fn test_parse_name_multiple_names() { 92 | let expected = 93 | vec!["foo".to_string(), "bar".to_string(), "baz".to_string()]; 94 | assert_eq!(parse_name_list("foo,bar,baz"), expected); 95 | assert_eq!(parse_name_list("foo, bar, baz"), expected); 96 | assert_eq!(parse_name_list("foo , bar , baz"), expected); 97 | assert_eq!( 98 | parse_name_list("foo (Mr Foo), bar (Ms Bar), baz (Dr Baz)"), 99 | expected 100 | ); 101 | assert_eq!( 102 | parse_name_list( 103 | "foo (Mr Foo) bar (Ms Bar) (the other one), baz (Dr Baz)" 104 | ), 105 | expected 106 | ); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /docs/reference/how-it-works-simple.md: -------------------------------------------------------------------------------- 1 | # How it works - Simple PR 2 | 3 | This section describes how `spr` works from a git perspective. 4 | This is not required to use `spr`, 5 | but more to understand how it works. 6 | 7 | It follows the [simple PR](../user/simple.md) workflow. 8 | Understanding that workflow will help understand the decisions made here. 9 | 10 | ## Creating the PR 11 | 12 | Let's say you have a repo with a `main` branch: 13 | 14 | ```mermaid 15 | gitGraph 16 | commit id: "A" 17 | commit id: "B" 18 | ``` 19 | 20 | Now you want to make a change. 21 | You make a commit on the `main` branch with your change and the commit id is `C1`: 22 | 23 | ```mermaid 24 | gitGraph 25 | commit id: "A" 26 | commit id: "B" 27 | commit id: "C1" 28 | ``` 29 | 30 | When you are ready to submit a PR, you run `spr diff` from the head commit of the `main` branch (`C1`). 31 | This will create a transient branch that is only used to create a PR on GitHub: 32 | 33 | ```mermaid 34 | gitGraph 35 | commit id: "A" 36 | commit id: "B" 37 | branch spr/username/commit-title-of-C1 38 | checkout main 39 | commit id: "C1" 40 | checkout spr/username/commit-title-of-C1 41 | commit id: "B->C1" 42 | ``` 43 | 44 | This `spr/username/commit-title-of-C1` branch is pushed to GitHub and used to open a PR against the `main` branch. 45 | The transient branch is not something you really need to directly interact with; 46 | `spr` takes care of keeping it up to date, creating the correct commits, etc. 47 | All you need to do is continue working on the `main` branch. 48 | 49 | The `C1` commit is updated with a few sections from the PR information. 50 | A `Pull Request` section is added that links to the PR that was created. 51 | This allows `spr` to know which branch/PR to update from only the commit. 52 | 53 | ## Amending the commit 54 | 55 | Let's say your PR needed some changes. 56 | What you'd do is make the changes to the commit that the PR was created from (in this case `C1`) 57 | amending the changes to the commit. 58 | 59 | ```mermaid 60 | gitGraph 61 | commit id: "A" 62 | commit id: "B" 63 | branch spr/username/commit-title-of-C1 64 | checkout main 65 | commit id: "C2" 66 | checkout spr/username/commit-title-of-C1 67 | commit id: "B->C1" 68 | ``` 69 | 70 | The next time that you use `spr diff`, 71 | it will compute the diff from `C1` to `C2`, 72 | and push that to GitHub as an additional commit: 73 | 74 | ```mermaid 75 | gitGraph 76 | commit id: "A" 77 | commit id: "B" 78 | branch spr/username/commit-title-of-C1 79 | checkout main 80 | commit id: "C2" 81 | checkout spr/username/commit-title-of-C1 82 | commit id: "B->C1" 83 | commit id: "C1->C2" 84 | ``` 85 | 86 | Pushing additional commits to the PR rather than rebasing the commits that are already on the PR works better with GitHub (discussions stay intact, commits aren't lost in the UI, changes between requests can be tracked, etc.). 87 | 88 | If you make more changes, 89 | it continues along this path: 90 | 91 | ```mermaid 92 | gitGraph 93 | commit id: "A" 94 | commit id: "B" 95 | branch spr/username/commit-title-of-C1 96 | checkout main 97 | commit id: "C3" 98 | checkout spr/username/commit-title-of-C1 99 | commit id: "B->C1" 100 | commit id: "C1->C2" 101 | commit id: "C2->C3" 102 | ``` 103 | 104 | ## Landing the change 105 | 106 | Once you're ready to merge the PR, 107 | you would use `spr land` to merge the PR. 108 | This will perform a squash merge on GitHub for the PR. 109 | Once the branch has been merged on GitHub, 110 | it will update the local `main` branch and delete the transient branch: 111 | 112 | ```mermaid 113 | gitGraph 114 | commit id: "A" 115 | commit id: "B" 116 | commit id: "C3" 117 | ``` 118 | -------------------------------------------------------------------------------- /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 color_eyre::eyre::{Result, bail}; 9 | 10 | use crate::{ 11 | git::PreparedCommit, 12 | git_remote::PushSpec, 13 | github::{PullRequestState, PullRequestUpdate}, 14 | message::MessageSection, 15 | output::{output, write_commit_title}, 16 | }; 17 | 18 | #[derive(Debug, clap::Parser)] 19 | pub struct CloseOptions { 20 | /// Close Pull Requests for the whole branch, not just the HEAD commit 21 | #[clap(long, short = 'a')] 22 | all: bool, 23 | } 24 | 25 | pub async fn close( 26 | opts: CloseOptions, 27 | git: &crate::git::Git, 28 | gh: &mut crate::github::GitHub, 29 | _config: &crate::config::Config, 30 | ) -> Result<()> { 31 | let mut result = Ok(()); 32 | 33 | let mut prepared_commits = gh.get_prepared_commits()?; 34 | 35 | if prepared_commits.is_empty() { 36 | output("👋", "Branch is empty - nothing to do. Good bye!")?; 37 | return result; 38 | }; 39 | 40 | if !opts.all { 41 | // Remove all prepared commits from the vector but the last. So, if 42 | // `--all` is not given, we only operate on the HEAD commit. 43 | prepared_commits.drain(0..prepared_commits.len() - 1); 44 | } 45 | 46 | for prepared_commit in prepared_commits.iter_mut() { 47 | if result.is_err() { 48 | break; 49 | } 50 | 51 | write_commit_title(prepared_commit)?; 52 | 53 | // The further implementation of the close command is in a separate function. 54 | // This makes it easier to run the code to update the local commit message 55 | // with all the changes that the implementation makes at the end, even if 56 | // the implementation encounters an error or exits early. 57 | result = close_impl(gh, prepared_commit).await; 58 | } 59 | 60 | // This updates the commit message in the local Git repository (if it was 61 | // changed by the implementation) 62 | git.rewrite_commit_messages(prepared_commits.as_mut_slice(), None)?; 63 | 64 | result 65 | } 66 | 67 | async fn close_impl( 68 | gh: &mut crate::github::GitHub, 69 | prepared_commit: &mut PreparedCommit, 70 | ) -> Result<()> { 71 | let pull_request_number = 72 | if let Some(number) = prepared_commit.pull_request_number { 73 | output("#️⃣ ", &format!("Pull Request #{}", number))?; 74 | number 75 | } else { 76 | bail!("This commit does not refer to a Pull Request."); 77 | }; 78 | 79 | // Load Pull Request information 80 | let pull_request = gh.clone().get_pull_request(pull_request_number).await?; 81 | 82 | if pull_request.state != PullRequestState::Open { 83 | bail!("This Pull Request is already closed!"); 84 | } 85 | 86 | output("📖", "Getting started...")?; 87 | 88 | let base_is_master = pull_request.base.is_master_branch(); 89 | 90 | let result = gh 91 | .update_pull_request( 92 | pull_request_number, 93 | PullRequestUpdate { 94 | state: Some(PullRequestState::Closed), 95 | ..Default::default() 96 | }, 97 | ) 98 | .await; 99 | 100 | match result { 101 | Ok(()) => (), 102 | Err(error) => { 103 | output("❌", "GitHub Pull Request close failed")?; 104 | 105 | return Err(error); 106 | } 107 | }; 108 | 109 | output("📕", "Closed!")?; 110 | 111 | // Remove sections from commit that are not relevant after closing. 112 | prepared_commit.message.remove(&MessageSection::PullRequest); 113 | prepared_commit.message.remove(&MessageSection::ReviewedBy); 114 | 115 | let mut push_specs = vec![PushSpec { 116 | oid: None, 117 | remote_ref: pull_request.head.on_github(), 118 | }]; 119 | 120 | if !base_is_master { 121 | push_specs.push(PushSpec { 122 | oid: None, 123 | remote_ref: pull_request.base.on_github(), 124 | }); 125 | } 126 | 127 | gh.remote().push_to_remote(&push_specs)?; 128 | 129 | Ok(()) 130 | } 131 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 color_eyre::eyre::Result; 9 | 10 | use crate::{ 11 | message::{MessageSection, build_commit_message}, 12 | output::output, 13 | }; 14 | 15 | #[derive(Debug, clap::Parser)] 16 | pub struct PatchOptions { 17 | /// Pull Request number 18 | pull_request: u64, 19 | 20 | /// Name of the branch to be created. Defaults to `PR-` 21 | #[clap(long)] 22 | branch_name: Option, 23 | 24 | /// If given, create new branch but do not check out 25 | #[clap(long)] 26 | no_checkout: bool, 27 | } 28 | 29 | pub async fn patch( 30 | opts: PatchOptions, 31 | git: &crate::git::Git, 32 | gh: &mut crate::github::GitHub, 33 | config: &crate::config::Config, 34 | ) -> Result<()> { 35 | let pr = gh.clone().get_pull_request(opts.pull_request).await?; 36 | output( 37 | "#️⃣ ", 38 | &format!( 39 | "Pull Request #{}: {}", 40 | pr.number, 41 | pr.sections 42 | .get(&MessageSection::Title) 43 | .map(|s| &s[..]) 44 | .unwrap_or("(no title)") 45 | ), 46 | )?; 47 | 48 | let branch_name = if let Some(name) = opts.branch_name { 49 | name 50 | } else { 51 | git.get_pr_patch_branch_name(pr.number)? 52 | }; 53 | 54 | let patch_branch_oid = if let Some(oid) = pr.merge_commit { 55 | output("❗", "Pull Request has been merged")?; 56 | 57 | oid 58 | } else { 59 | // Current oid of the master branch 60 | let current_master_oid = 61 | gh.remote().fetch_branch(config.master_ref.branch_name())?; 62 | 63 | // The parent commit to base the new PR branch on shall be the master 64 | // commit this PR is based on 65 | let mut pr_master_oid = 66 | git.repo().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 = git.repo().merge_base(pr.head_oid, pr.base_oid)?; 75 | 76 | if pr_base_oid != pr_master_oid { 77 | // So the commit the PR is based on is not the same as the master 78 | // commit it's based on. This means there must be a base branch that 79 | // contains additional commits. We want to squash those changes into 80 | // one commit that we then title "Base of Pull Reqeust #x". 81 | // Oh, one more thing. The base commit might not be on master, but 82 | // if it, for whatever reason, contains the same tree as the master 83 | // base, the base commit we construct here would turn out to be 84 | // empty. No point in creating an empty commit, so let's first check 85 | // whether base tree and master tree are different. 86 | let pr_base_tree = git.get_tree_oid_for_commit(pr.base_oid)?; 87 | let master_tree = git.get_tree_oid_for_commit(pr_master_oid)?; 88 | 89 | if pr_base_tree != master_tree { 90 | // The base of this PR is not on master. We need to create two 91 | // commits on the new branch we are making. First, a commit that 92 | // represents the base of the PR. And then second, the commit 93 | // that represents the contents of the PR. 94 | 95 | pr_master_oid = git.create_derived_commit( 96 | pr_base_oid, 97 | &format!("[𝘀𝗽𝗿] Base of Pull Request #{}", pr.number), 98 | pr_base_tree, 99 | &[pr_master_oid], 100 | )?; 101 | } 102 | } 103 | 104 | // Create the main commit for the patch branch. This is based on a 105 | // master commit, or, if the PR can't be based on master directly, on 106 | // the commit we created above to prepare the base of this commit. 107 | git.create_derived_commit( 108 | pr.head_oid, 109 | &build_commit_message(&pr.sections), 110 | git.get_tree_oid_for_commit(pr.head_oid)?, 111 | &[pr_master_oid], 112 | )? 113 | }; 114 | 115 | let repo = git.repo(); 116 | let patch_branch_commit = repo.find_commit(patch_branch_oid)?; 117 | 118 | // Create the new branch, now that we know the commit it shall point to 119 | repo.branch(&branch_name, &patch_branch_commit, true)?; 120 | 121 | output("🌱", &format!("Created new branch: {}", &branch_name))?; 122 | 123 | if !opts.no_checkout { 124 | // Check out the new branch 125 | repo.checkout_tree(patch_branch_commit.as_object(), None)?; 126 | repo.set_head(&format!("refs/heads/{}", branch_name))?; 127 | output("✅", "Checked out")?; 128 | } 129 | 130 | Ok(()) 131 | } 132 | -------------------------------------------------------------------------------- /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 color_eyre::eyre::Result; 9 | 10 | use crate::github::GitHubBranch; 11 | 12 | #[derive(Clone, Debug)] 13 | pub struct Config { 14 | pub owner: String, 15 | pub repo: String, 16 | pub master_ref: GitHubBranch, 17 | pub branch_prefix: String, 18 | pub auth_token: String, 19 | pub require_approval: bool, 20 | pub require_test_plan: bool, 21 | } 22 | 23 | impl Config { 24 | #[allow(clippy::too_many_arguments)] 25 | pub fn new( 26 | owner: String, 27 | repo: String, 28 | master_branch: String, 29 | branch_prefix: String, 30 | auth_token: String, 31 | require_approval: bool, 32 | require_test_plan: bool, 33 | ) -> Self { 34 | let master_ref = 35 | GitHubBranch::new_from_branch_name(&master_branch, &master_branch); 36 | Self { 37 | owner, 38 | repo, 39 | master_ref, 40 | branch_prefix, 41 | auth_token, 42 | require_approval, 43 | require_test_plan, 44 | } 45 | } 46 | 47 | pub fn pull_request_url(&self, number: u64) -> String { 48 | format!( 49 | "https://github.com/{owner}/{repo}/pull/{number}", 50 | owner = &self.owner, 51 | repo = &self.repo 52 | ) 53 | } 54 | 55 | pub fn parse_pull_request_field(&self, text: &str) -> Option { 56 | if text.is_empty() { 57 | return None; 58 | } 59 | 60 | let regex = lazy_regex::regex!(r#"^\s*#?\s*(\d+)\s*$"#); 61 | let m = regex.captures(text); 62 | if let Some(caps) = m { 63 | return Some(caps.get(1).unwrap().as_str().parse().unwrap()); 64 | } 65 | 66 | let regex = lazy_regex::regex!( 67 | r#"^\s*https?://github.com/([\w\-\.]+)/([\w\-\.]+)/pull/(\d+)([/?#].*)?\s*$"# 68 | ); 69 | let m = regex.captures(text); 70 | if let Some(caps) = m 71 | && self.owner == caps.get(1).unwrap().as_str() 72 | && self.repo == caps.get(2).unwrap().as_str() 73 | { 74 | return Some(caps.get(3).unwrap().as_str().parse().unwrap()); 75 | } 76 | 77 | None 78 | } 79 | 80 | pub fn new_github_branch_from_ref( 81 | &self, 82 | ghref: &str, 83 | ) -> Result { 84 | GitHubBranch::new_from_ref(ghref, self.master_ref.branch_name()) 85 | } 86 | 87 | pub fn new_github_branch(&self, branch_name: &str) -> GitHubBranch { 88 | GitHubBranch::new_from_branch_name( 89 | branch_name, 90 | self.master_ref.branch_name(), 91 | ) 92 | } 93 | } 94 | 95 | #[cfg(test)] 96 | mod tests { 97 | // Note this useful idiom: importing names from outer (for mod tests) scope. 98 | use super::*; 99 | 100 | fn config_factory() -> Config { 101 | crate::config::Config::new( 102 | "acme".into(), 103 | "codez".into(), 104 | "master".into(), 105 | "spr/foo/".into(), 106 | "xyz".into(), 107 | false, 108 | true, 109 | ) 110 | } 111 | 112 | #[test] 113 | fn test_pull_request_url() { 114 | let gh = config_factory(); 115 | 116 | assert_eq!( 117 | &gh.pull_request_url(123), 118 | "https://github.com/acme/codez/pull/123" 119 | ); 120 | } 121 | 122 | #[test] 123 | fn test_parse_pull_request_field_empty() { 124 | let gh = config_factory(); 125 | 126 | assert_eq!(gh.parse_pull_request_field(""), None); 127 | assert_eq!(gh.parse_pull_request_field(" "), None); 128 | assert_eq!(gh.parse_pull_request_field("\n"), None); 129 | } 130 | 131 | #[test] 132 | fn test_parse_pull_request_field_number() { 133 | let gh = config_factory(); 134 | 135 | assert_eq!(gh.parse_pull_request_field("123"), Some(123)); 136 | assert_eq!(gh.parse_pull_request_field(" 123 "), Some(123)); 137 | assert_eq!(gh.parse_pull_request_field("#123"), Some(123)); 138 | assert_eq!(gh.parse_pull_request_field(" # 123"), Some(123)); 139 | } 140 | 141 | #[test] 142 | fn test_parse_pull_request_field_url() { 143 | let gh = config_factory(); 144 | 145 | assert_eq!( 146 | gh.parse_pull_request_field( 147 | "https://github.com/acme/codez/pull/123" 148 | ), 149 | Some(123) 150 | ); 151 | assert_eq!( 152 | gh.parse_pull_request_field( 153 | " https://github.com/acme/codez/pull/123 " 154 | ), 155 | Some(123) 156 | ); 157 | assert_eq!( 158 | gh.parse_pull_request_field( 159 | "https://github.com/acme/codez/pull/123/" 160 | ), 161 | Some(123) 162 | ); 163 | assert_eq!( 164 | gh.parse_pull_request_field( 165 | "https://github.com/acme/codez/pull/123?x=a" 166 | ), 167 | Some(123) 168 | ); 169 | assert_eq!( 170 | gh.parse_pull_request_field( 171 | "https://github.com/acme/codez/pull/123/foo" 172 | ), 173 | Some(123) 174 | ); 175 | assert_eq!( 176 | gh.parse_pull_request_field( 177 | "https://github.com/acme/codez/pull/123#abc" 178 | ), 179 | Some(123) 180 | ); 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | ## [1.3.7] - 2025-08-25 6 | 7 | ### Improvements 8 | 9 | - use GitHub device auth flow for obtaining token 10 | - include all source errors in generic error handling (@quodlibetor) 11 | 12 | ### Fixes 13 | 14 | - fix octocrab routes (include leading slash) (@yamadapc) 15 | - fix clippy warnings 16 | 17 | ## [1.3.6] - 2025-04-27 18 | 19 | ### Fixes 20 | 21 | - fix regex to recognise GitHub owner and repo names that contain a period character (`.`) (@sqwxl) 22 | - move ownership of the spr repository in GitHub from getcord (the no longer existing command) to spacedentist (the primary author's personal account) 23 | - fix references to getcord (@AlbertQM) 24 | - fix build problems with Rust 1.80 (@chenrui333) 25 | - command line option `--branch-prefix` was ignored (@davinkevin) 26 | - add `--github-master-branch` command line option (@davinkevin) 27 | - update dependencies, fix warnings 28 | 29 | ### Improvements 30 | 31 | - use opentls instead of rustls in order to use system CAs, update octocrab to 0.38 for that (@mayanez) 32 | - add more documentation: how it works for simple PRs (@joneshf) 33 | - make GraphQL queries through octocrab instead of reqwest (@jtietema) 34 | - add `--refs` option to spr diff (@DylanZA) 35 | 36 | ## [1.3.5] - 2023-11-02 37 | 38 | ### Fixes 39 | 40 | - don't line-wrap URLs (@keyz) 41 | - fix base branch name for github protected branches (@rockwotj) 42 | - fix clippy warnings (@spacedentist) 43 | 44 | ### Improvements 45 | 46 | - turn repository into Cargo workspace (@spacedentist) 47 | - documentation improvements (@spacedentist) 48 | - add shorthand for `--all` (@rockwotj) 49 | - don't fetch all users/teams to check reviewers (@andrewhamon) 50 | - add refname checking (@cadolphs) 51 | - run post-rewrite hooks (@jwatzman) 52 | 53 | ## [1.3.4] - 2022-07-18 54 | 55 | ### Improvements 56 | 57 | - add config option to make test plan optional (@orausch) 58 | - add comprehensive documentation (@oyamauchi) 59 | - add a `close` command (@joneshf) 60 | - allow `spr format` to be used without GitHub credentials 61 | - don't fail on requesting reviewers (@joneshf) 62 | 63 | ## [1.3.3] - 2022-06-27 64 | 65 | ### Fixes 66 | 67 | - get rid of italics in generated commit messages - they're silly 68 | - fix unneccessary creation of base branches when updating PRs 69 | - when updating an existing PR, merge in master commit if the commit was rebased even if the base tree did not change 70 | - 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 71 | 72 | ### Improvemets 73 | 74 | - add spr version number in PR commit messages 75 | - add `--all` option to `spr diff` for operating on a stack of commits 76 | - updated Rust dependencies 77 | 78 | ## [1.3.2] - 2022-06-16 79 | 80 | ### Fixes 81 | 82 | - fix list of required GitHub permissions in `spr init` message 83 | - fix aborting Pull Request update by entering empty message on prompt 84 | - fix a problem where occasionally `spr diff` would fail because it could not push the base branch to GitHub 85 | 86 | ### Improvements 87 | 88 | - add `spr.requireApprovals` config field to control if spr enforces that only accepted PRs can be landed 89 | - the spr binary no longer depends on openssl 90 | - add documentation to the docs/ folder 91 | - `spr diff` now warns the user if the local commit message differs from the one on GitHub when updating an existing Pull Request 92 | 93 | ## [1.3.1] - 2022-06-10 94 | 95 | ### Fixes 96 | 97 | - register base branch at PR creation time instead of after 98 | - fix `--update-message` option of `spr diff` when invoked without making changes to the commit tree 99 | 100 | ### Security 101 | 102 | - remove dependency on `failure` to fix CVE-2019-25010 103 | 104 | ## [1.3.0] - 2022-06-01 105 | 106 | ### Improvements 107 | 108 | - make land command reject local changes on land 109 | - replace `--base` option with `--cherry-pick` in `spr diff` 110 | - add `--cherry-pick` option to `spr land` 111 | 112 | ## [1.2.4] - 2022-05-26 113 | 114 | ### Fixes 115 | 116 | - fix working with repositories not owned by an organization but by a user 117 | 118 | ## [1.2.3] - 2022-05-24 119 | 120 | ### Fixes 121 | 122 | - fix building with homebrew-installed Rust (currently 1.59) 123 | 124 | ## [1.2.2] - 2022-05-23 125 | 126 | ### Fixes 127 | 128 | - fix clippy warnings 129 | 130 | ### Improvements 131 | 132 | - clean-up `Cargo.toml` and update dependencies 133 | - add to `README.md` 134 | 135 | ## [1.2.1] - 2022-04-21 136 | 137 | ### Fixes 138 | 139 | - fix calculating base of PR for the `spr patch` command 140 | 141 | ## [1.2.0] - 2022-04-21 142 | 143 | ### Improvements 144 | 145 | - 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`.) 146 | - add new command `spr patch` to locally check out a Pull Request from GitHub 147 | 148 | ## [1.1.0] - 2022-03-18 149 | 150 | ### Fixes 151 | 152 | - set timestamps of PR commits to time of submitting, not the time the local commit was originally authored/committed 153 | 154 | ### Improvements 155 | 156 | - add `spr list` command, which lists the user's Pull Requests with their status 157 | - use `--no-verify` option for all git pushes 158 | 159 | ## [1.0.0] - 2022-02-10 160 | 161 | ### Added 162 | 163 | - Initial release 164 | 165 | [1.0.0]: https://github.com/spacedentist/spr/releases/tag/v1.0.0 166 | [1.1.0]: https://github.com/spacedentist/spr/releases/tag/v1.1.0 167 | [1.2.0]: https://github.com/spacedentist/spr/releases/tag/v1.2.0 168 | [1.2.1]: https://github.com/spacedentist/spr/releases/tag/v1.2.1 169 | [1.2.2]: https://github.com/spacedentist/spr/releases/tag/v1.2.2 170 | [1.2.3]: https://github.com/spacedentist/spr/releases/tag/v1.2.3 171 | [1.2.4]: https://github.com/spacedentist/spr/releases/tag/v1.2.4 172 | [1.3.0]: https://github.com/spacedentist/spr/releases/tag/v1.3.0 173 | [1.3.1]: https://github.com/spacedentist/spr/releases/tag/v1.3.1 174 | [1.3.2]: https://github.com/spacedentist/spr/releases/tag/v1.3.2 175 | [1.3.3]: https://github.com/spacedentist/spr/releases/tag/v1.3.3 176 | [1.3.4]: https://github.com/spacedentist/spr/releases/tag/v1.3.4 177 | [1.3.5]: https://github.com/spacedentist/spr/releases/tag/v1.3.5 178 | [1.3.6]: https://github.com/spacedentist/spr/releases/tag/v1.3.6 179 | [1.3.7]: https://github.com/spacedentist/spr/releases/tag/v1.3.7 180 | -------------------------------------------------------------------------------- /src/git_remote.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{HashMap, HashSet}, 3 | fmt::Write as _, 4 | }; 5 | 6 | use color_eyre::eyre::{Result, WrapErr, eyre}; 7 | use git2::{Oid, PushOptions, RemoteCallbacks}; 8 | use log::{debug, trace, warn}; 9 | 10 | #[derive(Clone)] 11 | pub struct GitRemote { 12 | repo: std::sync::Arc, 13 | url: String, 14 | auth_token: String, 15 | } 16 | 17 | impl GitRemote { 18 | pub fn new( 19 | repo: std::sync::Arc, 20 | url: String, 21 | auth_token: String, 22 | ) -> Self { 23 | Self { 24 | repo, 25 | url, 26 | auth_token, 27 | } 28 | } 29 | 30 | fn with_connection(&self, dir: git2::Direction, func: F) -> Result 31 | where 32 | F: FnOnce(&mut git2::RemoteConnection) -> Result, 33 | { 34 | let mut remote = self.repo.remote_anonymous(&self.url)?; 35 | let mut cb = git2::RemoteCallbacks::new(); 36 | cb.credentials(move |_url, _username, _allowed_types| { 37 | git2::Cred::userpass_plaintext("spr", &self.auth_token) 38 | }); 39 | let mut connection = 40 | remote.connect_auth(dir, Some(cb), None).wrap_err_with(|| { 41 | format!("Connection to git remote failed, url: {}", &self.url) 42 | })?; 43 | log::trace!("Connected to remote {} ({:?})", &self.url, dir); 44 | 45 | let result = func(&mut connection)?; 46 | 47 | connection.remote().disconnect()?; 48 | log::trace!("Disconnected from remote {}", &self.url); 49 | 50 | Ok(result) 51 | } 52 | 53 | fn get_branches_from_connection( 54 | connection: &mut git2::RemoteConnection, 55 | ) -> Result> { 56 | Ok(connection 57 | .remote() 58 | .list()? 59 | .iter() 60 | .filter(|&rh| !rh.oid().is_zero()) 61 | .filter_map(|rh| { 62 | rh.name() 63 | .strip_prefix("refs/heads/") 64 | .map(|branch| (branch.to_string(), rh.oid())) 65 | }) 66 | .collect()) 67 | } 68 | 69 | pub fn get_branches(&self) -> Result> { 70 | self.with_connection( 71 | git2::Direction::Fetch, 72 | Self::get_branches_from_connection, 73 | ) 74 | } 75 | 76 | pub fn fetch_from_remote( 77 | &self, 78 | branch_names: &[&str], 79 | commit_oids: &[Oid], 80 | ) -> Result>> { 81 | if branch_names.is_empty() && commit_oids.is_empty() { 82 | return Ok(Vec::new()); 83 | } 84 | 85 | let mut ref_oids = Vec::>::new(); 86 | let mut fetch_oids: HashSet = 87 | commit_oids.iter().cloned().collect(); 88 | 89 | self.with_connection(git2::Direction::Fetch, move |connection| { 90 | if !branch_names.is_empty() { 91 | let remote_branches = 92 | Self::get_branches_from_connection(connection)?; 93 | 94 | for &branch_name in branch_names.iter() { 95 | let oid = remote_branches.get(branch_name).cloned(); 96 | ref_oids.push(oid); 97 | fetch_oids.extend(oid.iter()); 98 | debug!("fetching branch {}: {:?}", branch_name, oid); 99 | } 100 | } 101 | 102 | if !fetch_oids.is_empty() { 103 | let fetch_oids = 104 | fetch_oids.iter().map(Oid::to_string).collect::>(); 105 | debug!("fetching oids: {:?}", &fetch_oids); 106 | 107 | let mut fetch_options = git2::FetchOptions::new(); 108 | fetch_options.update_fetchhead(false); 109 | fetch_options.download_tags(git2::AutotagOption::None); 110 | connection.remote().download( 111 | fetch_oids.as_slice(), 112 | Some(&mut fetch_options), 113 | )?; 114 | } 115 | 116 | Ok(ref_oids) 117 | }) 118 | } 119 | 120 | pub fn fetch_branch(&self, branch_name: &str) -> Result { 121 | self.fetch_from_remote(&[branch_name], &[])? 122 | .first() 123 | .and_then(|&x| x) 124 | .ok_or_else(|| eyre!("Could not fetch branch '{}'", branch_name)) 125 | } 126 | 127 | pub fn push_to_remote(&self, refs: &[PushSpec]) -> Result<()> { 128 | self.with_connection(git2::Direction::Push, move |connection| { 129 | let push_specs: Vec = 130 | refs.iter().map(ToString::to_string).collect(); 131 | let push_specs: Vec<&str> = 132 | push_specs.iter().map(String::as_str).collect(); 133 | 134 | let mut cbs = RemoteCallbacks::new(); 135 | cbs.push_update_reference(|ref_name, msg| { 136 | if let Some(msg) = msg { 137 | let error = format!("Push {} rejected: {}", ref_name, msg); 138 | warn!("{}", &error); 139 | Err(git2::Error::from_str(&error)) 140 | } else { 141 | trace!("Pushed {}", ref_name); 142 | Ok(()) 143 | } 144 | }); 145 | let mut po = PushOptions::new(); 146 | po.remote_callbacks(cbs); 147 | 148 | debug!("Push specs: {:?}", &push_specs); 149 | connection 150 | .remote() 151 | .push(push_specs.as_slice(), Some(&mut po))?; 152 | 153 | Ok(()) 154 | }) 155 | } 156 | 157 | pub fn find_unused_branch_name( 158 | &self, 159 | branch_prefix: &str, 160 | slug: &str, 161 | ) -> Result { 162 | let existing_branch_names = self.with_connection( 163 | git2::Direction::Fetch, 164 | Self::get_branches_from_connection, 165 | )?; 166 | 167 | let mut branch_name = format!("{branch_prefix}{slug}"); 168 | let mut suffix = 0; 169 | 170 | loop { 171 | if !existing_branch_names.contains_key(&branch_name) { 172 | return Ok(branch_name); 173 | } 174 | 175 | suffix += 1; 176 | branch_name = format!("{branch_prefix}{slug}-{suffix}"); 177 | } 178 | } 179 | } 180 | 181 | pub struct PushSpec<'a> { 182 | pub oid: Option, 183 | pub remote_ref: &'a str, 184 | } 185 | 186 | impl<'a> std::fmt::Display for PushSpec<'a> { 187 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 188 | if let Some(oid) = self.oid { 189 | oid.fmt(f)?; 190 | } 191 | f.write_char(':')?; 192 | f.write_str(self.remote_ref) 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /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 color_eyre::eyre::{Error, Result, eyre}; 14 | use log::debug; 15 | use spr::commands; 16 | 17 | #[derive(Parser, Debug)] 18 | #[clap( 19 | name = "spr", 20 | version, 21 | about = "Submit pull requests for individual, amendable, rebaseable commits to GitHub" 22 | )] 23 | pub struct Cli { 24 | /// Change to DIR before performing any operations 25 | #[clap(long, value_name = "DIR")] 26 | cd: Option, 27 | 28 | /// GitHub personal access token (if not given taken from git config 29 | /// spr.githubAuthToken) 30 | #[clap(long)] 31 | github_auth_token: Option, 32 | 33 | /// GitHub repository ('org/name', if not given taken from config 34 | /// spr.githubRepository) 35 | #[clap(long)] 36 | github_repository: Option, 37 | 38 | /// The name of the centrally shared branch into which the pull requests are merged 39 | /// spr.githubMasterBranch) 40 | #[clap(long)] 41 | github_master_branch: Option, 42 | 43 | /// prefix to be used for branches created for pull requests (if not given 44 | /// taken from git config spr.branchPrefix, defaulting to 45 | /// 'spr//') 46 | #[clap(long)] 47 | branch_prefix: Option, 48 | 49 | #[clap(subcommand)] 50 | command: Commands, 51 | } 52 | 53 | #[derive(Subcommand, Debug)] 54 | enum Commands { 55 | /// Interactive assistant for configuring spr in a local GitHub-backed Git 56 | /// repository 57 | Init, 58 | 59 | /// Create a new or update an existing Pull Request on GitHub from the 60 | /// current HEAD commit 61 | Diff(commands::diff::DiffOptions), 62 | 63 | /// Reformat commit message 64 | Format(commands::format::FormatOptions), 65 | 66 | /// Land a reviewed Pull Request 67 | Land(commands::land::LandOptions), 68 | 69 | /// Update local commit message with content on GitHub 70 | Amend(commands::amend::AmendOptions), 71 | 72 | /// List open Pull Requests on GitHub and their review decision 73 | List, 74 | 75 | /// Create a new branch with the contents of an existing Pull Request 76 | Patch(commands::patch::PatchOptions), 77 | 78 | /// Close a Pull request 79 | Close(commands::close::CloseOptions), 80 | } 81 | 82 | pub async fn spr() -> Result<()> { 83 | let cli = Cli::parse(); 84 | debug!("Started with command line: {:?}", cli); 85 | 86 | if let Some(path) = &cli.cd 87 | && let Err(err) = std::env::set_current_dir(path) 88 | { 89 | eprintln!("Could not change directory to {:?}", &path); 90 | return Err(err.into()); 91 | } 92 | 93 | if let Commands::Init = cli.command { 94 | return commands::init::init().await; 95 | } 96 | 97 | let repo = git2::Repository::discover(std::env::current_dir()?)?; 98 | 99 | let git_config = repo.config()?; 100 | 101 | let github_repository = match cli.github_repository { 102 | Some(v) => Ok(v), 103 | None => git_config.get_string("spr.githubRepository"), 104 | }?; 105 | 106 | let github_master_branch = match cli.github_master_branch { 107 | Some(v) => Ok::(v), 108 | None => git_config 109 | .get_string("spr.githubMasterBranch") 110 | .or_else(|_| Ok("master".to_string())), 111 | }?; 112 | 113 | let branch_prefix = match cli.branch_prefix { 114 | Some(v) => Ok(v), 115 | None => git_config.get_string("spr.branchPrefix"), 116 | }?; 117 | 118 | let (github_owner, github_repo) = { 119 | let captures = lazy_regex::regex!(r#"^([\w\-\.]+)/([\w\-\.]+)$"#) 120 | .captures(&github_repository) 121 | .ok_or_else(|| { 122 | eyre!( 123 | "GitHub repository must be given as 'OWNER/REPO', but given value was '{}'", 124 | &github_repository, 125 | ) 126 | })?; 127 | ( 128 | captures.get(1).unwrap().as_str().to_string(), 129 | captures.get(2).unwrap().as_str().to_string(), 130 | ) 131 | }; 132 | 133 | let require_approval = git_config 134 | .get_bool("spr.requireApproval") 135 | .ok() 136 | .unwrap_or(false); 137 | let require_test_plan = git_config 138 | .get_bool("spr.requireTestPlan") 139 | .ok() 140 | .unwrap_or(true); 141 | 142 | let github_auth_token = match cli.github_auth_token { 143 | Some(v) => Ok(v), 144 | None => git_config.get_string("spr.githubAuthToken"), 145 | }?; 146 | 147 | let config = spr::config::Config::new( 148 | github_owner, 149 | github_repo, 150 | github_master_branch, 151 | branch_prefix, 152 | github_auth_token.clone(), 153 | require_approval, 154 | require_test_plan, 155 | ); 156 | debug!("config: {:?}", config); 157 | 158 | let git = spr::git::Git::new(repo); 159 | 160 | octocrab::initialise( 161 | octocrab::Octocrab::builder() 162 | .personal_token(github_auth_token.clone()) 163 | .build()?, 164 | ); 165 | 166 | let mut gh = spr::github::GitHub::new( 167 | config.clone(), 168 | git.clone(), 169 | github_auth_token, 170 | ); 171 | 172 | match cli.command { 173 | Commands::Diff(opts) => { 174 | commands::diff::diff(opts, &git, &mut gh, &config).await? 175 | } 176 | Commands::Land(opts) => { 177 | commands::land::land(opts, &git, &mut gh, &config).await? 178 | } 179 | Commands::Amend(opts) => { 180 | commands::amend::amend(opts, &git, &mut gh, &config).await? 181 | } 182 | Commands::List => commands::list::list(&config).await?, 183 | Commands::Patch(opts) => { 184 | commands::patch::patch(opts, &git, &mut gh, &config).await? 185 | } 186 | Commands::Close(opts) => { 187 | commands::close::close(opts, &git, &mut gh, &config).await? 188 | } 189 | Commands::Format(opts) => { 190 | commands::format::format(opts, &git, &mut gh, &config).await? 191 | } 192 | 193 | // The following commands are executed above and return from this 194 | // function before it reaches this match. 195 | Commands::Init => (), 196 | }; 197 | 198 | Ok::<_, Error>(()) 199 | } 200 | 201 | #[tokio::main(flavor = "current_thread")] 202 | async fn main() -> Result<()> { 203 | env_logger::init(); 204 | 205 | tokio::task::LocalSet::new().run_until(spr()).await 206 | } 207 | -------------------------------------------------------------------------------- /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 color_eyre::eyre::{Result, bail}; 9 | 10 | use crate::output::output; 11 | 12 | pub type MessageSectionsMap = 13 | std::collections::BTreeMap; 14 | 15 | #[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Debug)] 16 | pub enum MessageSection { 17 | Title, 18 | Summary, 19 | TestPlan, 20 | Reviewers, 21 | ReviewedBy, 22 | PullRequest, 23 | } 24 | 25 | pub fn message_section_label(section: &MessageSection) -> &'static str { 26 | use MessageSection::*; 27 | 28 | match section { 29 | Title => "Title", 30 | Summary => "Summary", 31 | TestPlan => "Test Plan", 32 | Reviewers => "Reviewers", 33 | ReviewedBy => "Reviewed By", 34 | PullRequest => "Pull Request", 35 | } 36 | } 37 | 38 | pub fn message_section_by_label(label: &str) -> Option { 39 | use MessageSection::*; 40 | 41 | match &label.to_ascii_lowercase()[..] { 42 | "title" => Some(Title), 43 | "summary" => Some(Summary), 44 | "test plan" => Some(TestPlan), 45 | "reviewer" => Some(Reviewers), 46 | "reviewers" => Some(Reviewers), 47 | "reviewed by" => Some(ReviewedBy), 48 | "pull request" => Some(PullRequest), 49 | _ => None, 50 | } 51 | } 52 | 53 | pub fn parse_message( 54 | msg: &str, 55 | top_section: MessageSection, 56 | ) -> MessageSectionsMap { 57 | let regex = lazy_regex::regex!(r#"^\s*([\w\s]+?)\s*:\s*(.*)$"#); 58 | 59 | let mut section = top_section; 60 | let mut lines_in_section = Vec::<&str>::new(); 61 | let mut sections = 62 | std::collections::BTreeMap::::new(); 63 | 64 | for (lineno, line) in msg 65 | .trim() 66 | .split('\n') 67 | .map(|line| line.trim_end()) 68 | .enumerate() 69 | { 70 | if let Some(caps) = regex.captures(line) { 71 | let label = caps.get(1).unwrap().as_str(); 72 | let payload = caps.get(2).unwrap().as_str(); 73 | 74 | if let Some(new_section) = message_section_by_label(label) { 75 | append_to_message_section( 76 | sections.entry(section), 77 | lines_in_section.join("\n").trim(), 78 | ); 79 | section = new_section; 80 | lines_in_section = vec![payload]; 81 | continue; 82 | } 83 | } 84 | 85 | if lineno == 0 && top_section == MessageSection::Title { 86 | sections.insert(top_section, line.to_string()); 87 | section = MessageSection::Summary; 88 | } else { 89 | lines_in_section.push(line); 90 | } 91 | } 92 | 93 | if !lines_in_section.is_empty() { 94 | append_to_message_section( 95 | sections.entry(section), 96 | lines_in_section.join("\n").trim(), 97 | ); 98 | } 99 | 100 | sections 101 | } 102 | 103 | fn append_to_message_section( 104 | entry: std::collections::btree_map::Entry, 105 | text: &str, 106 | ) { 107 | if !text.is_empty() { 108 | entry 109 | .and_modify(|value| { 110 | if value.is_empty() { 111 | *value = text.to_string(); 112 | } else { 113 | *value = format!("{}\n\n{}", value, text); 114 | } 115 | }) 116 | .or_insert_with(|| text.to_string()); 117 | } else { 118 | entry.or_default(); 119 | } 120 | } 121 | 122 | pub fn build_message( 123 | section_texts: &MessageSectionsMap, 124 | sections: &[MessageSection], 125 | ) -> String { 126 | let mut result = String::new(); 127 | let mut display_label = false; 128 | 129 | for section in sections { 130 | let value = section_texts.get(section); 131 | if let Some(text) = value { 132 | if !result.is_empty() { 133 | result.push('\n'); 134 | } 135 | 136 | if section != &MessageSection::Title 137 | && section != &MessageSection::Summary 138 | { 139 | // Once we encounter a section that's neither Title nor Summary, 140 | // we start displaying the labels. 141 | display_label = true; 142 | } 143 | 144 | if display_label { 145 | let label = message_section_label(section); 146 | result.push_str(label); 147 | result.push_str( 148 | if label.len() + text.len() > 76 || text.contains('\n') { 149 | ":\n" 150 | } else { 151 | ": " 152 | }, 153 | ); 154 | } 155 | 156 | result.push_str(text); 157 | result.push('\n'); 158 | } 159 | } 160 | 161 | result 162 | } 163 | 164 | pub fn build_commit_message(section_texts: &MessageSectionsMap) -> String { 165 | build_message( 166 | section_texts, 167 | &[ 168 | MessageSection::Title, 169 | MessageSection::Summary, 170 | MessageSection::TestPlan, 171 | MessageSection::Reviewers, 172 | MessageSection::ReviewedBy, 173 | MessageSection::PullRequest, 174 | ], 175 | ) 176 | } 177 | 178 | pub fn build_github_body(section_texts: &MessageSectionsMap) -> String { 179 | build_message( 180 | section_texts, 181 | &[MessageSection::Summary, MessageSection::TestPlan], 182 | ) 183 | } 184 | 185 | pub fn build_github_body_for_merging( 186 | section_texts: &MessageSectionsMap, 187 | ) -> String { 188 | build_message( 189 | section_texts, 190 | &[ 191 | MessageSection::Summary, 192 | MessageSection::TestPlan, 193 | MessageSection::Reviewers, 194 | MessageSection::ReviewedBy, 195 | MessageSection::PullRequest, 196 | ], 197 | ) 198 | } 199 | 200 | pub fn validate_commit_message( 201 | message: &MessageSectionsMap, 202 | config: &crate::config::Config, 203 | ) -> Result<()> { 204 | if config.require_test_plan 205 | && !message.contains_key(&MessageSection::TestPlan) 206 | { 207 | output("💔", "Commit message does not have a Test Plan!")?; 208 | bail!("Commit message does not have a Test Plan!"); 209 | } 210 | 211 | let title_missing_or_empty = match message.get(&MessageSection::Title) { 212 | None => true, 213 | Some(title) => title.is_empty(), 214 | }; 215 | if title_missing_or_empty { 216 | output("💔", "Commit message does not have a title!")?; 217 | bail!("Commit message does not have a title!"); 218 | } 219 | 220 | Ok(()) 221 | } 222 | 223 | #[cfg(test)] 224 | mod tests { 225 | // Note this useful idiom: importing names from outer (for mod tests) scope. 226 | use super::*; 227 | 228 | #[test] 229 | fn test_parse_empty() { 230 | assert_eq!( 231 | parse_message("", MessageSection::Title), 232 | [(MessageSection::Title, "".to_string())].into() 233 | ); 234 | } 235 | 236 | #[test] 237 | fn test_parse_title() { 238 | assert_eq!( 239 | parse_message("Hello", MessageSection::Title), 240 | [(MessageSection::Title, "Hello".to_string())].into() 241 | ); 242 | assert_eq!( 243 | parse_message("Hello\n", MessageSection::Title), 244 | [(MessageSection::Title, "Hello".to_string())].into() 245 | ); 246 | assert_eq!( 247 | parse_message("\n\nHello\n\n", MessageSection::Title), 248 | [(MessageSection::Title, "Hello".to_string())].into() 249 | ); 250 | } 251 | 252 | #[test] 253 | fn test_parse_title_and_summary() { 254 | assert_eq!( 255 | parse_message("Hello\nFoo Bar", MessageSection::Title), 256 | [ 257 | (MessageSection::Title, "Hello".to_string()), 258 | (MessageSection::Summary, "Foo Bar".to_string()) 259 | ] 260 | .into() 261 | ); 262 | assert_eq!( 263 | parse_message("Hello\n\nFoo Bar", MessageSection::Title), 264 | [ 265 | (MessageSection::Title, "Hello".to_string()), 266 | (MessageSection::Summary, "Foo Bar".to_string()) 267 | ] 268 | .into() 269 | ); 270 | assert_eq!( 271 | parse_message("Hello\n\n\nFoo Bar", MessageSection::Title), 272 | [ 273 | (MessageSection::Title, "Hello".to_string()), 274 | (MessageSection::Summary, "Foo Bar".to_string()) 275 | ] 276 | .into() 277 | ); 278 | assert_eq!( 279 | parse_message("Hello\n\nSummary:\nFoo Bar", MessageSection::Title), 280 | [ 281 | (MessageSection::Title, "Hello".to_string()), 282 | (MessageSection::Summary, "Foo Bar".to_string()) 283 | ] 284 | .into() 285 | ); 286 | } 287 | 288 | #[test] 289 | fn test_parse_sections() { 290 | assert_eq!( 291 | parse_message( 292 | r#"Hello 293 | 294 | Test plan: testzzz 295 | 296 | Summary: 297 | here is 298 | the 299 | summary (it's not a "Test plan:"!) 300 | 301 | Reviewer: a, b, c"#, 302 | MessageSection::Title 303 | ), 304 | [ 305 | (MessageSection::Title, "Hello".to_string()), 306 | ( 307 | MessageSection::Summary, 308 | "here is\nthe\nsummary (it's not a \"Test plan:\"!)" 309 | .to_string() 310 | ), 311 | (MessageSection::TestPlan, "testzzz".to_string()), 312 | (MessageSection::Reviewers, "a, b, c".to_string()), 313 | ] 314 | .into() 315 | ); 316 | } 317 | } 318 | -------------------------------------------------------------------------------- /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 color_eyre::eyre::{Result, WrapErr, bail}; 9 | use indoc::formatdoc; 10 | use lazy_regex::regex; 11 | use octocrab::FromResponse; 12 | use secrecy::ExposeSecret as _; 13 | 14 | use crate::output::output; 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()).wrap_err(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 | let github_auth_token = config 30 | .get_string("spr.githubAuthToken") 31 | .ok() 32 | .and_then(|value| if value.is_empty() { None } else { Some(value) }); 33 | 34 | let scopes = if let Some(token) = github_auth_token.as_deref() { 35 | let response: AuthScopes = octocrab::OctocrabBuilder::new() 36 | .personal_token(token) 37 | .build()? 38 | .get("/", Some(&())) 39 | .await?; 40 | 41 | response.scopes 42 | } else { 43 | vec![] 44 | }; 45 | 46 | let valid_auth = scopes.iter().any(|s| s == "repo") 47 | && scopes.iter().any(|s| s == "user") 48 | && scopes.iter().any(|s| s == "org" || s == "read:org") 49 | && scopes.iter().any(|s| s == "workflow"); 50 | 51 | let github_auth_token = if valid_auth { 52 | github_auth_token.unwrap() 53 | } else { 54 | console::Term::stdout().write_line("")?; 55 | 56 | let client_id = "Ov23liD6WOMYlLy12wkg"; 57 | 58 | let client = octocrab::OctocrabBuilder::new() 59 | .base_uri("https://github.com")? 60 | .add_header( 61 | http::HeaderName::from_static("accept"), 62 | "application/json".into(), 63 | ) 64 | .build()?; 65 | 66 | let device_codes = client 67 | .authenticate_as_device( 68 | &client_id.into(), 69 | ["repo user read:org workflow"], 70 | ) 71 | .await?; 72 | 73 | open::that_detached(&device_codes.verification_uri)?; 74 | output( 75 | "🔑", 76 | &formatdoc!(" 77 | Okay, let's get started. 78 | 79 | To authenticate spr with GitHub, please go to 80 | 81 | -----> {} <----- 82 | 83 | and enter code 84 | 85 | > > > > > {} < < < < < 86 | 87 | For your convenience, the link should open in your web browser now.", 88 | &device_codes.verification_uri, 89 | &device_codes.user_code, 90 | ) 91 | )?; 92 | 93 | let auth = device_codes 94 | .poll_until_available(&client, &client_id.into()) 95 | .await?; 96 | let token: String = auth.access_token.expose_secret().into(); 97 | 98 | config.set_str("spr.githubAuthToken", &token)?; 99 | 100 | token 101 | }; 102 | 103 | let octocrab = octocrab::OctocrabBuilder::new() 104 | .personal_token(github_auth_token.clone()) 105 | .build()?; 106 | let github_user = octocrab.current().user().await?; 107 | 108 | output("👋", &formatdoc!("Hello {}!", github_user.login))?; 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 regex = 124 | lazy_regex::regex!(r#"github\.com[/:]([\w\-\.]+/[\w\-\.]+?)(.git)?$"#); 125 | let github_repo = config 126 | .get_string("spr.githubRepository") 127 | .ok() 128 | .and_then(|value| if value.is_empty() { None } else { Some(value) }) 129 | .or_else(|| { 130 | // We can provide a default value in case the remote "origin" is pointing to github.com 131 | repo.find_remote("origin") 132 | .ok() 133 | .and_then(|remote| remote.url().map(String::from)) 134 | .and_then(|url| { 135 | regex.captures(&url).and_then(|caps| { 136 | caps.get(1).map(|m| m.as_str().to_string()) 137 | }) 138 | }) 139 | }) 140 | .unwrap_or_default(); 141 | 142 | let github_repo = dialoguer::Input::::new() 143 | .with_prompt("GitHub repository") 144 | .with_initial_text(github_repo) 145 | .interact_text()?; 146 | config.set_str("spr.githubRepository", &github_repo)?; 147 | 148 | // Master branch name (just query GitHub) 149 | 150 | let github_repo_info = octocrab 151 | .get::( 152 | format!("/repos/{}", &github_repo), 153 | None::<&()>, 154 | ) 155 | .await 156 | .context("Getting github repo info".to_string())?; 157 | 158 | config.set_str( 159 | "spr.githubMasterBranch", 160 | github_repo_info 161 | .default_branch 162 | .as_ref() 163 | .map(|s| &s[..]) 164 | .unwrap_or("master"), 165 | )?; 166 | 167 | // Pull Request branch prefix 168 | 169 | console::Term::stdout().write_line("")?; 170 | 171 | let branch_prefix = config 172 | .get_string("spr.branchPrefix") 173 | .ok() 174 | .and_then(|value| if value.is_empty() { None } else { Some(value) }) 175 | .unwrap_or_else(|| format!("spr/{}/", &github_user.login)); 176 | 177 | output( 178 | "❓", 179 | &formatdoc!( 180 | "What prefix should be used when naming Pull Request branches? 181 | Good practice is to begin with 'spr/' as a general namespace \ 182 | for spr-managed Pull Request branches. Continuing with the \ 183 | GitHub user name is a good idea, so there is no danger of names \ 184 | clashing with those of other users. 185 | The prefix should end with a good separator character (like '/' \ 186 | or '-'), since commit titles will be appended to this prefix." 187 | ), 188 | )?; 189 | 190 | let branch_prefix = dialoguer::Input::::new() 191 | .with_prompt("Branch prefix") 192 | .with_initial_text(branch_prefix) 193 | .validate_with(|input: &String| -> Result<()> { 194 | validate_branch_prefix(input) 195 | }) 196 | .interact_text()?; 197 | 198 | config.set_str("spr.branchPrefix", &branch_prefix)?; 199 | 200 | Ok(()) 201 | } 202 | 203 | fn validate_branch_prefix(branch_prefix: &str) -> Result<()> { 204 | // They can include slash / for hierarchical (directory) grouping, but no slash-separated component can begin with a dot . or end with the sequence .lock. 205 | if branch_prefix.contains("/.") 206 | || branch_prefix.contains(".lock/") 207 | || branch_prefix.ends_with(".lock") 208 | || branch_prefix.starts_with('.') 209 | { 210 | bail!( 211 | "Branch prefix cannot have slash-separated component beginning with a dot . or ending with the sequence .lock", 212 | ); 213 | } 214 | 215 | if branch_prefix.contains("..") { 216 | bail!("Branch prefix cannot contain two consecutive dots anywhere.",); 217 | } 218 | 219 | if branch_prefix.chars().any(|c| c.is_ascii_control()) { 220 | bail!("Branch prefix cannot contain ASCII control sequence",); 221 | } 222 | 223 | let forbidden_chars_re = regex!(r"[ \~\^:?*\[\\]"); 224 | if forbidden_chars_re.is_match(branch_prefix) { 225 | bail!("Branch prefix contains one or more forbidden characters.",); 226 | } 227 | 228 | if branch_prefix.contains("//") || branch_prefix.starts_with('/') { 229 | bail!( 230 | "Branch prefix contains multiple consecutive slashes or starts with slash.", 231 | ); 232 | } 233 | 234 | if branch_prefix.contains("@{") { 235 | bail!("Branch prefix cannot contain the sequence @{{"); 236 | } 237 | 238 | Ok(()) 239 | } 240 | 241 | #[derive(Debug)] 242 | struct AuthScopes { 243 | scopes: Vec, 244 | } 245 | 246 | impl FromResponse for AuthScopes { 247 | fn from_response<'async_trait, B>( 248 | response: http::Response, 249 | ) -> std::pin::Pin< 250 | Box< 251 | dyn std::future::Future> 252 | + std::marker::Send 253 | + 'async_trait, 254 | >, 255 | > 256 | where 257 | B: http_body::Body + Send, 258 | B: 'async_trait, 259 | Self: 'async_trait, 260 | { 261 | Box::pin(async move { 262 | let scopes = response 263 | .headers() 264 | .get("x-oauth-scopes") 265 | .map(|v| v.to_str()) 266 | .transpose() 267 | .map_err(|err| octocrab::Error::Other { 268 | source: Box::new(err), 269 | backtrace: std::backtrace::Backtrace::capture(), 270 | })? 271 | .map(|value| { 272 | value 273 | .split(',') 274 | .map(str::trim) 275 | .filter(|x| !x.is_empty()) 276 | .map(String::from) 277 | .collect::>() 278 | }) 279 | .unwrap_or_default(); 280 | Ok(AuthScopes { scopes }) 281 | }) 282 | } 283 | } 284 | 285 | #[cfg(test)] 286 | mod tests { 287 | use super::validate_branch_prefix; 288 | 289 | #[test] 290 | fn test_branch_prefix_rules() { 291 | // Rules taken from https://git-scm.com/docs/git-check-ref-format 292 | // Note: Some rules don't need to be checked because the prefix is 293 | // always embedded into a larger context. For example, rule 9 in the 294 | // reference states that a _refname_ cannot be the single character @. 295 | // This rule is impossible to break purely via the branch prefix. 296 | let bad_prefixes: Vec<(&str, &str)> = vec![ 297 | ( 298 | "spr/.bad", 299 | "Cannot start slash-separated component with dot", 300 | ), 301 | (".bad", "Cannot start slash-separated component with dot"), 302 | ("spr/bad.lock", "Cannot end with .lock"), 303 | ( 304 | "spr/bad.lock/some_more", 305 | "Cannot end slash-separated component with .lock", 306 | ), 307 | ( 308 | "spr/b..ad/bla", 309 | "They cannot contain two consecutive dots anywhere", 310 | ), 311 | ("spr/bad//bla", "They cannot contain consecutive slashes"), 312 | ("/bad", "Prefix should not start with slash"), 313 | ("/bad@{stuff", "Prefix cannot contain sequence @{"), 314 | ]; 315 | 316 | for (branch_prefix, reason) in bad_prefixes { 317 | assert!( 318 | validate_branch_prefix(branch_prefix).is_err(), 319 | "{}", 320 | reason 321 | ); 322 | } 323 | 324 | let ok_prefix = "spr/some.lockprefix/with-stuff/foo"; 325 | assert!(validate_branch_prefix(ok_prefix).is_ok()); 326 | } 327 | 328 | #[test] 329 | fn test_branch_prefix_rejects_forbidden_characters() { 330 | // Here I'm mostly concerned about escaping / not escaping in the regex :p 331 | assert!(validate_branch_prefix("bad\x1F").is_err()); 332 | assert!(validate_branch_prefix("notbad!").is_ok()); 333 | assert!( 334 | validate_branch_prefix("bad /space").is_err(), 335 | "Reject space in prefix" 336 | ); 337 | assert!(validate_branch_prefix("bad~").is_err(), "Reject tilde"); 338 | assert!(validate_branch_prefix("bad^").is_err(), "Reject caret"); 339 | assert!(validate_branch_prefix("bad:").is_err(), "Reject colon"); 340 | assert!(validate_branch_prefix("bad?").is_err(), "Reject ?"); 341 | assert!(validate_branch_prefix("bad*").is_err(), "Reject *"); 342 | assert!(validate_branch_prefix("bad[").is_err(), "Reject ["); 343 | assert!(validate_branch_prefix(r"bad\").is_err(), "Reject \\"); 344 | } 345 | } 346 | -------------------------------------------------------------------------------- /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 color_eyre::eyre::{Error, Report, Result, WrapErr as _, bail, eyre}; 9 | use indoc::formatdoc; 10 | use std::time::Duration; 11 | 12 | use crate::{ 13 | git_remote::PushSpec, 14 | github::{PullRequestState, PullRequestUpdate, ReviewStatus}, 15 | message::build_github_body_for_merging, 16 | output::{output, write_commit_title}, 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.check_no_uncommitted_changes()?; 34 | let mut prepared_commits = gh.get_prepared_commits()?; 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::msg(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 | bail!("This commit does not refer to a Pull Request."); 64 | }; 65 | 66 | // Load Pull Request information 67 | let pull_request = gh.clone().get_pull_request(pull_request_number).await?; 68 | 69 | if pull_request.state != PullRequestState::Open { 70 | bail!("This Pull Request is already closed!"); 71 | } 72 | 73 | if config.require_approval 74 | && pull_request.review_status != Some(ReviewStatus::Approved) 75 | { 76 | bail!("This Pull Request has not been approved on GitHub."); 77 | } 78 | 79 | output("🛫", "Getting started...")?; 80 | 81 | // Fetch current master from GitHub. 82 | let current_master = 83 | gh.remote().fetch_branch(config.master_ref.branch_name())?; 84 | 85 | let base_is_master = pull_request.base.is_master_branch(); 86 | let index = git.cherrypick(prepared_commit.oid, current_master)?; 87 | 88 | if index.has_conflicts() { 89 | return Err(Error::msg(formatdoc!( 90 | "This commit cannot be applied on top of the '{master}' branch. 91 | Please rebase this commit.{unlanded}", 92 | master = &config.master_ref.branch_name(), 93 | unlanded = if based_on_unlanded_commits { 94 | " You may also have to land commits that this commit depends on first." 95 | } else { 96 | "" 97 | }, 98 | ))); 99 | } 100 | 101 | // This is the tree we are getting from cherrypicking the local commit 102 | // on the selected base (master or stacked-on Pull Request). 103 | let our_tree_oid = git.write_index(index)?; 104 | 105 | // Now let's predict what merging the PR into the master branch would 106 | // produce. 107 | let merge_index = { 108 | let repo = git.repo(); 109 | let current_master = repo.find_commit(current_master)?; 110 | let pr_head = repo.find_commit(pull_request.head_oid)?; 111 | repo.merge_commits(¤t_master, &pr_head, None) 112 | }?; 113 | 114 | let merge_matches_cherrypick = if merge_index.has_conflicts() { 115 | false 116 | } else { 117 | let merge_tree_oid = git.write_index(merge_index)?; 118 | merge_tree_oid == our_tree_oid 119 | }; 120 | 121 | if !merge_matches_cherrypick { 122 | return Err(Error::msg(formatdoc!( 123 | "This commit has been updated and/or rebased since the pull \ 124 | request was last updated. Please run `spr diff` to update the \ 125 | pull request and then try `spr land` again!" 126 | ))); 127 | } 128 | 129 | // Okay, we are confident now that the PR can be merged and the result of 130 | // that merge would be a master commit with the same tree as if we 131 | // cherry-picked the commit onto master. 132 | let mut pr_head_oid = pull_request.head_oid; 133 | 134 | if !base_is_master { 135 | // The base of the Pull Request on GitHub is not set to master. This 136 | // means the Pull Request uses a base branch. We tested above that 137 | // merging the Pull Request branch into the master branch produces the 138 | // intended result (the same as cherry-picking the local commit onto 139 | // master), so what we want to do is actually merge the Pull Request as 140 | // it is into master. Hence, we change the base to the master branch. 141 | // 142 | // Before we do that, there is one more edge case to look out for: if 143 | // the base branch contains changes that have since been landed on 144 | // master, then Git might be able to figure out that these changes 145 | // appear both in the pull request branch (via the merge branch) and in 146 | // master, but are identical in those two so it is not a merge conflict 147 | // but can go ahead. The result of this in master if we merge now is 148 | // correct, but there is one problem: when looking at the Pull Request 149 | // in GitHub after merging, it will show these change as part of the 150 | // Pull Request. So when you look at the changed files of the Pull 151 | // Request, you will see both changes in this commit (great!) and those 152 | // in the base branch (a previous commit that has already been landed on 153 | // master - not great!). This is because the changes shown are the ones 154 | // that happened on this Pull Request branch (now including the base 155 | // branch) since it branched off master. This can include changes in the 156 | // base branch that are already on master, but were added to master 157 | // after the Pull Request branch branched from master. 158 | // The solution is to merge current master into the Pull Request branch. 159 | // Doing that now means that the final changes done by this Pull Request 160 | // are only the changes that are not yet in master. That's what we want. 161 | // This final merge never introduces any changes to the Pull Request. In 162 | // fact, the tree that we use for the merge commit is the one we got 163 | // above from the cherry-picking of this commit on master. 164 | 165 | // The commit on the base branch that the PR branch is currently based on 166 | let pr_base_oid = 167 | git.repo().merge_base(pr_head_oid, pull_request.base_oid)?; 168 | let pr_base_tree = git.get_tree_oid_for_commit(pr_base_oid)?; 169 | 170 | let pr_master_base = 171 | git.repo().merge_base(pr_base_oid, current_master)?; 172 | let pr_master_base_tree = 173 | git.get_tree_oid_for_commit(pr_master_base)?; 174 | 175 | if pr_base_tree != pr_master_base_tree { 176 | // So the current file contents of the base branch are not the same 177 | // as those of the master branch commit that the base branch is 178 | // based on. In other words, the base branch is currently not 179 | // "empty". Or, the base branch has changes in them. These changes 180 | // must all have been landed on master in the meantime (after this 181 | // base branch was branched off) or otherwise we would have aborted 182 | // this whole operation further above. But in order not to show them 183 | // as part of this Pull Request after landing, we have to make clear 184 | // those are changes in master, not in this Pull Request. 185 | // Here comes the additional merge-in-master commit on the Pull 186 | // Request branch that achieves that! 187 | 188 | pr_head_oid = git.create_derived_commit( 189 | pr_head_oid, 190 | &format!( 191 | "[𝘀𝗽𝗿] landed version\n\nCreated using spr {}", 192 | env!("CARGO_PKG_VERSION"), 193 | ), 194 | our_tree_oid, 195 | &[pr_head_oid, current_master], 196 | )?; 197 | 198 | gh.remote() 199 | .push_to_remote(&[PushSpec { 200 | oid: Some(pr_head_oid), 201 | remote_ref: pull_request.head.on_github(), 202 | }]) 203 | .wrap_err("git push failed")?; 204 | } 205 | 206 | gh.update_pull_request( 207 | pull_request_number, 208 | PullRequestUpdate { 209 | base: Some(config.master_ref.branch_name().to_string()), 210 | ..Default::default() 211 | }, 212 | ) 213 | .await?; 214 | } 215 | 216 | // Check whether GitHub says this PR is mergeable. This happens in a 217 | // retry-loop because recent changes to the Pull Request can mean that 218 | // GitHub has not finished the mergeability check yet. 219 | let mut attempts = 0; 220 | let result = loop { 221 | attempts += 1; 222 | 223 | let mergeability = gh 224 | .get_pull_request_mergeability(pull_request_number) 225 | .await?; 226 | 227 | if mergeability.head_oid != pr_head_oid { 228 | break Err(eyre!( 229 | "The Pull Request seems to have been updated externally. Please try again!" 230 | )); 231 | } 232 | 233 | if mergeability.base.is_master_branch() 234 | && mergeability.mergeable.is_some() 235 | { 236 | if mergeability.mergeable != Some(true) { 237 | break Err(Error::msg(formatdoc!( 238 | "GitHub concluded the Pull Request is not mergeable at \ 239 | this point. Please rebase your changes and try again!" 240 | ))); 241 | } 242 | 243 | if let Some(merge_commit) = mergeability.merge_commit { 244 | gh.remote().fetch_from_remote(&[], &[merge_commit])?; 245 | 246 | if git.get_tree_oid_for_commit(merge_commit)? != our_tree_oid { 247 | return Err(Error::msg(formatdoc!( 248 | "This commit has been updated and/or rebased since the pull 249 | request was last updated. Please run `spr diff` to update the pull 250 | request and then try `spr land` again!" 251 | ))); 252 | } 253 | }; 254 | 255 | break Ok(()); 256 | } 257 | 258 | if attempts >= 10 { 259 | // After ten failed attempts we give up. 260 | break Err(eyre!( 261 | "GitHub Pull Request did not update. Please try again!" 262 | )); 263 | } 264 | 265 | // Wait one second before retrying 266 | tokio::time::sleep(Duration::from_secs(1)).await; 267 | }; 268 | 269 | let result = match result { 270 | Ok(()) => { 271 | // We have checked that merging the Pull Request branch into the master 272 | // branch produces the intended result, and that's independent of whether we 273 | // used a base branch with this Pull Request or not. We have made sure the 274 | // target of the Pull Request is set to the master branch. So let GitHub do 275 | // the merge now! 276 | octocrab::instance() 277 | .pulls(&config.owner, &config.repo) 278 | .merge(pull_request_number) 279 | .method(octocrab::params::pulls::MergeMethod::Squash) 280 | .title(pull_request.title) 281 | .message(build_github_body_for_merging(&pull_request.sections)) 282 | .sha(format!("{}", pr_head_oid)) 283 | .send() 284 | .await 285 | .map_err(Report::new) 286 | .and_then(|merge| { 287 | if merge.merged { 288 | Ok(merge) 289 | } else { 290 | Err(eyre!( 291 | "GitHub Pull Request merge failed: {}", 292 | merge.message.unwrap_or_default() 293 | )) 294 | } 295 | }) 296 | } 297 | Err(err) => Err(err), 298 | }; 299 | 300 | let merge = match result { 301 | Ok(merge) => merge, 302 | Err(mut error) => { 303 | output("❌", "GitHub Pull Request merge failed")?; 304 | 305 | // If we changed the target branch of the Pull Request earlier, then 306 | // undo this change now. 307 | if !base_is_master { 308 | let result = gh 309 | .update_pull_request( 310 | pull_request_number, 311 | PullRequestUpdate { 312 | base: Some( 313 | pull_request.base.on_github().to_string(), 314 | ), 315 | ..Default::default() 316 | }, 317 | ) 318 | .await; 319 | if let Err(e) = result { 320 | error = error.wrap_err(e); 321 | } 322 | } 323 | 324 | return Err(error); 325 | } 326 | }; 327 | 328 | output("🛬", "Landed!")?; 329 | 330 | // Rebase us on top of the now-landed commit 331 | if let Some(sha) = merge.sha { 332 | let new_parent_oid = git2::Oid::from_str(&sha)?; 333 | // Try this up to three times, because fetching the very moment after 334 | // the merge might still not find the new commit. 335 | for i in 0..3 { 336 | // Fetch current master and the merge commit from GitHub. 337 | let result = gh.remote().fetch_from_remote(&[], &[new_parent_oid]); 338 | 339 | if result.is_ok() { 340 | break; 341 | } else if i == 2 { 342 | return result 343 | .map(|_| ()) 344 | .context("git fetch failed".to_string()); 345 | } 346 | } 347 | git.rebase_commits(&mut prepared_commits[..], new_parent_oid) 348 | .context( 349 | "The automatic rebase failed - please rebase manually!" 350 | .to_string(), 351 | )?; 352 | } 353 | 354 | let mut push_specs = vec![PushSpec { 355 | oid: None, 356 | remote_ref: pull_request.head.on_github(), 357 | }]; 358 | 359 | if !base_is_master { 360 | push_specs.push(PushSpec { 361 | oid: None, 362 | remote_ref: pull_request.base.on_github(), 363 | }); 364 | } 365 | 366 | gh.remote().push_to_remote(&push_specs)?; 367 | 368 | Ok(()) 369 | } 370 | -------------------------------------------------------------------------------- /src/git.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 color_eyre::eyre::{Error, Result, WrapErr as _, bail, eyre}; 9 | use std::collections::{HashSet, VecDeque}; 10 | 11 | use crate::{ 12 | config::Config, 13 | message::{ 14 | MessageSection, MessageSectionsMap, build_commit_message, parse_message, 15 | }, 16 | }; 17 | use git2::Oid; 18 | 19 | #[derive(Debug)] 20 | pub struct PreparedCommit { 21 | pub oid: Oid, 22 | pub short_id: String, 23 | pub parent_oid: Oid, 24 | pub message: MessageSectionsMap, 25 | pub pull_request_number: Option, 26 | } 27 | 28 | #[derive(Clone)] 29 | pub struct Git { 30 | repo: std::sync::Arc, 31 | hooks: std::sync::Arc, 32 | } 33 | 34 | impl Git { 35 | pub fn new(repo: git2::Repository) -> Self { 36 | Self { 37 | hooks: std::sync::Arc::new( 38 | git2_ext::hooks::Hooks::with_repo(&repo).unwrap(), 39 | ), 40 | #[allow(clippy::arc_with_non_send_sync)] 41 | repo: std::sync::Arc::new(repo), 42 | } 43 | } 44 | 45 | pub fn repo(&self) -> &std::sync::Arc { 46 | &self.repo 47 | } 48 | 49 | fn hooks(&self) -> &git2_ext::hooks::Hooks { 50 | self.hooks.as_ref() 51 | } 52 | 53 | pub fn get_commit_oids(&self, master_oid: Oid) -> Result> { 54 | let mut walk = self.repo.revwalk()?; 55 | walk.set_sorting(git2::Sort::TOPOLOGICAL.union(git2::Sort::REVERSE))?; 56 | walk.push_head()?; 57 | walk.hide(master_oid)?; 58 | 59 | Ok(walk.collect::, _>>()?) 60 | } 61 | 62 | pub fn get_prepared_commits( 63 | &self, 64 | config: &Config, 65 | master_oid: Oid, 66 | ) -> Result> { 67 | self.get_commit_oids(master_oid)? 68 | .into_iter() 69 | .map(|oid| self.prepare_commit(config, oid)) 70 | .collect() 71 | } 72 | 73 | pub fn rewrite_commit_messages( 74 | &self, 75 | commits: &mut [PreparedCommit], 76 | mut limit: Option, 77 | ) -> Result<()> { 78 | if commits.is_empty() { 79 | return Ok(()); 80 | } 81 | 82 | let mut parent_oid: Option = None; 83 | let mut updating = false; 84 | let mut message: String; 85 | let first_parent = commits[0].parent_oid; 86 | let hooks = self.hooks(); 87 | 88 | for prepared_commit in commits.iter_mut() { 89 | let commit = self.repo.find_commit(prepared_commit.oid)?; 90 | if limit != Some(0) { 91 | message = build_commit_message(&prepared_commit.message); 92 | if Some(&message[..]) != commit.message() { 93 | updating = true; 94 | } 95 | } else { 96 | if !updating { 97 | return Ok(()); 98 | } 99 | message = String::from_utf8_lossy(commit.message_bytes()) 100 | .into_owned(); 101 | } 102 | limit = limit.map(|n| if n > 0 { n - 1 } else { 0 }); 103 | 104 | if updating { 105 | let new_oid = self.repo.commit( 106 | None, 107 | &commit.author(), 108 | &commit.committer(), 109 | &message[..], 110 | &commit.tree()?, 111 | &[&self 112 | .repo 113 | .find_commit(parent_oid.unwrap_or(first_parent))?], 114 | )?; 115 | hooks.run_post_rewrite_rebase( 116 | self.repo.as_ref(), 117 | &[(prepared_commit.oid, new_oid)], 118 | ); 119 | prepared_commit.oid = new_oid; 120 | parent_oid = Some(new_oid); 121 | } else { 122 | parent_oid = Some(prepared_commit.oid); 123 | } 124 | } 125 | 126 | if updating && let Some(oid) = parent_oid { 127 | self.repo 128 | .find_reference("HEAD")? 129 | .resolve()? 130 | .set_target(oid, "spr updated commit messages")?; 131 | } 132 | 133 | Ok(()) 134 | } 135 | 136 | pub fn rebase_commits( 137 | &self, 138 | commits: &mut [PreparedCommit], 139 | mut new_parent_oid: git2::Oid, 140 | ) -> Result<()> { 141 | if commits.is_empty() { 142 | return Ok(()); 143 | } 144 | let hooks = self.hooks(); 145 | 146 | for prepared_commit in commits.iter_mut() { 147 | let new_parent_commit = self.repo.find_commit(new_parent_oid)?; 148 | let commit = self.repo.find_commit(prepared_commit.oid)?; 149 | 150 | let mut index = self.repo.cherrypick_commit( 151 | &commit, 152 | &new_parent_commit, 153 | 0, 154 | None, 155 | )?; 156 | if index.has_conflicts() { 157 | bail!("Rebase failed due to merge conflicts"); 158 | } 159 | 160 | let tree_oid = index.write_tree_to(self.repo.as_ref())?; 161 | if tree_oid == new_parent_commit.tree_id() { 162 | // Rebasing makes this an empty commit. This is probably because 163 | // we just landed this commit. So we should run a hook as this 164 | // commit (the local pre-land commit) having been rewritten into 165 | // the parent (the freshly landed and pulled commit). Although 166 | // this behaviour is tuned around a land operation, it's in 167 | // general not an unreasoanble thing for a rebase, ala git 168 | // rebase --interactive and fixups etc. 169 | hooks.run_post_rewrite_rebase( 170 | self.repo.as_ref(), 171 | &[(prepared_commit.oid, new_parent_oid)], 172 | ); 173 | continue; 174 | } 175 | let tree = self.repo.find_tree(tree_oid)?; 176 | 177 | new_parent_oid = self.repo.commit( 178 | None, 179 | &commit.author(), 180 | &commit.committer(), 181 | String::from_utf8_lossy(commit.message_bytes()).as_ref(), 182 | &tree, 183 | &[&new_parent_commit], 184 | )?; 185 | hooks.run_post_rewrite_rebase( 186 | self.repo.as_ref(), 187 | &[(prepared_commit.oid, new_parent_oid)], 188 | ); 189 | } 190 | 191 | let new_oid = new_parent_oid; 192 | let new_commit = self.repo.find_commit(new_oid)?; 193 | 194 | // Get and resolve the HEAD reference. This will be either a reference 195 | // to a branch ('refs/heads/...') or 'HEAD' if the head is detached. 196 | let mut reference = self.repo.head()?.resolve()?; 197 | 198 | // Checkout the tree of the top commit of the rebased branch. This can 199 | // fail if there are local changes in the worktree that collide with 200 | // files that need updating in order to check out the rebased commit. In 201 | // this case we fail early here, before we update any references. The 202 | // result is that the worktree is unchanged and neither the branch nor 203 | // HEAD gets updated. We can just prompt the user to rebase manually. 204 | // That's a fine solution. If the user tries "git rebase origin/master" 205 | // straight away, they will find that it also fails because of local 206 | // worktree changes. Once the user has dealt with those (revert, stash 207 | // or commit), the rebase should work nicely. 208 | self.repo 209 | .checkout_tree(new_commit.as_object(), None) 210 | .map_err(Error::from) 211 | .wrap_err( 212 | "Could not check out rebased branch - please rebase manually", 213 | )?; 214 | 215 | // Update the reference. The reference may be a branch or "HEAD", if 216 | // detached. Either way, whatever we are on gets update to point to the 217 | // new commit. 218 | reference.set_target(new_oid, "spr rebased")?; 219 | 220 | Ok(()) 221 | } 222 | 223 | pub fn head(&self) -> Result { 224 | let oid = self 225 | .repo 226 | .head()? 227 | .resolve()? 228 | .target() 229 | .ok_or_else(|| eyre!("Cannot resolve HEAD"))?; 230 | 231 | Ok(oid) 232 | } 233 | 234 | pub fn resolve_reference(&self, reference: &str) -> Result { 235 | let result = 236 | self.repo.find_reference(reference)?.peel_to_commit()?.id(); 237 | 238 | Ok(result) 239 | } 240 | 241 | pub fn prepare_commit( 242 | &self, 243 | config: &Config, 244 | oid: Oid, 245 | ) -> Result { 246 | let commit = self.repo.find_commit(oid)?; 247 | 248 | if commit.parent_count() != 1 { 249 | bail!("Parent commit count != 1"); 250 | } 251 | 252 | let parent_oid = commit.parent_id(0)?; 253 | 254 | let message = 255 | String::from_utf8_lossy(commit.message_bytes()).into_owned(); 256 | 257 | let short_id = 258 | commit.as_object().short_id()?.as_str().unwrap().to_string(); 259 | drop(commit); 260 | 261 | let mut message = parse_message(&message, MessageSection::Title); 262 | 263 | let pull_request_number = message 264 | .get(&MessageSection::PullRequest) 265 | .and_then(|text| config.parse_pull_request_field(text)); 266 | 267 | if let Some(number) = pull_request_number { 268 | message.insert( 269 | MessageSection::PullRequest, 270 | config.pull_request_url(number), 271 | ); 272 | } else { 273 | message.remove(&MessageSection::PullRequest); 274 | } 275 | 276 | Ok(PreparedCommit { 277 | oid, 278 | short_id, 279 | parent_oid, 280 | message, 281 | pull_request_number, 282 | }) 283 | } 284 | 285 | pub fn get_all_ref_names(&self) -> Result> { 286 | let result: std::result::Result, _> = self 287 | .repo 288 | .references()? 289 | .names() 290 | .map(|r| r.map(String::from)) 291 | .collect(); 292 | 293 | Ok(result?) 294 | } 295 | 296 | pub fn get_pr_patch_branch_name(&self, pr_number: u64) -> Result { 297 | let ref_names = self.get_all_ref_names()?; 298 | let default_name = format!("PR-{}", pr_number); 299 | if !ref_names.contains(&format!("refs/heads/{}", default_name)) { 300 | return Ok(default_name); 301 | } 302 | 303 | let mut count = 1; 304 | loop { 305 | let name = format!("PR-{}-{}", pr_number, count); 306 | if !ref_names.contains(&format!("refs/heads/{}", name)) { 307 | return Ok(name); 308 | } 309 | count += 1; 310 | } 311 | } 312 | 313 | pub fn cherrypick(&self, oid: Oid, base_oid: Oid) -> Result { 314 | let commit = self.repo.find_commit(oid)?; 315 | let base_commit = self.repo.find_commit(base_oid)?; 316 | 317 | Ok(self 318 | .repo 319 | .cherrypick_commit(&commit, &base_commit, 0, None)?) 320 | } 321 | 322 | pub fn write_index(&self, mut index: git2::Index) -> Result { 323 | Ok(index.write_tree_to(self.repo.as_ref())?) 324 | } 325 | 326 | pub fn get_tree_oid_for_commit(&self, oid: Oid) -> Result { 327 | let tree_oid = self.repo.find_commit(oid)?.tree_id(); 328 | 329 | Ok(tree_oid) 330 | } 331 | 332 | pub fn find_master_base( 333 | &self, 334 | commit_oid: Oid, 335 | master_oid: Oid, 336 | ) -> Result> { 337 | let mut commit_ancestors = HashSet::new(); 338 | let mut commit_oid = Some(commit_oid); 339 | let mut master_ancestors = HashSet::new(); 340 | let mut master_queue = VecDeque::new(); 341 | master_ancestors.insert(master_oid); 342 | master_queue.push_back(master_oid); 343 | 344 | while !(commit_oid.is_none() && master_queue.is_empty()) { 345 | if let Some(oid) = commit_oid { 346 | if master_ancestors.contains(&oid) { 347 | return Ok(Some(oid)); 348 | } 349 | commit_ancestors.insert(oid); 350 | let commit = self.repo.find_commit(oid)?; 351 | commit_oid = match commit.parent_count() { 352 | 0 => None, 353 | l => Some(commit.parent_id(l - 1)?), 354 | }; 355 | } 356 | 357 | if let Some(oid) = master_queue.pop_front() { 358 | if commit_ancestors.contains(&oid) { 359 | return Ok(Some(oid)); 360 | } 361 | let commit = self.repo.find_commit(oid)?; 362 | for oid in commit.parent_ids() { 363 | if !master_ancestors.contains(&oid) { 364 | master_queue.push_back(oid); 365 | master_ancestors.insert(oid); 366 | } 367 | } 368 | } 369 | } 370 | 371 | Ok(None) 372 | } 373 | 374 | pub fn create_derived_commit( 375 | &self, 376 | original_commit_oid: Oid, 377 | message: &str, 378 | tree_oid: Oid, 379 | parent_oids: &[Oid], 380 | ) -> Result { 381 | let original_commit = self.repo.find_commit(original_commit_oid)?; 382 | let tree = self.repo.find_tree(tree_oid)?; 383 | let parents = parent_oids 384 | .iter() 385 | .map(|oid| self.repo.find_commit(*oid)) 386 | .collect::, _>>()?; 387 | let parent_refs = parents.iter().collect::>(); 388 | let message = git2::message_prettify(message, None)?; 389 | 390 | // The committer signature should be the default signature (i.e. the 391 | // current user - as configured in Git as `user.name` and `user.email` - 392 | // and the timestamp set to now). If the default signature can't be 393 | // obtained (no user configured), then take the user/email from the 394 | // existing commit but make a new signature which has a timestamp of 395 | // now. 396 | let committer = self.repo.signature().or_else(|_| { 397 | git2::Signature::now( 398 | String::from_utf8_lossy( 399 | original_commit.committer().name_bytes(), 400 | ) 401 | .as_ref(), 402 | String::from_utf8_lossy( 403 | original_commit.committer().email_bytes(), 404 | ) 405 | .as_ref(), 406 | ) 407 | })?; 408 | 409 | // The author signature should reference the same user as the original 410 | // commit, but we set the timestamp to now, so this commit shows up in 411 | // GitHub's timeline in the right place. 412 | let author = git2::Signature::now( 413 | String::from_utf8_lossy(original_commit.author().name_bytes()) 414 | .as_ref(), 415 | String::from_utf8_lossy(original_commit.author().email_bytes()) 416 | .as_ref(), 417 | )?; 418 | 419 | let oid = self.repo.commit( 420 | None, 421 | &author, 422 | &committer, 423 | &message, 424 | &tree, 425 | &parent_refs[..], 426 | )?; 427 | 428 | Ok(oid) 429 | } 430 | 431 | pub fn check_no_uncommitted_changes(&self) -> Result<()> { 432 | let mut opts = git2::StatusOptions::new(); 433 | opts.include_ignored(false).include_untracked(false); 434 | if self.repo.statuses(Some(&mut opts))?.is_empty() { 435 | Ok(()) 436 | } else { 437 | Err(eyre!( 438 | "There are uncommitted changes. Stash or amend them first" 439 | )) 440 | } 441 | } 442 | } 443 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 color_eyre::eyre::{Error, Result, WrapErr as _, eyre}; 9 | use graphql_client::{GraphQLQuery, Response}; 10 | use serde::Deserialize; 11 | 12 | use crate::{ 13 | git::PreparedCommit, 14 | git_remote::GitRemote, 15 | message::{ 16 | MessageSection, MessageSectionsMap, build_github_body, parse_message, 17 | }, 18 | }; 19 | use std::collections::{HashMap, HashSet}; 20 | 21 | #[derive(Clone)] 22 | pub struct GitHub { 23 | config: crate::config::Config, 24 | git: crate::git::Git, 25 | git_remote: crate::git_remote::GitRemote, 26 | } 27 | 28 | #[derive(Debug, Clone)] 29 | pub struct PullRequest { 30 | pub number: u64, 31 | pub state: PullRequestState, 32 | pub title: String, 33 | pub body: Option, 34 | pub sections: MessageSectionsMap, 35 | pub base: GitHubBranch, 36 | pub head: GitHubBranch, 37 | pub base_oid: git2::Oid, 38 | pub head_oid: git2::Oid, 39 | pub merge_commit: Option, 40 | pub reviewers: HashMap, 41 | pub review_status: Option, 42 | } 43 | 44 | #[derive(Debug, Clone, PartialEq, Eq)] 45 | pub enum ReviewStatus { 46 | Requested, 47 | Approved, 48 | Rejected, 49 | } 50 | 51 | #[derive(serde::Serialize, Default, Debug)] 52 | pub struct PullRequestUpdate { 53 | #[serde(skip_serializing_if = "Option::is_none")] 54 | pub title: Option, 55 | #[serde(skip_serializing_if = "Option::is_none")] 56 | pub body: Option, 57 | #[serde(skip_serializing_if = "Option::is_none")] 58 | pub base: Option, 59 | #[serde(skip_serializing_if = "Option::is_none")] 60 | pub state: Option, 61 | } 62 | 63 | impl PullRequestUpdate { 64 | pub fn is_empty(&self) -> bool { 65 | self.title.is_none() 66 | && self.body.is_none() 67 | && self.base.is_none() 68 | && self.state.is_none() 69 | } 70 | 71 | pub fn update_message( 72 | &mut self, 73 | pull_request: &PullRequest, 74 | message: &MessageSectionsMap, 75 | ) { 76 | let title = message.get(&MessageSection::Title); 77 | if title.is_some() && title != Some(&pull_request.title) { 78 | self.title = title.cloned(); 79 | } 80 | 81 | let body = build_github_body(message); 82 | if pull_request.body.as_ref() != Some(&body) { 83 | self.body = Some(body); 84 | } 85 | } 86 | } 87 | 88 | #[derive(serde::Serialize, Default, Debug)] 89 | pub struct PullRequestRequestReviewers { 90 | pub reviewers: Vec, 91 | pub team_reviewers: Vec, 92 | } 93 | 94 | #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] 95 | #[serde(rename_all = "lowercase")] 96 | pub enum PullRequestState { 97 | Open, 98 | Closed, 99 | } 100 | 101 | #[derive(serde::Deserialize, Debug, Clone)] 102 | pub struct UserWithName { 103 | pub login: String, 104 | pub name: Option, 105 | #[serde(default)] 106 | pub is_collaborator: bool, 107 | } 108 | 109 | #[derive(Debug, Clone)] 110 | pub struct PullRequestMergeability { 111 | pub base: GitHubBranch, 112 | pub head_oid: git2::Oid, 113 | pub mergeable: Option, 114 | pub merge_commit: Option, 115 | } 116 | 117 | #[derive(GraphQLQuery)] 118 | #[graphql( 119 | schema_path = "src/gql/schema.docs.graphql", 120 | query_path = "src/gql/pullrequest_query.graphql", 121 | response_derives = "Debug" 122 | )] 123 | pub struct PullRequestQuery; 124 | type GitObjectID = String; 125 | 126 | #[derive(GraphQLQuery)] 127 | #[graphql( 128 | schema_path = "src/gql/schema.docs.graphql", 129 | query_path = "src/gql/pullrequest_mergeability_query.graphql", 130 | response_derives = "Debug" 131 | )] 132 | pub struct PullRequestMergeabilityQuery; 133 | 134 | impl GitHub { 135 | pub fn new( 136 | config: crate::config::Config, 137 | git: crate::git::Git, 138 | auth_token: String, 139 | ) -> Self { 140 | let git_remote = GitRemote::new( 141 | git.repo().clone(), 142 | format!( 143 | "https://github.com/{}/{}.git", 144 | &config.owner, &config.repo, 145 | ), 146 | auth_token, 147 | ); 148 | Self { 149 | config, 150 | git, 151 | git_remote, 152 | } 153 | } 154 | 155 | pub fn remote(&self) -> &GitRemote { 156 | &self.git_remote 157 | } 158 | 159 | pub fn get_prepared_commits(&self) -> Result> { 160 | let master_oid = self 161 | .git_remote 162 | .fetch_branch(self.config.master_ref.branch_name())?; 163 | self.git.get_prepared_commits(&self.config, master_oid) 164 | } 165 | 166 | pub async fn get_github_user(login: String) -> Result { 167 | octocrab::instance() 168 | .get::(format!("/users/{}", login), None::<&()>) 169 | .await 170 | .map_err(Error::from) 171 | } 172 | 173 | pub async fn get_github_team( 174 | owner: String, 175 | team: String, 176 | ) -> Result { 177 | octocrab::instance() 178 | .teams(owner) 179 | .get(team) 180 | .await 181 | .map_err(Error::from) 182 | } 183 | 184 | pub async fn get_pull_request(self, number: u64) -> Result { 185 | let GitHub { 186 | config, git_remote, .. 187 | } = self; 188 | 189 | let variables = pull_request_query::Variables { 190 | name: config.repo.clone(), 191 | owner: config.owner.clone(), 192 | number: number as i64, 193 | }; 194 | let request_body = PullRequestQuery::build_query(variables); 195 | let response_body: Response = 196 | octocrab::instance() 197 | .post("/graphql", Some(&request_body)) 198 | .await?; 199 | 200 | if let Some(errors) = response_body.errors { 201 | let error = Err(eyre!("fetching PR #{number} failed")); 202 | return errors 203 | .into_iter() 204 | .fold(error, |err, e| err.context(e.to_string())); 205 | } 206 | 207 | let pr = response_body 208 | .data 209 | .ok_or_else(|| eyre!("failed to fetch PR"))? 210 | .repository 211 | .ok_or_else(|| eyre!("failed to find repository"))? 212 | .pull_request 213 | .ok_or_else(|| eyre!("failed to find PR"))?; 214 | 215 | let base = config.new_github_branch_from_ref(&pr.base_ref_name)?; 216 | let head = config.new_github_branch_from_ref(&pr.head_ref_name)?; 217 | 218 | let branch_names: Vec<_> = 219 | [&base, &head].iter().map(|&b| b.branch_name()).collect(); 220 | 221 | let [base_oid, head_oid] = 222 | git_remote.fetch_from_remote(&branch_names, &[])?[0..2] 223 | else { 224 | unreachable!(); 225 | }; 226 | 227 | let base_oid = base_oid.ok_or_else(|| { 228 | eyre!("{} not found on GitHub", &base.ref_on_github) 229 | })?; 230 | let head_oid = head_oid.ok_or_else(|| { 231 | eyre!("{} not found on GitHub", &head.ref_on_github) 232 | })?; 233 | 234 | let mut sections = parse_message(&pr.body, MessageSection::Summary); 235 | 236 | let title = pr.title.trim().to_string(); 237 | sections.insert( 238 | MessageSection::Title, 239 | if title.is_empty() { 240 | String::from("(untitled)") 241 | } else { 242 | title 243 | }, 244 | ); 245 | 246 | sections.insert( 247 | MessageSection::PullRequest, 248 | config.pull_request_url(number), 249 | ); 250 | 251 | let reviewers: HashMap = pr 252 | .latest_opinionated_reviews 253 | .iter() 254 | .flat_map(|all_reviews| &all_reviews.nodes) 255 | .flatten() 256 | .flatten() 257 | .flat_map(|review| { 258 | let user_name = review.author.as_ref()?.login.clone(); 259 | let status = match review.state { 260 | pull_request_query::PullRequestReviewState::APPROVED => ReviewStatus::Approved, 261 | pull_request_query::PullRequestReviewState::CHANGES_REQUESTED => ReviewStatus::Rejected, 262 | _ => ReviewStatus::Requested, 263 | }; 264 | Some((user_name, status)) 265 | }) 266 | .collect(); 267 | 268 | let review_status = match pr.review_decision { 269 | Some(pull_request_query::PullRequestReviewDecision::APPROVED) => Some(ReviewStatus::Approved), 270 | Some(pull_request_query::PullRequestReviewDecision::CHANGES_REQUESTED) => Some(ReviewStatus::Rejected), 271 | Some(pull_request_query::PullRequestReviewDecision::REVIEW_REQUIRED) => Some(ReviewStatus::Requested), 272 | _ => None, 273 | }; 274 | 275 | let requested_reviewers: Vec = pr.review_requests 276 | .iter() 277 | .flat_map(|x| &x.nodes) 278 | .flatten() 279 | .flatten() 280 | .flat_map(|x| &x.requested_reviewer) 281 | .flat_map(|reviewer| { 282 | type UserType = pull_request_query::PullRequestQueryRepositoryPullRequestReviewRequestsNodesRequestedReviewer; 283 | match reviewer { 284 | UserType::User(user) => Some(user.login.clone()), 285 | UserType::Team(team) => Some(format!("#{}", team.slug)), 286 | _ => None, 287 | } 288 | }) 289 | .chain(reviewers.keys().cloned()) 290 | .collect::>() // de-duplicate 291 | .into_iter() 292 | .collect(); 293 | 294 | sections.insert( 295 | MessageSection::Reviewers, 296 | requested_reviewers.iter().fold(String::new(), |out, slug| { 297 | if out.is_empty() { 298 | slug.to_string() 299 | } else { 300 | format!("{}, {}", out, slug) 301 | } 302 | }), 303 | ); 304 | 305 | if review_status == Some(ReviewStatus::Approved) { 306 | sections.insert( 307 | MessageSection::ReviewedBy, 308 | reviewers 309 | .iter() 310 | .filter_map(|(k, v)| { 311 | if v == &ReviewStatus::Approved { 312 | Some(k) 313 | } else { 314 | None 315 | } 316 | }) 317 | .fold(String::new(), |out, slug| { 318 | if out.is_empty() { 319 | slug.to_string() 320 | } else { 321 | format!("{}, {}", out, slug) 322 | } 323 | }), 324 | ); 325 | } 326 | 327 | Ok::<_, Error>(PullRequest { 328 | number: pr.number as u64, 329 | state: match pr.state { 330 | pull_request_query::PullRequestState::OPEN => { 331 | PullRequestState::Open 332 | } 333 | _ => PullRequestState::Closed, 334 | }, 335 | title: pr.title, 336 | body: Some(pr.body), 337 | sections, 338 | base, 339 | head, 340 | base_oid, 341 | head_oid, 342 | reviewers, 343 | review_status, 344 | merge_commit: pr 345 | .merge_commit 346 | .and_then(|sha| git2::Oid::from_str(&sha.oid).ok()), 347 | }) 348 | } 349 | 350 | pub async fn create_pull_request( 351 | &self, 352 | message: &MessageSectionsMap, 353 | base_ref_name: String, 354 | head_ref_name: String, 355 | draft: bool, 356 | ) -> Result { 357 | let number = octocrab::instance() 358 | .pulls(self.config.owner.clone(), self.config.repo.clone()) 359 | .create( 360 | message 361 | .get(&MessageSection::Title) 362 | .unwrap_or(&String::new()), 363 | head_ref_name, 364 | base_ref_name, 365 | ) 366 | .body(build_github_body(message)) 367 | .draft(Some(draft)) 368 | .send() 369 | .await? 370 | .number; 371 | 372 | Ok(number) 373 | } 374 | 375 | pub async fn update_pull_request( 376 | &self, 377 | number: u64, 378 | updates: PullRequestUpdate, 379 | ) -> Result<()> { 380 | octocrab::instance() 381 | .patch::( 382 | format!( 383 | "/repos/{}/{}/pulls/{}", 384 | self.config.owner, self.config.repo, number 385 | ), 386 | Some(&updates), 387 | ) 388 | .await?; 389 | 390 | Ok(()) 391 | } 392 | 393 | pub async fn request_reviewers( 394 | &self, 395 | number: u64, 396 | reviewers: PullRequestRequestReviewers, 397 | ) -> Result<()> { 398 | #[derive(Deserialize)] 399 | struct Ignore {} 400 | let _: Ignore = octocrab::instance() 401 | .post( 402 | format!( 403 | "/repos/{}/{}/pulls/{}/requested_reviewers", 404 | self.config.owner, self.config.repo, number 405 | ), 406 | Some(&reviewers), 407 | ) 408 | .await?; 409 | 410 | Ok(()) 411 | } 412 | 413 | pub async fn get_pull_request_mergeability( 414 | &self, 415 | number: u64, 416 | ) -> Result { 417 | let variables = pull_request_mergeability_query::Variables { 418 | name: self.config.repo.clone(), 419 | owner: self.config.owner.clone(), 420 | number: number as i64, 421 | }; 422 | let request_body = PullRequestMergeabilityQuery::build_query(variables); 423 | let response_body: Response< 424 | pull_request_mergeability_query::ResponseData, 425 | > = octocrab::instance() 426 | .post("/graphql", Some(&request_body)) 427 | .await?; 428 | 429 | if let Some(errors) = response_body.errors { 430 | let error = Err(eyre!("querying PR #{number} mergeability failed")); 431 | return errors.into_iter().fold(error, |err, e| err.wrap_err(e)); 432 | } 433 | 434 | let pr = response_body 435 | .data 436 | .ok_or_else(|| eyre!("failed to fetch PR"))? 437 | .repository 438 | .ok_or_else(|| eyre!("failed to find repository"))? 439 | .pull_request 440 | .ok_or_else(|| eyre!("failed to find PR"))?; 441 | 442 | Ok::<_, Error>(PullRequestMergeability { 443 | base: self.config.new_github_branch_from_ref(&pr.base_ref_name)?, 444 | head_oid: git2::Oid::from_str(&pr.head_ref_oid)?, 445 | mergeable: match pr.mergeable { 446 | pull_request_mergeability_query::MergeableState::CONFLICTING => Some(false), 447 | pull_request_mergeability_query::MergeableState::MERGEABLE => Some(true), 448 | pull_request_mergeability_query::MergeableState::UNKNOWN => None, 449 | _ => None, 450 | }, 451 | merge_commit: pr 452 | .merge_commit 453 | .and_then(|sha| git2::Oid::from_str(&sha.oid).ok()), 454 | }) 455 | } 456 | } 457 | 458 | #[derive(Debug, Clone)] 459 | pub struct GitHubBranch { 460 | ref_on_github: String, 461 | is_master_branch: bool, 462 | } 463 | 464 | impl GitHubBranch { 465 | pub fn new_from_ref(ghref: &str, master_branch_name: &str) -> Result { 466 | let ref_on_github = if ghref.starts_with("refs/heads/") { 467 | ghref.to_string() 468 | } else if ghref.starts_with("refs/") { 469 | return Err(eyre!("Ref '{ghref}' does not refer to a branch")); 470 | } else { 471 | format!("refs/heads/{ghref}") 472 | }; 473 | 474 | // The branch name is `ref_on_github` with the `refs/heads/` prefix 475 | // (length 11) removed 476 | let branch_name = &ref_on_github[11..]; 477 | let is_master_branch = branch_name == master_branch_name; 478 | 479 | Ok(Self { 480 | ref_on_github, 481 | is_master_branch, 482 | }) 483 | } 484 | 485 | pub fn new_from_branch_name( 486 | branch_name: &str, 487 | master_branch_name: &str, 488 | ) -> Self { 489 | Self { 490 | ref_on_github: format!("refs/heads/{branch_name}"), 491 | is_master_branch: branch_name == master_branch_name, 492 | } 493 | } 494 | 495 | pub fn on_github(&self) -> &str { 496 | &self.ref_on_github 497 | } 498 | 499 | pub fn is_master_branch(&self) -> bool { 500 | self.is_master_branch 501 | } 502 | 503 | pub fn branch_name(&self) -> &str { 504 | // The branch name is `ref_on_github` with the `refs/heads/` prefix 505 | // (length 11) removed 506 | &self.ref_on_github[11..] 507 | } 508 | } 509 | 510 | #[cfg(test)] 511 | mod tests { 512 | // Note this useful idiom: importing names from outer (for mod tests) scope. 513 | use super::*; 514 | 515 | #[test] 516 | fn test_new_from_ref_with_branch_name() { 517 | let r = GitHubBranch::new_from_ref("foo", "masterbranch").unwrap(); 518 | assert_eq!(r.on_github(), "refs/heads/foo"); 519 | assert_eq!(r.branch_name(), "foo"); 520 | assert!(!r.is_master_branch()); 521 | } 522 | 523 | #[test] 524 | fn test_new_from_ref_with_master_branch_name() { 525 | let r = 526 | GitHubBranch::new_from_ref("masterbranch", "masterbranch").unwrap(); 527 | assert_eq!(r.on_github(), "refs/heads/masterbranch"); 528 | assert_eq!(r.branch_name(), "masterbranch"); 529 | assert!(r.is_master_branch()); 530 | } 531 | 532 | #[test] 533 | fn test_new_from_ref_with_ref_name() { 534 | let r = GitHubBranch::new_from_ref("refs/heads/foo", "masterbranch") 535 | .unwrap(); 536 | assert_eq!(r.on_github(), "refs/heads/foo"); 537 | assert_eq!(r.branch_name(), "foo"); 538 | assert!(!r.is_master_branch()); 539 | } 540 | 541 | #[test] 542 | fn test_new_from_ref_with_master_ref_name() { 543 | let r = GitHubBranch::new_from_ref( 544 | "refs/heads/masterbranch", 545 | "masterbranch", 546 | ) 547 | .unwrap(); 548 | assert_eq!(r.on_github(), "refs/heads/masterbranch"); 549 | assert_eq!(r.branch_name(), "masterbranch"); 550 | assert!(r.is_master_branch()); 551 | } 552 | 553 | #[test] 554 | fn test_new_from_branch_name() { 555 | let r = GitHubBranch::new_from_branch_name("foo", "masterbranch"); 556 | assert_eq!(r.on_github(), "refs/heads/foo"); 557 | assert_eq!(r.branch_name(), "foo"); 558 | assert!(!r.is_master_branch()); 559 | } 560 | 561 | #[test] 562 | fn test_new_from_master_branch_name() { 563 | let r = 564 | GitHubBranch::new_from_branch_name("masterbranch", "masterbranch"); 565 | assert_eq!(r.on_github(), "refs/heads/masterbranch"); 566 | assert_eq!(r.branch_name(), "masterbranch"); 567 | assert!(r.is_master_branch()); 568 | } 569 | 570 | #[test] 571 | fn test_new_from_ref_with_edge_case_ref_name() { 572 | let r = GitHubBranch::new_from_ref( 573 | "refs/heads/refs/heads/foo", 574 | "masterbranch", 575 | ) 576 | .unwrap(); 577 | assert_eq!(r.on_github(), "refs/heads/refs/heads/foo"); 578 | assert_eq!(r.branch_name(), "refs/heads/foo"); 579 | assert!(!r.is_master_branch()); 580 | } 581 | 582 | #[test] 583 | fn test_new_from_edge_case_branch_name() { 584 | let r = GitHubBranch::new_from_branch_name( 585 | "refs/heads/foo", 586 | "masterbranch", 587 | ); 588 | assert_eq!(r.on_github(), "refs/heads/refs/heads/foo"); 589 | assert_eq!(r.branch_name(), "refs/heads/foo"); 590 | assert!(!r.is_master_branch()); 591 | } 592 | } 593 | -------------------------------------------------------------------------------- /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::collections::HashSet; 9 | use std::iter::zip; 10 | 11 | use color_eyre::eyre::{Error, Result, WrapErr as _, bail, eyre}; 12 | 13 | use crate::{ 14 | git::PreparedCommit, 15 | git_remote::PushSpec, 16 | github::{ 17 | GitHub, PullRequest, PullRequestRequestReviewers, PullRequestState, 18 | PullRequestUpdate, 19 | }, 20 | message::{MessageSection, validate_commit_message}, 21 | output::{output, write_commit_title}, 22 | utils::{parse_name_list, remove_all_parens, slugify}, 23 | }; 24 | use git2::Oid; 25 | use indoc::{formatdoc, indoc}; 26 | 27 | #[derive(Debug, clap::Parser)] 28 | pub struct DiffOptions { 29 | /// Create/update pull requests for the whole branch, not just the HEAD commit 30 | #[clap(long, short = 'a')] 31 | all: bool, 32 | 33 | /// Update the pull request title and description on GitHub from the local 34 | /// commit message 35 | #[clap(long)] 36 | update_message: bool, 37 | 38 | /// Submit any new Pull Request as a draft 39 | #[clap(long)] 40 | draft: bool, 41 | 42 | /// Message to be used for commits updating existing pull requests (e.g. 43 | /// 'rebase' or 'review comments') 44 | #[clap(long, short = 'm')] 45 | message: Option, 46 | 47 | /// Which commits in the branch should be created/updated. This can be a 48 | /// revspec such as HEAD~4..HEAD~1 or just one commit like HEAD~7. 49 | #[clap(long, short = 'r')] 50 | refs: Option, 51 | 52 | /// Submit this commit as if it was cherry-picked on master. Do not base it 53 | /// on any intermediate changes between the master branch and this commit. 54 | #[clap(long)] 55 | cherry_pick: bool, 56 | } 57 | 58 | fn get_oids(refs: &str, repo: &git2::Repository) -> Result> { 59 | // refs might be a single (eg 012345abc or HEAD) or a range (HEAD~4..HEAD~2) 60 | let revspec = repo.revparse(refs)?; 61 | 62 | let from = revspec 63 | .from() 64 | .ok_or_else(|| eyre!("Unexpectedly no from id in range"))? 65 | .id(); 66 | if revspec.mode().contains(git2::RevparseMode::SINGLE) { 67 | // simple case, just return the id 68 | return Ok(HashSet::from([from])); 69 | } 70 | let to = revspec 71 | .to() 72 | .ok_or_else(|| eyre!("Unexpectedly no to id in range"))? 73 | .id(); 74 | 75 | let mut walk = repo.revwalk()?; 76 | walk.push(to)?; 77 | walk.hide(from)?; 78 | walk.map(|r| Ok(r?)).collect() 79 | } 80 | 81 | pub async fn diff( 82 | opts: DiffOptions, 83 | git: &crate::git::Git, 84 | gh: &mut crate::github::GitHub, 85 | config: &crate::config::Config, 86 | ) -> Result<()> { 87 | // Abort right here if the local Git repository is not clean 88 | git.check_no_uncommitted_changes()?; 89 | 90 | let mut result = Ok(()); 91 | 92 | // Look up the commits on the local branch 93 | let mut prepared_commits = gh.get_prepared_commits()?; 94 | 95 | // The parent of the first commit in the list is the commit on master that 96 | // the local branch is based on 97 | let master_base_oid = if let Some(first_commit) = prepared_commits.first() { 98 | first_commit.parent_oid 99 | } else { 100 | output("👋", "Branch is empty - nothing to do. Good bye!")?; 101 | return result; 102 | }; 103 | 104 | // If refs is set, we want to track which commits to run `diff` against. The 105 | // simple approach would be to adjust the prepared_commits Vec (as with 106 | // opts.all above). This does not work however, as we need to know the 107 | // entire list (or more specifically the list after the first update) for 108 | // the rewrite_commit_messages step. This is not a problem for opts.all as 109 | // it only ever has a single commit to update, and so nothing after it. 110 | let revs_to_pr = match (opts.refs.as_deref(), opts.all) { 111 | (Some(refs), false) => Some(get_oids(refs, git.repo())?), 112 | (Some(_), true) => { 113 | bail!("Do not use --refs with --all"); 114 | } 115 | (None, true) => { 116 | // Operate on all commits 117 | None 118 | } 119 | (None, false) => { 120 | // Only operate on the HEAD commit. 121 | prepared_commits.drain(0..prepared_commits.len() - 1); 122 | None 123 | } 124 | }; 125 | 126 | #[allow(clippy::needless_collect)] 127 | let pull_request_tasks: Vec<_> = prepared_commits 128 | .iter() 129 | .map(|pc: &PreparedCommit| { 130 | if revs_to_pr 131 | .as_ref() 132 | .map(|revs| revs.contains(&pc.oid)) 133 | .unwrap_or(true) 134 | { 135 | // We are going to want to look at this pull request below. 136 | pc.pull_request_number.map(|number| { 137 | tokio::task::spawn_local( 138 | gh.clone().get_pull_request(number), 139 | ) 140 | }) 141 | } else { 142 | // We will be skipping this commit below, because we have as set 143 | // of commit oids to operate on, and this commit is not in 144 | // there. 145 | None 146 | } 147 | }) 148 | .collect(); 149 | 150 | let mut message_on_prompt = "".to_string(); 151 | 152 | for (prepared_commit, pull_request_task) in 153 | zip(prepared_commits.iter_mut(), pull_request_tasks.into_iter()) 154 | { 155 | if result.is_err() { 156 | break; 157 | } 158 | 159 | // Check whether to skip this commit because we have a hashset of oids 160 | // to operate on, but it doesn't contain this commit oid 161 | if revs_to_pr 162 | .as_ref() 163 | .map(|revs| !revs.contains(&prepared_commit.oid)) 164 | .unwrap_or(false) 165 | { 166 | continue; 167 | } 168 | 169 | let pull_request = if let Some(task) = pull_request_task { 170 | Some(task.await??) 171 | } else { 172 | None 173 | }; 174 | 175 | write_commit_title(prepared_commit)?; 176 | 177 | // The further implementation of the diff command is in a separate 178 | // function. This makes it easier to run the code to update the local 179 | // commit message with all the changes that the implementation makes at 180 | // the end, even if the implementation encounters an error or exits 181 | // early. 182 | result = diff_impl( 183 | &opts, 184 | &mut message_on_prompt, 185 | git, 186 | gh, 187 | config, 188 | prepared_commit, 189 | master_base_oid, 190 | pull_request, 191 | ) 192 | .await; 193 | } 194 | 195 | // This updates the commit message in the local Git repository (if it was 196 | // changed by the implementation) 197 | git.rewrite_commit_messages(prepared_commits.as_mut_slice(), None)?; 198 | 199 | result 200 | } 201 | 202 | #[allow(clippy::too_many_arguments)] 203 | async fn diff_impl( 204 | opts: &DiffOptions, 205 | message_on_prompt: &mut String, 206 | git: &crate::git::Git, 207 | gh: &mut crate::github::GitHub, 208 | config: &crate::config::Config, 209 | local_commit: &mut PreparedCommit, 210 | master_base_oid: Oid, 211 | pull_request: Option, 212 | ) -> Result<()> { 213 | // Parsed commit message of the local commit 214 | let message = &mut local_commit.message; 215 | 216 | // Check if the local commit is based directly on the master branch. 217 | let directly_based_on_master = local_commit.parent_oid == master_base_oid; 218 | 219 | // Determine the trees the Pull Request branch and the base branch should 220 | // have when we're done here. 221 | let (new_head_tree, new_base_tree) = if !opts.cherry_pick 222 | || directly_based_on_master 223 | { 224 | // Unless the user tells us to --cherry-pick, these should be the trees 225 | // of the current commit and its parent. 226 | // If the current commit is directly based on master (i.e. 227 | // directly_based_on_master is true), then we can do this here even when 228 | // the user tells us to --cherry-pick, because we would cherry pick the 229 | // current commit onto its parent, which gives us the same tree as the 230 | // current commit has, and the master base is the same as this commit's 231 | // parent. 232 | let head_tree = git.get_tree_oid_for_commit(local_commit.oid)?; 233 | let base_tree = git.get_tree_oid_for_commit(local_commit.parent_oid)?; 234 | 235 | (head_tree, base_tree) 236 | } else { 237 | // Cherry-pick the current commit onto master 238 | let index = git.cherrypick(local_commit.oid, master_base_oid)?; 239 | 240 | if index.has_conflicts() { 241 | bail!( 242 | "This commit cannot be cherry-picked on {master}.", 243 | master = config.master_ref.branch_name(), 244 | ); 245 | } 246 | 247 | // This is the tree we are getting from cherrypicking the local commit 248 | // on master. 249 | let cherry_pick_tree = git.write_index(index)?; 250 | let master_tree = git.get_tree_oid_for_commit(master_base_oid)?; 251 | 252 | (cherry_pick_tree, master_tree) 253 | }; 254 | 255 | if let Some(number) = local_commit.pull_request_number { 256 | output( 257 | "#️⃣ ", 258 | &format!( 259 | "Pull Request #{}: {}", 260 | number, 261 | config.pull_request_url(number) 262 | ), 263 | )?; 264 | } 265 | 266 | if local_commit.pull_request_number.is_none() || opts.update_message { 267 | validate_commit_message(message, config)?; 268 | } 269 | 270 | if let Some(ref pull_request) = pull_request { 271 | if pull_request.state == PullRequestState::Closed { 272 | return Err(Error::msg(formatdoc!( 273 | "Pull request is closed. If you want to open a new one, \ 274 | remove the 'Pull Request' section from the commit message." 275 | ))); 276 | } 277 | 278 | if !opts.update_message { 279 | let mut pull_request_updates: PullRequestUpdate = 280 | Default::default(); 281 | pull_request_updates.update_message(pull_request, message); 282 | 283 | if !pull_request_updates.is_empty() { 284 | output( 285 | "⚠️", 286 | indoc!( 287 | "The Pull Request's title/message differ from the \ 288 | local commit's message. 289 | Use `spr diff --update-message` to overwrite the \ 290 | title and message on GitHub with the local message, \ 291 | or `spr amend` to go the other way (rewrite the local \ 292 | commit message with what is on GitHub)." 293 | ), 294 | )?; 295 | } 296 | } 297 | } 298 | 299 | // Parse "Reviewers" section, if this is a new Pull Request 300 | let mut requested_reviewers = PullRequestRequestReviewers::default(); 301 | 302 | if local_commit.pull_request_number.is_none() 303 | && let Some(reviewers) = message.get(&MessageSection::Reviewers) 304 | { 305 | let reviewers = parse_name_list(reviewers); 306 | let mut checked_reviewers = Vec::new(); 307 | 308 | for reviewer in reviewers { 309 | // Teams are indicated with a leading # 310 | if let Some(slug) = reviewer.strip_prefix('#') { 311 | if let Ok(team) = 312 | GitHub::get_github_team((&config.owner).into(), slug.into()) 313 | .await 314 | { 315 | requested_reviewers 316 | .team_reviewers 317 | .push(team.slug.to_string()); 318 | 319 | checked_reviewers.push(reviewer); 320 | } else { 321 | bail!( 322 | "Reviewers field contains unknown team '{}'", 323 | reviewer, 324 | ); 325 | } 326 | } else if let Ok(user) = 327 | GitHub::get_github_user(reviewer.clone()).await 328 | { 329 | requested_reviewers.reviewers.push(user.login); 330 | if let Some(name) = user.name { 331 | checked_reviewers.push(format!( 332 | "{} ({})", 333 | reviewer.clone(), 334 | remove_all_parens(&name) 335 | )); 336 | } else { 337 | checked_reviewers.push(reviewer); 338 | } 339 | } else { 340 | bail!("Reviewers field contains unknown user '{}'", reviewer); 341 | } 342 | } 343 | 344 | message.insert(MessageSection::Reviewers, checked_reviewers.join(", ")); 345 | } 346 | 347 | // Get the name of the existing Pull Request branch, or constuct one if 348 | // there is none yet. 349 | 350 | let title = message 351 | .get(&MessageSection::Title) 352 | .map(|t| &t[..]) 353 | .unwrap_or(""); 354 | 355 | let pull_request_branch = match &pull_request { 356 | Some(pr) => pr.head.clone(), 357 | None => { 358 | config.new_github_branch(&gh.remote().find_unused_branch_name( 359 | &config.branch_prefix, 360 | &slugify(title), 361 | )?) 362 | } 363 | }; 364 | 365 | // Get the tree ids of the current head of the Pull Request, as well as the 366 | // base, and the commit id of the master commit this PR is currently based 367 | // on. 368 | // If there is no pre-existing Pull Request, we fill in the equivalent 369 | // values. 370 | let (pr_head_oid, pr_head_tree, pr_base_oid, pr_base_tree, pr_master_base) = 371 | if let Some(pr) = &pull_request { 372 | let pr_head_tree = git.get_tree_oid_for_commit(pr.head_oid)?; 373 | 374 | let current_master_oid = 375 | gh.remote().fetch_branch(config.master_ref.branch_name())?; 376 | let pr_base_oid = 377 | git.repo().merge_base(pr.head_oid, pr.base_oid)?; 378 | let pr_base_tree = git.get_tree_oid_for_commit(pr_base_oid)?; 379 | 380 | let pr_master_base = 381 | git.repo().merge_base(pr.head_oid, current_master_oid)?; 382 | 383 | ( 384 | pr.head_oid, 385 | pr_head_tree, 386 | pr_base_oid, 387 | pr_base_tree, 388 | pr_master_base, 389 | ) 390 | } else { 391 | let master_base_tree = 392 | git.get_tree_oid_for_commit(master_base_oid)?; 393 | ( 394 | master_base_oid, 395 | master_base_tree, 396 | master_base_oid, 397 | master_base_tree, 398 | master_base_oid, 399 | ) 400 | }; 401 | let needs_merging_master = pr_master_base != master_base_oid; 402 | 403 | // At this point we can check if we can exit early because no update to the 404 | // existing Pull Request is necessary 405 | if let Some(ref pull_request) = pull_request { 406 | // So there is an existing Pull Request... 407 | if !needs_merging_master 408 | && pr_head_tree == new_head_tree 409 | && pr_base_tree == new_base_tree 410 | { 411 | // ...and it does not need a rebase, and the trees of both Pull 412 | // Request branch and base are all the right ones. 413 | output("✅", "No update necessary")?; 414 | 415 | if opts.update_message { 416 | // However, the user requested to update the commit message on 417 | // GitHub 418 | 419 | let mut pull_request_updates: PullRequestUpdate = 420 | Default::default(); 421 | pull_request_updates.update_message(pull_request, message); 422 | 423 | if !pull_request_updates.is_empty() { 424 | // ...and there are actual changes to the message 425 | gh.update_pull_request( 426 | pull_request.number, 427 | pull_request_updates, 428 | ) 429 | .await?; 430 | output("✍", "Updated commit message on GitHub")?; 431 | } 432 | } 433 | 434 | return Ok(()); 435 | } 436 | } 437 | 438 | // Check if there is a base branch on GitHub already. That's the case when 439 | // there is an existing Pull Request, and its base is not the master branch. 440 | let base_branch = if let Some(ref pr) = pull_request { 441 | if pr.base.is_master_branch() { 442 | None 443 | } else { 444 | Some(pr.base.clone()) 445 | } 446 | } else { 447 | None 448 | }; 449 | 450 | // We are going to construct `pr_base_parent: Option`. 451 | // The value will be the commit we have to merge into the new Pull Request 452 | // commit to reflect changes in the parent of the local commit (by rebasing 453 | // or changing commits between master and this one, although technically 454 | // that's also rebasing). 455 | // If it's `None`, then we will not merge anything into the new Pull Request 456 | // commit. 457 | // If we are updating an existing PR, then there are three cases here: 458 | // (1) the parent tree of this commit is unchanged and we do not need to 459 | // merge in master, which means that the local commit was amended, but 460 | // not rebased. We don't need to merge anything into the Pull Request 461 | // branch. 462 | // (2) the parent tree has changed, but the parent of the local commit is on 463 | // master (or we are cherry-picking) and we are not already using a base 464 | // branch: in this case we can merge the master commit we are based on 465 | // into the PR branch, without going via a base branch. Thus, we don't 466 | // introduce a base branch here and the PR continues to target the 467 | // master branch. 468 | // (3) the parent tree has changed, and we need to use a base branch (either 469 | // because one was already created earlier, or we find that we are not 470 | // directly based on master now): we need to construct a new commit for 471 | // the base branch. That new commit's tree is always that of that local 472 | // commit's parent (thus making sure that the difference between base 473 | // branch and pull request branch are exactly the changes made by the 474 | // local commit, thus the changes we want to have reviewed). The new 475 | // commit may have one or two parents. The previous base is always a 476 | // parent (that's either the current commit on an existing base branch, 477 | // or the previous master commit the PR was based on if there isn't a 478 | // base branch already). In addition, if the master commit this commit 479 | // is based on has changed, (i.e. the local commit got rebased on newer 480 | // master in the meantime) then we have to merge in that master commit, 481 | // which will be the second parent. 482 | // If we are creating a new pull request then `pr_base_tree` (the current 483 | // base of the PR) was set above to be the tree of the master commit the 484 | // local commit is based one, whereas `new_base_tree` is the tree of the 485 | // parent of the local commit. So if the local commit for this new PR is on 486 | // master, those two are the same (and we want to apply case 1). If the 487 | // commit is not directly based on master, we have to create this new PR 488 | // with a base branch, so that is case 3. 489 | 490 | let (pr_base_parent, base_branch) = 491 | if pr_base_tree == new_base_tree && !needs_merging_master { 492 | // Case 1 493 | (None, base_branch) 494 | } else if base_branch.is_none() 495 | && (directly_based_on_master || opts.cherry_pick) 496 | { 497 | // Case 2 498 | (Some(master_base_oid), None) 499 | } else { 500 | // Case 3 501 | 502 | // We are constructing a base branch commit. 503 | // One parent of the new base branch commit will be the current base 504 | // commit, that could be either the top commit of an existing base 505 | // branch, or a commit on master. 506 | let mut parents = vec![pr_base_oid]; 507 | 508 | // If we need to rebase on master, make the master commit also a 509 | // parent (except if the first parent is that same commit, we don't 510 | // want duplicates in `parents`). 511 | if needs_merging_master && pr_base_oid != master_base_oid { 512 | parents.push(master_base_oid); 513 | } 514 | 515 | let new_base_branch_commit = git.create_derived_commit( 516 | local_commit.parent_oid, 517 | &format!( 518 | "[𝘀𝗽𝗿] {}\n\nCreated using spr {}\n\n[skip ci]", 519 | if pull_request.is_some() { 520 | "changes introduced through rebase".to_string() 521 | } else { 522 | format!( 523 | "changes to {} this commit is based on", 524 | config.master_ref.branch_name() 525 | ) 526 | }, 527 | env!("CARGO_PKG_VERSION"), 528 | ), 529 | new_base_tree, 530 | &parents[..], 531 | )?; 532 | 533 | // If `base_branch` is `None` (which means a base branch does not exist 534 | // yet), then make a `GitHubBranch` with a new name for a base branch 535 | let base_branch = if let Some(base_branch) = base_branch { 536 | base_branch 537 | } else { 538 | config.new_github_branch(&gh.remote().find_unused_branch_name( 539 | &config.branch_prefix, 540 | &format!( 541 | "{}.{}", 542 | config.master_ref.branch_name(), 543 | &slugify(title), 544 | ), 545 | )?) 546 | }; 547 | 548 | (Some(new_base_branch_commit), Some(base_branch)) 549 | }; 550 | 551 | let mut github_commit_message = opts.message.clone(); 552 | if pull_request.is_some() && github_commit_message.is_none() { 553 | let input = { 554 | let message_on_prompt = message_on_prompt.clone(); 555 | 556 | tokio::task::spawn_blocking(move || { 557 | dialoguer::Input::::new() 558 | .with_prompt("Message (leave empty to abort)") 559 | .with_initial_text(message_on_prompt) 560 | .allow_empty(true) 561 | .interact_text() 562 | }) 563 | .await?? 564 | }; 565 | 566 | if input.is_empty() { 567 | bail!("Aborted as per user request"); 568 | } 569 | 570 | *message_on_prompt = input.clone(); 571 | github_commit_message = Some(input); 572 | } 573 | 574 | // Construct the new commit for the Pull Request branch. First parent is the 575 | // current head commit of the Pull Request (we set this to the master base 576 | // commit earlier if the Pull Request does not yet exist) 577 | let mut pr_commit_parents = vec![pr_head_oid]; 578 | 579 | // If we prepared a commit earlier that needs merging into the Pull Request 580 | // branch, then that commit is a parent of the new Pull Request commit. 581 | if let Some(oid) = pr_base_parent { 582 | // ...unless if that's the same commit as the one we added to 583 | // pr_commit_parents first. 584 | if pr_commit_parents.first() != Some(&oid) { 585 | pr_commit_parents.push(oid); 586 | } 587 | } 588 | 589 | // Create the new commit 590 | let pr_commit = git.create_derived_commit( 591 | local_commit.oid, 592 | &format!( 593 | "{}\n\nCreated using spr {}", 594 | github_commit_message 595 | .as_ref() 596 | .map(|s| &s[..]) 597 | .unwrap_or("[𝘀𝗽𝗿] initial version"), 598 | env!("CARGO_PKG_VERSION"), 599 | ), 600 | new_head_tree, 601 | &pr_commit_parents[..], 602 | )?; 603 | 604 | let mut push_specs = vec![PushSpec { 605 | oid: Some(pr_commit), 606 | remote_ref: pull_request_branch.on_github(), 607 | }]; 608 | 609 | if let Some(pull_request) = pull_request { 610 | // We are updating an existing Pull Request 611 | 612 | if needs_merging_master { 613 | output( 614 | "⚾", 615 | &format!( 616 | "Commit was rebased - updating Pull Request #{}", 617 | pull_request.number 618 | ), 619 | )?; 620 | } else { 621 | output( 622 | "🔁", 623 | &format!( 624 | "Commit was changed - updating Pull Request #{}", 625 | pull_request.number 626 | ), 627 | )?; 628 | } 629 | 630 | // Things we want to update in the Pull Request on GitHub 631 | let mut pull_request_updates: PullRequestUpdate = Default::default(); 632 | 633 | if opts.update_message { 634 | pull_request_updates.update_message(&pull_request, message); 635 | } 636 | 637 | if let Some(base_branch) = base_branch { 638 | // We are using a base branch. 639 | 640 | if let Some(base_branch_commit) = pr_base_parent { 641 | // ...and we prepared a new commit for it, so we need to push an 642 | // update of the base branch. 643 | push_specs.push(PushSpec { 644 | oid: Some(base_branch_commit), 645 | remote_ref: base_branch.on_github(), 646 | }); 647 | } 648 | 649 | // Push the new commit onto the Pull Request branch (and also the 650 | // new base commit, if we added that to push_specs above). 651 | gh.remote() 652 | .push_to_remote(push_specs.as_slice()) 653 | .context("git push failed".to_string())?; 654 | 655 | // If the Pull Request's base is not set to the base branch yet, 656 | // change that now. 657 | if pull_request.base.branch_name() != base_branch.branch_name() { 658 | pull_request_updates.base = 659 | Some(base_branch.branch_name().to_string()); 660 | } 661 | } else { 662 | // The Pull Request is against the master branch. In that case we 663 | // only need to push the update to the Pull Request branch. 664 | gh.remote() 665 | .push_to_remote(push_specs.as_slice()) 666 | .context("git push failed".to_string())?; 667 | } 668 | 669 | if !pull_request_updates.is_empty() { 670 | gh.update_pull_request(pull_request.number, pull_request_updates) 671 | .await?; 672 | } 673 | } else { 674 | // We are creating a new Pull Request. 675 | 676 | // If there's a base branch, add it to the push 677 | if let (Some(base_branch), Some(base_branch_commit)) = 678 | (&base_branch, pr_base_parent) 679 | { 680 | push_specs.push(PushSpec { 681 | oid: Some(base_branch_commit), 682 | remote_ref: base_branch.on_github(), 683 | }); 684 | } 685 | // Push the pull request branch and the base branch if present 686 | gh.remote() 687 | .push_to_remote(push_specs.as_slice()) 688 | .context("git push failed".to_string())?; 689 | 690 | // Then call GitHub to create the Pull Request. 691 | let pull_request_number = gh 692 | .create_pull_request( 693 | message, 694 | base_branch 695 | .as_ref() 696 | .unwrap_or(&config.master_ref) 697 | .branch_name() 698 | .to_string(), 699 | pull_request_branch.branch_name().to_string(), 700 | opts.draft, 701 | ) 702 | .await?; 703 | 704 | let pull_request_url = config.pull_request_url(pull_request_number); 705 | 706 | output( 707 | "✨", 708 | &format!( 709 | "Created new Pull Request #{}: {}", 710 | pull_request_number, &pull_request_url, 711 | ), 712 | )?; 713 | 714 | message.insert(MessageSection::PullRequest, pull_request_url); 715 | 716 | let result = gh 717 | .request_reviewers(pull_request_number, requested_reviewers) 718 | .await; 719 | match result { 720 | Ok(()) => (), 721 | Err(report) => { 722 | output("⚠️", "Requesting reviewers failed")?; 723 | for message in report.chain() { 724 | output(" ", &message.to_string())?; 725 | } 726 | } 727 | } 728 | } 729 | 730 | Ok(()) 731 | } 732 | --------------------------------------------------------------------------------