├── LICENSE ├── README.md ├── combine-prs.yml └── images ├── combined-pr.png └── run.png /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Hrvey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Combine PRs 2 | This GitHub Workflow allows you to combine a number of PRs into a single one, by merging the branches for each PR into a new branch and opening a PR for it. Specifically, it was created to group Dependabot PRs together, but it is generic enough that it can be used with other services that create PRs with branch names that have a specific prefix (such as `dependabot/{branchname}` in the case of dependabot). 3 | 4 | ![Combined PR](/images/combined-pr.png?raw=true "Combined PR") 5 | 6 | # Get started 7 | To use this, copy [combine-prs.yml](combine-prs.yml) into your repository to the folder `.github/workflows/` and commit and push it to GitHub. 8 | Then on GitHub, click the 'Actions' tab at the top of your repository, then under 'All workflows' on the left click 'Combine PRs', and finally on the righthand side, click 'Run workflow'. 9 | 10 | ![Run workflow](/images/run.png?raw=true "Run workflow") 11 | 12 | ⚠️ If you don't run checks on your repo, set mustBeGreen to false (consider setting it to default to false in the yml file, so you don't have to remember to change it to false on every run) ⚠️ 13 | 14 | # Customization 15 | The workflow uses only the [actions/github-script](https://github.com/actions/github-script/) action published by GitHub. 16 | As you can see from the image you can easily set another branch prefix for matching, and choose whether to only include branches that are green (have the 'success' status on GitHub, e.g. from successful CI) - this is the default. If you don't run checks, then set this setting to false, since a PR with no checks will have a status of 'pending' rather than 'success'. 17 | 18 | Finally, you can set a label on PRs that you want to exclude when combining PRs - by default this label is 'nocombine'. 19 | 20 | You are also welcome to modify the code to your needs if you need more customization than that (for example it currently doesn't work with forks, only branches within the same repo). 21 | 22 | # Limitations 23 | This workflow merges the branches of the PRs together into a single branch using git's simple merge and automatic merge strategies. Unfortunately that means it has the same limitations as these merge strategies, so it will only work on PRs with branches that can be auto-merged without running into a merge conflict. In case any merge conflicts happen, the created combined PR will just include as many branches as could be merged without conflict, and it will list which PRs were left out due to merge conflicts. 24 | The way merge conflicts will typically happen when updating dependencies is that different dependency updates end up modifying the same line in the `.lock` file (the file that ensures stability in exactly which versions of depencies are currently being used, when dependencies are defined in a broad enough way that multiple different versions could satisfy the constraints). 25 | This typically happens if they share some common third dependency - for example if some modular framework has components A and B, that both depend on C, then updating A and B independently might lead to also indirectly updating C to two different versions in the two branches, and that will prevent them from being mergable. 26 | 27 | The "correct" solution here is to add both dependencies together, then let the package manager resolve the dependencies to hopefully find a version of C to put in the `.lock` file that will satisfy both A and B. 28 | If you find yourself in need of this, then this workflow won't help you, and you should consider switching to a dependency update service that will update dependencies together like that (at the moment *Dependabot* does not, but *Depfu* and *Renovate* do). 29 | -------------------------------------------------------------------------------- /combine-prs.yml: -------------------------------------------------------------------------------- 1 | name: 'Combine PRs' 2 | 3 | # Controls when the action will run - in this case triggered manually 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | branchPrefix: 8 | description: 'Branch prefix to find combinable PRs based on' 9 | required: true 10 | default: 'dependabot' 11 | mustBeGreen: 12 | description: 'Only combine PRs that are green (status is success). Set to false if repo does not run checks' 13 | type: boolean 14 | required: true 15 | default: true 16 | combineBranchName: 17 | description: 'Name of the branch to combine PRs into' 18 | required: true 19 | default: 'combine-prs-branch' 20 | ignoreLabel: 21 | description: 'Exclude PRs with this label' 22 | required: true 23 | default: 'nocombine' 24 | 25 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 26 | jobs: 27 | # This workflow contains a single job called "combine-prs" 28 | combine-prs: 29 | # The type of runner that the job will run on 30 | runs-on: ubuntu-latest 31 | 32 | # Steps represent a sequence of tasks that will be executed as part of the job 33 | steps: 34 | - uses: actions/github-script@v6 35 | id: create-combined-pr 36 | name: Create Combined PR 37 | with: 38 | github-token: ${{secrets.GITHUB_TOKEN}} 39 | script: | 40 | const pulls = await github.paginate('GET /repos/:owner/:repo/pulls', { 41 | owner: context.repo.owner, 42 | repo: context.repo.repo 43 | }); 44 | let branchesAndPRStrings = []; 45 | let baseBranch = null; 46 | let baseBranchSHA = null; 47 | for (const pull of pulls) { 48 | const branch = pull['head']['ref']; 49 | console.log('Pull for branch: ' + branch); 50 | if (branch.startsWith('${{ github.event.inputs.branchPrefix }}')) { 51 | console.log('Branch matched prefix: ' + branch); 52 | let statusOK = true; 53 | if(${{ github.event.inputs.mustBeGreen }}) { 54 | console.log('Checking green status: ' + branch); 55 | const stateQuery = `query($owner: String!, $repo: String!, $pull_number: Int!) { 56 | repository(owner: $owner, name: $repo) { 57 | pullRequest(number:$pull_number) { 58 | commits(last: 1) { 59 | nodes { 60 | commit { 61 | statusCheckRollup { 62 | state 63 | } 64 | } 65 | } 66 | } 67 | } 68 | } 69 | }` 70 | const vars = { 71 | owner: context.repo.owner, 72 | repo: context.repo.repo, 73 | pull_number: pull['number'] 74 | }; 75 | const result = await github.graphql(stateQuery, vars); 76 | const [{ commit }] = result.repository.pullRequest.commits.nodes; 77 | const state = commit.statusCheckRollup.state 78 | console.log('Validating status: ' + state); 79 | if(state != 'SUCCESS') { 80 | console.log('Discarding ' + branch + ' with status ' + state); 81 | statusOK = false; 82 | } 83 | } 84 | console.log('Checking labels: ' + branch); 85 | const labels = pull['labels']; 86 | for(const label of labels) { 87 | const labelName = label['name']; 88 | console.log('Checking label: ' + labelName); 89 | if(labelName == '${{ github.event.inputs.ignoreLabel }}') { 90 | console.log('Discarding ' + branch + ' with label ' + labelName); 91 | statusOK = false; 92 | } 93 | } 94 | if (statusOK) { 95 | console.log('Adding branch to array: ' + branch); 96 | const prString = '#' + pull['number'] + ' ' + pull['title']; 97 | branchesAndPRStrings.push({ branch, prString }); 98 | baseBranch = pull['base']['ref']; 99 | baseBranchSHA = pull['base']['sha']; 100 | } 101 | } 102 | } 103 | if (branchesAndPRStrings.length == 0) { 104 | core.setFailed('No PRs/branches matched criteria'); 105 | return; 106 | } 107 | try { 108 | await github.rest.git.createRef({ 109 | owner: context.repo.owner, 110 | repo: context.repo.repo, 111 | ref: 'refs/heads/' + '${{ github.event.inputs.combineBranchName }}', 112 | sha: baseBranchSHA 113 | }); 114 | } catch (error) { 115 | console.log(error); 116 | core.setFailed('Failed to create combined branch - maybe a branch by that name already exists?'); 117 | return; 118 | } 119 | 120 | let combinedPRs = []; 121 | let mergeFailedPRs = []; 122 | for(const { branch, prString } of branchesAndPRStrings) { 123 | try { 124 | await github.rest.repos.merge({ 125 | owner: context.repo.owner, 126 | repo: context.repo.repo, 127 | base: '${{ github.event.inputs.combineBranchName }}', 128 | head: branch, 129 | }); 130 | console.log('Merged branch ' + branch); 131 | combinedPRs.push(prString); 132 | } catch (error) { 133 | console.log('Failed to merge branch ' + branch); 134 | mergeFailedPRs.push(prString); 135 | } 136 | } 137 | 138 | console.log('Creating combined PR'); 139 | const combinedPRsString = combinedPRs.join('\n'); 140 | let body = '✅ This PR was created by the Combine PRs action by combining the following PRs:\n' + combinedPRsString; 141 | if(mergeFailedPRs.length > 0) { 142 | const mergeFailedPRsString = mergeFailedPRs.join('\n'); 143 | body += '\n\n⚠️ The following PRs were left out due to merge conflicts:\n' + mergeFailedPRsString 144 | } 145 | await github.rest.pulls.create({ 146 | owner: context.repo.owner, 147 | repo: context.repo.repo, 148 | title: 'Combined PR', 149 | head: '${{ github.event.inputs.combineBranchName }}', 150 | base: baseBranch, 151 | body: body 152 | }); 153 | -------------------------------------------------------------------------------- /images/combined-pr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hrvey/combine-prs-workflow/0db0c4b7bd918267c5b534bf1700619ab45a56a0/images/combined-pr.png -------------------------------------------------------------------------------- /images/run.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hrvey/combine-prs-workflow/0db0c4b7bd918267c5b534bf1700619ab45a56a0/images/run.png --------------------------------------------------------------------------------