├── .github └── stale.yml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── action.yml ├── examples ├── assignees-example.yml ├── branch-from-environment.yml ├── custom-body-example.yml ├── outputs-example.yml ├── push-example.yml ├── release-example.yml └── reviewers-example.yml ├── img ├── open-source-halloween-2021.png └── outputs.png └── pull-request.py /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: wontfix 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | This is a manually generated log to track changes to the repository for each release. 4 | Each section should include general headers such as **Implemented enhancements** 5 | and **Merged pull requests**. All closed issued and bug fixes should be 6 | represented by the pull requests that fixed them. Critical items to know are: 7 | 8 | - renamed commands 9 | - deprecated / removed commands 10 | - changed defaults 11 | - backward incompatible changes 12 | 13 | 14 | Versions correspond with GitHub releases that can be referenced with @ using actions. 15 | 16 | ## [master](https://github.com/vsoch/pull-request-action/tree/master) (master) 17 | - alpine cannot install to system python anymore (1.1.0) 18 | - bugfix of missing output values (1.0.23) 19 | - bugfix of token handling if 401 error received (missing 401 case) (1.0.21) 20 | - bugfix of writing to environment file (missing newline) (1.0.19) 21 | - bugfix of missing from branch with scheduled run (1.0.16) 22 | - forgot to add assignees (1.0.15) 23 | - output and environment variables for PR number and return codes (1.0.5) 24 | - added support for reviewer (individual and team) assignments (1.0.4) 25 | - added support for maintainer can modify and assignees (1.0.3) 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | 3 | # docker build -t vanessa/pull-request-action . 4 | 5 | LABEL "com.github.actions.name"="Pull Request on Branch Push" 6 | LABEL "com.github.actions.description"="Create a pull request when a branch is created or updated" 7 | LABEL "com.github.actions.icon"="activity" 8 | LABEL "com.github.actions.color"="yellow" 9 | 10 | # Newer alpine we are not allowed to install to system python 11 | RUN apk --no-cache add python3 py3-pip py3-virtualenv git bash && \ 12 | python3 -m venv /opt/env && \ 13 | /opt/env/bin/pip3 install --break-system-packages requests 14 | COPY pull-request.py /pull-request.py 15 | 16 | RUN chmod u+x /pull-request.py 17 | ENTRYPOINT ["/opt/env/bin/python3", "/pull-request.py"] 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2023 Vanessa Sochat 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 | # Automated Branch Pull Requests 2 | 3 | This action will open a pull request to master branch (or otherwise specified) 4 | whenever a branch with some prefix is pushed to. The idea is that you can 5 | set up some workflow that pushes content to branches of the repostory, 6 | and you would then want this push reviewed for merge to master. 7 | 8 | Here is an example of what to put in your `.github/workflows/pull-request.yml` file to 9 | trigger the action. 10 | 11 | ```yaml 12 | name: Pull Request on Branch Push 13 | on: 14 | push: 15 | branches-ignore: 16 | - staging 17 | - launchpad 18 | - production 19 | jobs: 20 | auto-pull-request: 21 | name: PullRequestAction 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: pull-request-action 25 | uses: vsoch/pull-request-action@master 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | BRANCH_PREFIX: "update/" 29 | PULL_REQUEST_BRANCH: "master" 30 | ``` 31 | 32 | **Important**: Make sure to use a stable [release](https://github.com/vsoch/pull-request-action/releases) instead of a branch for your workflow. 33 | 34 | 35 | ## Environment Variable Inputs 36 | 37 | Unlike standard actions, this action just uses variables from the environment. 38 | 39 | | Name | Description | Required | Default | 40 | |------|-------------|----------|---------| 41 | | BRANCH_PREFIX | the prefix to filter to. If the branch doesn't start with the prefix, it will be ignored | false | "" | 42 | | PULL_REQUEST_REPOSITORY | Choose another repository instead of default GITHUB_REPOSITORY for the PR | false | | 43 | | PULL_REQUEST_TOKEN | [Personal Access Token(PAT)](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token) only if you define a different repository with PULL_REQUEST_REPOSITORY | false | | 44 | | PULL_REQUEST_BRANCH | open pull request against this branch | false | master | 45 | | PULL_REQUEST_FROM_BRANCH | if a branch isn't found in your GitHub payload, use this branch | false | | 46 | | PULL_REQUEST_BODY | the body for the pull request | false | | 47 | | PULL_REQUEST_TITLE | the title for the pull request | false | | 48 | | PULL_REQUEST_DRAFT | should this be a draft PR? | false | unset | 49 | | MAINTAINER_CANT_MODIFY | Do not allow the maintainer to modify the PR | false | unset | 50 | | PULL_REQUEST_ASSIGNEES | A list (string with spaces) of users to assign | false | unset | 51 | | PULL_REQUEST_REVIEWERS | A list (string with spaces) of users to assign review | false | unset | 52 | | PULL_REQUEST_TEAM_REVIEWERS | A list (string with spaces) of teams to assign review | false | unset | 53 | | PASS_ON_ERROR | Instead of failing on an error response, pass | false | unset | 54 | | PASS_IF_EXISTS | Instead of failing if the pull request already exists, pass | false | unset | 55 | | PULL_REQUEST_UPDATE | If the pull request already exists, update it | false | unset | 56 | | PULL_REQUEST_STATE | If `PULL_REQUEST_UPDATE` is true, update to this state (open, closed) | false |open | 57 | 58 | For `PULL_REQUEST_DRAFT`, `PASS_ON_ERROR`, `PASS_IF_EXISTS`, and `MAINTAINER_CANT_MODIFY`, these are 59 | treated as environment booleans. If they are defined in the environment, they trigger the 60 | "true" condition. E.g.,: 61 | 62 | - Define `MAINTAINER_CANT_MODIFY` if you don't want the maintainer to be able to modify the pull request. 63 | - Define `PULL_REQUEST_DRAFT` if you want the PR to be a draft. 64 | - Define `PASS_ON_ERROR` if you want the PR to not exit given any non 200/201 response. 65 | - Define `PASS_IF_EXISTS` if you want the PR to not exit given the pull request is already open. 66 | - Define `PULL_REQUEST_UPDATE` if you want the pull request to be updated if it already exits. 67 | 68 | For `PULL_REQUEST_ASSIGNEES`, `PULL_REQUEST_REVIEWERS`, and `PULL_REQUEST_TEAM_REVIEWERS` 69 | you can provide a string of one or more GitHub usernames (or team names) to 70 | assign to the issue. Note that only users with push access can add assigness to 71 | an issue or PR, they are ignored otherwise. 72 | 73 | The `GITHUB_TOKEN` secret is required to interact and authenticate with the GitHub API to open 74 | the pull request. The example is [deployed here](https://github.com/vsoch/pull-request-action-example) with an example opened (and merged) [pull request here](https://github.com/vsoch/pull-request-action-example/pull/1) if needed. 75 | 76 | If you want to create a pull request to another repository, for example, a pull request to the upstream repository, you need to define PULL_REQUEST_REPOSITORY and PULL_REQUEST_TOKEN. The PULL_REQUEST_TOKEN is one [Personal Access Token(PAT)](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token), which can be save in the [encrypted secrets](https://docs.github.com/en/actions/reference/encrypted-secrets#creating-encrypted-secrets-for-a-repository) 77 | 78 | ## Outputs 79 | 80 | The action sets a few useful output and environment variables. An output can 81 | be referenced later as `${{ steps..outputs. }}`. 82 | An environment variable of course can be referenced as you usually would. 83 | 84 | | Name | Description | Environment | 85 | |------|-------------|-------------| 86 | | pull_request_number |If the pull request is opened, this is the number for it. | PULL_REQUEST_NUMBER | 87 | | pull_request_url |If the pull request is opened, the html url for it. | PULL_REQUEST_URL | 88 | | pull_request_return_code | Return code for the pull request | PULL_REQUEST_RETURN_CODE | 89 | | assignees_return_code | Return code for the assignees request | ASSIGNEES_RETURN_CODE | 90 | | reviewers_return_code | Return code for the reviewers request | REVIEWERS_RETURN_CODE | 91 | 92 | See the [examples/outputs-example.yml](examples/outputs-example.yml) for how this works. 93 | In this example, we can reference `${{ steps.pull_request.outputs.pull_request_url }}` 94 | in either another environment variable declaration, or within a run statement to access 95 | our variable `pull_request_url` that was generated in a step with id `pull_request`. 96 | The screenshot below shows the example in action to interact with outputs in several ways. 97 | 98 | ![img/outputs.png](img/outputs.png) 99 | 100 | ## Examples 101 | 102 | Example workflows are provided in [examples](examples), and please contribute any 103 | examples that you might have to help other users! You can get the same commit hashes 104 | and commented tags if you use the [action-updater](https://github.com/vsoch/action-updater) 105 | also maintained by @vsoch. We will walk through a basic 106 | example here for a niche case. Let's say that we are opening a pull request on the release event. This would mean 107 | that the payload's branch variable would be null. We would need to define `PULL_REQUEST_FROM`. How would 108 | we do that? We can [set environment variables](https://github.com/actions/toolkit/blob/main/docs/commands.md#environment-files) for next steps. Here is an example: 109 | 110 | ```yaml 111 | name: Pull Request on Branch Push 112 | on: [release] 113 | jobs: 114 | pull-request-on-release: 115 | name: PullRequestAction 116 | runs-on: ubuntu-latest 117 | steps: 118 | - name: Checkout Code 119 | uses: actions/checkout@v2 120 | - name: Derive from branch name 121 | run: | 122 | # do custom parsing of your code / date to derive a branch from 123 | PR_BRANCH_FROM=release-v$(cat VERSION) 124 | echo "PULL_REQUEST_FROM_BRANCH=${PR_BRANCH_FROM}" >> $GITHUB_ENV 125 | - name: pull-request-action 126 | uses: vsoch/pull-request-action@master 127 | env: 128 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 129 | PULL_REQUEST_BRANCH: "master" 130 | ``` 131 | 132 | The above workflow is triggered on a release, so the branch will be null in the GItHub 133 | payload. Since we want the release PR to come from a special branch, we derive it 134 | in the second step, and then set the `PULL_REQUEST_FROM_BRANCH` variable in the environment 135 | for the next step. In the Pull Request Action step, the pull request 136 | will be opened from `PULL_REQUEST_FROM_BRANCH` against `PULL_REQUEST_BRANCH`, which is 137 | master. If we do not set this variable, the job will exit in an error, 138 | as it is not clear what action to take. 139 | 140 | 141 | ## Example use Case: Update Registry 142 | 143 | As an example, I created this action to be intended for an 144 | [organizational static registry](https://www.github.com/singularityhub/registry-org) for container builds. 145 | Specifically, you have modular repositories building container recipes, and then opening pull requests to the 146 | registry to update it. 147 | 148 | - the container collection content should be generated from a separate GitHub repository, including the folder structure (manifests, tags, collection README) that are expected. 149 | - the container collection metadata is pushed to a new branch on the registry repository, with namespace matching the GitHub repository, meaning that each GitHub repository always has a unique branch for its content. 150 | - pushing this branch that starts with the prefix (update/) triggers the GitHub actions to open the pull request. 151 | 152 | If the branch is already open for PR, it updates it. Take a look at [this example](https://github.com/singularityhub/registry-org/pull/8) 153 | for the pull request opened when we updated the previous GitHub syntax to the new yaml syntax. Although this 154 | doesn't describe the workflow above, it works equivalently in terms of the triggers. 155 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Pull Request Action' 2 | description: 'A GitHub action to open a pull request' 3 | author: 'vsoch' 4 | runs: 5 | using: 'docker' 6 | image: 'Dockerfile' 7 | branding: 8 | icon: 'activity' 9 | color: 'yellow' 10 | outputs: 11 | pull_request_number: 12 | description: 'If the pull request is opened, this is the number for it.' 13 | pull_request_url: 14 | description: 'If the pull request is opened, the html url for it.' 15 | pull_request_return_code: 16 | description: 'The pull request return code.' 17 | assignees_return_code: 18 | description: 'The add assignees post return code.' 19 | reviewers_return_code: 20 | description: 'The add reviewers post return code.' 21 | -------------------------------------------------------------------------------- /examples/assignees-example.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request on Branch Push 2 | on: 3 | push: 4 | branches-ignore: 5 | - devel 6 | jobs: 7 | auto-pull-request: 8 | name: PullRequestAction 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: pull-request-action 12 | uses: vsoch/pull-request-action@d703f40f3af5ae294f9816395ddf2e3d2d3feafa # 1.0.21 13 | env: 14 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 15 | BRANCH_PREFIX: update/ 16 | PULL_REQUEST_BRANCH: master 17 | PULL_REQUEST_ASSIGNEES: vsoch 18 | -------------------------------------------------------------------------------- /examples/branch-from-environment.yml: -------------------------------------------------------------------------------- 1 | name: derive-branch-from-environment 2 | 3 | on: 4 | schedule: - 5 | cron: 0 0 * * 0 6 | 7 | jobs: 8 | DoSomeUpdate: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout Repository 12 | uses: actions/checkout@v3 13 | - name: Install or Do Something to Change repository 14 | run: | 15 | echo "This is a new file." >> newfile.txt 16 | 17 | - name: Checkout New Branch 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | BRANCH_AGAINST: master 21 | run: | 22 | printf "GitHub Actor: ${GITHUB_ACTOR}\n" 23 | export BRANCH_FROM="update/newfile-$(date '+%Y-%m-%d')" 24 | git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" 25 | git branch 26 | git checkout -b "${BRANCH_FROM}" || git checkout "${BRANCH_FROM}" 27 | git branch 28 | 29 | git config --global user.name "github-actions" 30 | git config --global user.email "github-actions@users.noreply.github.com" 31 | 32 | git add newfile.txt 33 | 34 | if git diff-index --quiet HEAD --; then 35 | printf "No changes\n" 36 | else 37 | printf "Changes\n" 38 | git commit -m "Automated deployment to update software database $(date '+%Y-%m-%d')" 39 | git push origin "${BRANCH_FROM}" 40 | fi 41 | # Here is where we are setting the environment variable! 42 | echo "PULL_REQUEST_FROM_BRANCH=${BRANCH_FROM}" >> $GITHUB_ENV 43 | 44 | - name: Open Pull Request 45 | uses: vsoch/pull-request-action@d703f40f3af5ae294f9816395ddf2e3d2d3feafa # 1.0.21 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | PULL_REQUEST_BRANCH: master 49 | -------------------------------------------------------------------------------- /examples/custom-body-example.yml: -------------------------------------------------------------------------------- 1 | name: Hotfix Branch Pull Request 2 | on: 3 | push: 4 | branches-ignore: 5 | - master 6 | - production 7 | 8 | # See https://github.com/vsoch/pull-request-action/issues/47#issuecomment-707109132 9 | 10 | jobs: 11 | auto-pull-request: 12 | name: PullRequestAction 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Generate branch name 16 | uses: actions/github-script@v6 17 | id: set-branch-name 18 | with: 19 | script: | 20 | const capitalize = (name) => name.charAt(0).toUpperCase() + name.slice(1); 21 | const emoji = context.payload.ref.startsWith("refs/heads/feature") 22 | ? "✨ " 23 | : context.payload.ref.startsWith("refs/heads/hotfix") 24 | ? "🚑 " 25 | : ""; 26 | return `${emoji}${capitalize( 27 | context.payload.ref 28 | .replace("refs/heads/", "") 29 | .replace(/-/g, " ") 30 | .replace("feature ", "") 31 | .replace("hotfix ", "") 32 | )}`; 33 | result-encoding: string 34 | - name: Set branch name 35 | run: echo "PULL_REQUEST_TITLE=${{steps.set-branch-name.outputs.result}}" >> $GITHUB_ENV 36 | - name: Generate PR body 37 | uses: actions/github-script@v6 38 | id: set-pr-body 39 | with: 40 | script: | 41 | return `I'm opening this pull request for this branch, pushed by @${ 42 | context.payload.head_commit.author.username 43 | } with ${context.payload.commits.length} commit${ 44 | context.payload.commits.length === 1 ? "" : "s" 45 | }.`; 46 | result-encoding: string 47 | - name: Set PR body 48 | run: echo "PULL_REQUEST_BODY=${{steps.set-pr-body.outputs.result}}" >> $GITHUB_ENV 49 | - name: pull-request-action 50 | uses: vsoch/pull-request-action@d703f40f3af5ae294f9816395ddf2e3d2d3feafa # 1.0.21 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | BRANCH_PREFIX: hotfix- 54 | PULL_REQUEST_BRANCH: production 55 | PULL_REQUEST_REVIEWERS: AnandChowdhary 56 | -------------------------------------------------------------------------------- /examples/outputs-example.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request on Branch Push 2 | on: 3 | push: 4 | branches-ignore: 5 | - devel 6 | jobs: 7 | auto-pull-request: 8 | name: PullRequestAction 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: pull-request-action 12 | id: pull_request 13 | uses: vsoch/pull-request-action@d703f40f3af5ae294f9816395ddf2e3d2d3feafa # 1.0.21 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | BRANCH_PREFIX: update/ 17 | PULL_REQUEST_BRANCH: master 18 | PULL_REQUEST_REVIEWERS: vsoch 19 | - name: Test outputs 20 | env: 21 | pull_request_number_output: ${{ steps.pull_request.outputs.pull_request_number }} 22 | pull_request_url_output: ${{ steps.pull_request.outputs.pull_request_url }} 23 | run: | 24 | echo "Pull request number from output: ${pull_request_number_output}" 25 | echo "Pull request url from output: ${pull_request_url_output}" 26 | echo "Pull request number from environment: ${PULL_REQUEST_NUMBER}" 27 | echo "Pull request url from environment: ${PULL_REQUEST_URL}" 28 | echo "Another way to specify from output ${{ steps.pull_request.outputs.pull_request_number }}" 29 | -------------------------------------------------------------------------------- /examples/push-example.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request on Branch Push 2 | on: 3 | push: 4 | branches-ignore: 5 | - staging 6 | - launchpad 7 | - production 8 | jobs: 9 | auto-pull-request: 10 | name: PullRequestAction 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: pull-request-action 14 | uses: vsoch/pull-request-action@d703f40f3af5ae294f9816395ddf2e3d2d3feafa # 1.0.21 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | BRANCH_PREFIX: update/ 18 | PULL_REQUEST_BRANCH: master 19 | -------------------------------------------------------------------------------- /examples/release-example.yml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: 4 | - published 5 | 6 | jobs: 7 | persist-new-suite-yml: 8 | name: Commit Suite Release YML 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | # Likely other steps go here 13 | - name: Set BRANCH_NAME 14 | run: | 15 | tag_name=${{github.event.release.tag_name}} 16 | echo "Tag: $tag_name" 17 | 18 | version=$(echo "$tag_name" | sed 's/^v//') 19 | echo "Version: $version" 20 | 21 | echo "suite_version=${version}" >> $GITHUB_OUTPUT 22 | echo "suite_update_branch=suite_${version}" >> $GITHUB_OUTPUT 23 | id: data 24 | 25 | - name: Permanently save the new suite release 26 | run: | 27 | mkdir -p releases 28 | new_suite_version_yml="releases/suite_${{ steps.data.outputs.suite_version }}.yml" 29 | echo "Suite target file: $new_suite_version_yml" 30 | cp suite.yml "${new_suite_version_yml}" 31 | git add "${new_suite_version_yml}" 32 | git commit -m "Suite v${{ steps.data.outputs.suite_version }} auto-commit of new release files" 33 | 34 | - name: Push files 35 | run: git push --force "https://${{ github.actor }}:${{secrets.GITHUB_TOKEN}}@github.com/${{ github.repository }}.git" "HEAD:${{ steps.data.outputs.suite_update_branch }}" 36 | 37 | - name: Open a PR to the default branch 38 | uses: vsoch/pull-request-action@d703f40f3af5ae294f9816395ddf2e3d2d3feafa # 1.0.21 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | PULL_REQUEST_FROM_BRANCH: ${{ steps.data.outputs.suite_update_branch }} 42 | PULL_REQUEST_BRANCH: master 43 | PULL_REQUEST_TITLE: 'Action: Update suite release file for v${{ steps.data.outputs.suite_version }}' 44 | PULL_REQUEST_BODY: Auto-generated PR! 45 | -------------------------------------------------------------------------------- /examples/reviewers-example.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request on Branch Push 2 | on: 3 | push: 4 | branches-ignore: 5 | - devel 6 | jobs: 7 | auto-pull-request: 8 | name: PullRequestAction 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: pull-request-action 12 | uses: vsoch/pull-request-action@d703f40f3af5ae294f9816395ddf2e3d2d3feafa # 1.0.21 13 | env: 14 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 15 | BRANCH_PREFIX: update/ 16 | PULL_REQUEST_BRANCH: master 17 | PULL_REQUEST_REVIEWERS: vsoch 18 | -------------------------------------------------------------------------------- /img/open-source-halloween-2021.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsoch/pull-request-action/77b3eea5c721545c56f775e1ed4ff9c9c1386148/img/open-source-halloween-2021.png -------------------------------------------------------------------------------- /img/outputs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsoch/pull-request-action/77b3eea5c721545c56f775e1ed4ff9c9c1386148/img/outputs.png -------------------------------------------------------------------------------- /pull-request.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import os 5 | import json 6 | import requests 7 | 8 | ################################################################################ 9 | # Helper Functions 10 | ################################################################################ 11 | 12 | 13 | def get_envar(name): 14 | """ 15 | Given a name, return the corresponding environment variable. Exit if not 16 | defined, as using this function indicates the envar is required. 17 | 18 | Parameters: 19 | name (str): the name of the environment variable 20 | """ 21 | value = os.environ.get(name) 22 | if not value: 23 | sys.exit("%s is required for vsoch/pull-request-action" % name) 24 | return value 25 | 26 | 27 | def check_events_json(): 28 | """the github events json is required in order to indicate that we are 29 | in an action environment. 30 | """ 31 | events = get_envar("GITHUB_EVENT_PATH") 32 | if not os.path.exists(events): 33 | sys.exit("Cannot find Github events file at ${GITHUB_EVENT_PATH}") 34 | print("Found ${GITHUB_EVENT_PATH} at %s" % events) 35 | return events 36 | 37 | 38 | def abort_if_fail(response, reason): 39 | """If PASS_ON_ERROR, don't exit. Otherwise exit with an error and print 40 | the reason. 41 | 42 | Parameters: 43 | response (requests.Response) : an unparsed response from requests 44 | reason (str) : a message to print to the user for fail. 45 | """ 46 | message = "%s: %s: %s\n %s" % ( 47 | reason, 48 | response.status_code, 49 | response.reason, 50 | response.json(), 51 | ) 52 | 53 | if os.environ.get("PASS_ON_ERROR"): 54 | print("Error, but PASS_ON_ERROR is set, continuing: %s" % message) 55 | else: 56 | sys.exit(message) 57 | 58 | 59 | def parse_into_list(values): 60 | """A list of reviewers or assignees to parse from a string to a list 61 | 62 | Parameters: 63 | values (str) : a list of space separated, quoted values to parse to a list 64 | """ 65 | if values: 66 | values = values.replace('"', "").replace("'", "") 67 | if not values: 68 | return [] 69 | return [x.strip() for x in values.split(" ")] 70 | 71 | 72 | def set_env_and_output(name, value): 73 | """helper function to echo a key/value pair to the environement file 74 | 75 | Parameters: 76 | name (str) : the name of the environment variable 77 | value (str) : the value to write to file 78 | """ 79 | for env_var in ("GITHUB_ENV", "GITHUB_OUTPUT"): 80 | environment_file_path = os.environ.get(env_var) 81 | if not environment_file_path: 82 | print(f"Warning: {env_var} is unset, skipping.") 83 | continue 84 | print("Writing %s=%s to %s" % (name, value, env_var)) 85 | 86 | with open(environment_file_path, "a") as environment_file: 87 | environment_file.write("%s=%s\n" % (name, value)) 88 | 89 | 90 | def open_pull_request(title, body, target, source, is_draft=False, can_modify=True): 91 | """Open pull request opens a pull request with a given body and content, 92 | and sets output variables. An unparsed response is returned. 93 | 94 | Parameters: 95 | title (str) : the title to set for the new pull request 96 | body (str) : the body to set for the new pull request 97 | target (str) : the target branch 98 | source (str) : the source branch 99 | is_draft (bool) : indicate the pull request is a draft 100 | can_modify (bool) : indicate the maintainer can modify 101 | """ 102 | print("No pull request from %s to %s is open, continuing!" % (source, target)) 103 | 104 | # Post the pull request 105 | data = { 106 | "title": title, 107 | "body": body, 108 | "base": target, 109 | "head": source, 110 | "draft": is_draft, 111 | "maintainer_can_modify": can_modify, 112 | } 113 | print("Data for opening pull request: %s" % data) 114 | response = requests.post(PULLS_URL, json=data, headers=HEADERS) 115 | if response.status_code != 201: 116 | print(f"pull request url is {PULLS_URL}") 117 | abort_if_fail(response, "Unable to create pull request") 118 | 119 | return response 120 | 121 | 122 | def update_pull_request(entry, title, body, target, state=None): 123 | """Given an existing pull request, update it. 124 | 125 | Parameters: 126 | entry (dict) : the pull request metadata 127 | title (str) : the title to set for the new pull request 128 | body (str) : the body to set for the new pull request 129 | target (str) : the target branch 130 | state (bool) : the state of the PR (open, closed) 131 | """ 132 | print("PULL_REQUEST_UPDATE is set, updating existing pull request.") 133 | 134 | data = { 135 | "title": title, 136 | "body": body, 137 | "base": target, 138 | "state": state or "open", 139 | } 140 | # PATCH /repos/{owner}/{repo}/pulls/{pull_number} 141 | url = "%s/%s" % (PULLS_URL, entry.get("number")) 142 | print("Data for updating pull request: %s" % data) 143 | response = requests.patch(url, json=data, headers=HEADERS) 144 | if response.status_code != 200: 145 | abort_if_fail(response, "Unable to create pull request") 146 | 147 | return response 148 | 149 | 150 | def set_pull_request_groups(response): 151 | """Given a response for an open or updated PR, set metadata 152 | 153 | Parameters: 154 | response (requests.Response) : a requests response, unparsed 155 | """ 156 | # Expected return codes are 0 for success 157 | pull_request_return_code = ( 158 | 0 if response.status_code == 201 else response.status_code 159 | ) 160 | response = response.json() 161 | print("::group::github response") 162 | print(response) 163 | print("::endgroup::github response") 164 | number = response.get("number") 165 | html_url = response.get("html_url") 166 | print("Number opened for PR is %s" % number) 167 | set_env_and_output("PULL_REQUEST_NUMBER", number) 168 | set_env_and_output("PULL_REQUEST_RETURN_CODE", pull_request_return_code) 169 | set_env_and_output("PULL_REQUEST_URL", html_url) 170 | 171 | 172 | def list_pull_requests(target, source): 173 | """Given a target and source, return a list of pull requests that match 174 | (or simply exit given some kind of error code) 175 | 176 | Parameters: 177 | target (str) : the target branch 178 | source (str) : the source branch 179 | """ 180 | # Check if the branch already has a pull request open 181 | params = {"base": target, "head": source, "state": "open"} 182 | print("Params for checking if pull request exists: %s" % params) 183 | response = requests.get(PULLS_URL, params=params) 184 | 185 | # Case 1: 401, 404 might warrant needing a token 186 | if response.status_code in [401, 404]: 187 | response = requests.get(PULLS_URL, params=params, headers=HEADERS) 188 | if response.status_code != 200: 189 | abort_if_fail(response, "Unable to retrieve information about pull requests") 190 | 191 | return response.json() 192 | 193 | 194 | def add_assignees(entry, assignees): 195 | """Given a pull request metadata (from create or update) add assignees 196 | 197 | Parameters: 198 | entry (dict) : the pull request metadata 199 | assignees (str) : comma separated assignees string set by action 200 | """ 201 | # Remove leading and trailing quotes 202 | assignees = parse_into_list(assignees) 203 | number = entry.get("number") 204 | 205 | print( 206 | "Attempting to assign %s to pull request with number %s" % (assignees, number) 207 | ) 208 | 209 | # POST /repos/:owner/:repo/issues/:issue_number/assignees 210 | data = {"assignees": assignees} 211 | ASSIGNEES_URL = "%s/%s/assignees" % (ISSUE_URL, number) 212 | response = requests.post(ASSIGNEES_URL, json=data, headers=HEADERS) 213 | if response.status_code != 201: 214 | abort_if_fail(response, "Unable to create assignees") 215 | 216 | assignees_return_code = 0 if response.status_code == 201 else response.status_code 217 | print("::group::github assignees response") 218 | print(response.json()) 219 | print("::endgroup::github assignees response") 220 | set_env_and_output("ASSIGNEES_RETURN_CODE", assignees_return_code) 221 | 222 | 223 | def find_pull_request(listing, source): 224 | """Given a listing and a source, find a pull request based on the source 225 | (the branch name). 226 | 227 | Parameters: 228 | listing (list) : the list of PR objects (dict) to parse over 229 | source (str) : the source (head) branch to look for 230 | """ 231 | if listing: 232 | for entry in listing: 233 | if entry.get("head", {}).get("ref", "") == source: 234 | print("Pull request from %s is already open!" % source) 235 | return entry 236 | 237 | 238 | def find_default_branch(): 239 | """Find default branch for a repo (only called if branch not provided)""" 240 | response = requests.get(REPO_URL) 241 | 242 | # Case 1: 401, 404 might need a token 243 | if response.status_code in [401, 404]: 244 | response = requests.get(REPO_URL, headers=HEADERS) 245 | if response.status_code != 200: 246 | abort_if_fail(response, "Unable to retrieve default branch") 247 | 248 | default_branch = response.json()["default_branch"] 249 | print("Found default branch: %s" % default_branch) 250 | return default_branch 251 | 252 | 253 | def add_reviewers(entry, reviewers, team_reviewers): 254 | """Given regular or team reviewers, add them to a PR. 255 | 256 | Parameters: 257 | entry (dict) : the pull request metadata 258 | """ 259 | print("Found reviewers: %s and team reviewers: %s" % (reviewers, team_reviewers)) 260 | team_reviewers = parse_into_list(team_reviewers) 261 | reviewers = parse_into_list(reviewers) 262 | print("Parsed reviewers: %s and team reviewers: %s" % (reviewers, team_reviewers)) 263 | 264 | # POST /repos/:owner/:repo/pulls/:pull_number/requested_reviewers 265 | REVIEWERS_URL = "%s/%s/requested_reviewers" % (PULLS_URL, entry.get("number")) 266 | 267 | data = {"reviewers": reviewers, "team_reviewers": team_reviewers} 268 | response = requests.post(REVIEWERS_URL, json=data, headers=HEADERS) 269 | if response.status_code != 201: 270 | abort_if_fail(response, "Unable to assign reviewers") 271 | reviewers_return_code = 0 if response.status_code == 201 else response.status_code 272 | 273 | print("::group::github reviewers response") 274 | print(response.json()) 275 | print("::endgroup::github reviewers response") 276 | set_env_and_output("REVIEWERS_RETURN_CODE", reviewers_return_code) 277 | 278 | 279 | ################################################################################ 280 | # Global Variables (we can't use GITHUB_ prefix) 281 | ################################################################################ 282 | 283 | API_VERSION = "v3" 284 | 285 | # Allow for a GitHub enterprise URL 286 | BASE = os.environ.get("GITHUB_API_URL") or "https://api.github.com" 287 | 288 | PR_TOKEN = os.environ.get("PULL_REQUEST_TOKEN") or get_envar("GITHUB_TOKEN") 289 | PR_REPO = os.environ.get("PULL_REQUEST_REPOSITORY") or get_envar("GITHUB_REPOSITORY") 290 | 291 | HEADERS = { 292 | "Authorization": "token %s" % PR_TOKEN, 293 | "Accept": "application/vnd.github.%s+json;application/vnd.github.antiope-preview+json;application/vnd.github.shadow-cat-preview+json" 294 | % API_VERSION, 295 | } 296 | 297 | # URLs 298 | REPO_URL = "%s/repos/%s" % (BASE, PR_REPO) 299 | ISSUE_URL = "%s/issues" % REPO_URL 300 | PULLS_URL = "%s/pulls" % REPO_URL 301 | 302 | 303 | def create_pull_request( 304 | source, 305 | target, 306 | body, 307 | title, 308 | assignees, 309 | reviewers, 310 | team_reviewers, 311 | is_draft=False, 312 | can_modify=True, 313 | state="open", 314 | ): 315 | """Create pull request is the base function that determines if the PR exists, 316 | and then updates or creates it depending on user preferences. 317 | """ 318 | listing = list_pull_requests(target, source) 319 | 320 | # Determine if the pull request is already open 321 | entry = find_pull_request(listing, source) 322 | response = None 323 | 324 | # Case 1: we found the PR, the user wants to pass 325 | if entry and os.environ.get("PASS_IF_EXISTS"): 326 | print("PASS_IF_EXISTS is set, exiting with success status.") 327 | sys.exit(0) 328 | 329 | # Does the user want to update the existing PR? 330 | if entry and os.environ.get("PULL_REQUEST_UPDATE"): 331 | response = update_pull_request(entry, title, body, target, state) 332 | set_pull_request_groups(response) 333 | 334 | # If it's not open, we open a new pull request 335 | elif not entry: 336 | response = open_pull_request(title, body, target, source, is_draft, can_modify) 337 | set_pull_request_groups(response) 338 | 339 | # If we have a response, parse into json (no longer need retvals) 340 | response = response.json() if response else None 341 | 342 | # If we have opened or updated, we can add assignees 343 | if response and assignees: 344 | add_assignees(response, assignees) 345 | if response and (reviewers or team_reviewers): 346 | add_reviewers(response, reviewers, team_reviewers) 347 | 348 | 349 | def main(): 350 | """main primarily parses environment variables to prepare for creation""" 351 | 352 | # path to file that contains the POST response of the event 353 | # Example: https://github.com/actions/bin/tree/master/debug 354 | # Value: /github/workflow/event.json 355 | check_events_json() 356 | 357 | branch_prefix = os.environ.get("BRANCH_PREFIX", "") 358 | print("Branch prefix is %s" % branch_prefix) 359 | if not branch_prefix: 360 | print("No branch prefix is set, all branches will be used.") 361 | 362 | # Default to project default branch if none provided 363 | pull_request_branch = os.environ.get("PULL_REQUEST_BRANCH") 364 | if not pull_request_branch: 365 | pull_request_branch = find_default_branch() 366 | 367 | print("Pull requests will go to %s" % pull_request_branch) 368 | 369 | # Pull request draft 370 | pull_request_draft = os.environ.get("PULL_REQUEST_DRAFT") 371 | if not pull_request_draft: 372 | print("No explicit preference for draft PR: created PRs will be normal PRs.") 373 | pull_request_draft = False 374 | else: 375 | print("PULL_REQUEST_DRAFT set to a value: created PRs will be draft PRs.") 376 | pull_request_draft = True 377 | 378 | # If an update is true, we can change the state 379 | pull_request_state = os.environ.get("PULL_REQUEST_STATE", "open") 380 | if pull_request_state not in ["open", "closed"]: 381 | sys.exit("State is required to be one of 'open' or 'closed'") 382 | 383 | # Maintainer can modify, defaults to CAN, unless user sets MAINTAINER_CANT_MODIFY 384 | maintainer_can_modify = os.environ.get("MAINTAINER_CANT_MODIFY") 385 | if not maintainer_can_modify: 386 | print("No preference for maintainer being able to modify: default is true.") 387 | maintainer_can_modify = True 388 | else: 389 | print( 390 | "MAINTAINER_CANT_MODIFY set to a value: maintainer will not be able to modify." 391 | ) 392 | maintainer_can_modify = False 393 | 394 | # Assignees 395 | assignees = os.environ.get("PULL_REQUEST_ASSIGNEES") 396 | if not assignees: 397 | print("PULL_REQUEST_ASSIGNEES is not set, no assignees.") 398 | else: 399 | print("PULL_REQUEST_ASSIGNEES is set, %s" % assignees) 400 | 401 | # Reviewers (individual and team) 402 | 403 | reviewers = os.environ.get("PULL_REQUEST_REVIEWERS") 404 | team_reviewers = os.environ.get("PULL_REQUEST_TEAM_REVIEWERS") 405 | if not reviewers: 406 | print("PULL_REQUEST_REVIEWERS is not set, no reviewers.") 407 | else: 408 | print("PULL_REQUEST_REVIEWERS is set, %s" % reviewers) 409 | 410 | if not team_reviewers: 411 | print("PULL_REQUEST_TEAM_REVIEWERS is not set, no team reviewers.") 412 | else: 413 | print("PULL_REQUEST_TEAM_REVIEWERS is set, %s" % team_reviewers) 414 | 415 | # The user is allowed to explicitly set the name of the branch 416 | from_branch = os.environ.get("PULL_REQUEST_FROM_BRANCH") 417 | if not from_branch: 418 | print("PULL_REQUEST_FROM_BRANCH is not set, checking branch in payload.") 419 | with open(check_events_json(), "r") as fd: 420 | from_branch = json.loads(fd.read()).get("ref", "") 421 | from_branch = from_branch.replace("refs/heads/", "").strip("/") 422 | else: 423 | print("PULL_REQUEST_FROM_BRANCH is set.") 424 | 425 | # At this point, we must have a branch 426 | if from_branch: 427 | print("Found branch %s to open PR from" % from_branch) 428 | else: 429 | sys.exit( 430 | "You are required to define PULL_REQUEST_FROM_BRANCH in the environment." 431 | ) 432 | 433 | # If it's to the target branch, ignore it 434 | if from_branch == pull_request_branch: 435 | print("Target and current branch are identical (%s), skipping." % from_branch) 436 | sys.exit(0) 437 | 438 | # If the prefix for the branch matches 439 | if not branch_prefix or from_branch.startswith(branch_prefix): 440 | 441 | # Pull request body (optional) 442 | pull_request_body = os.environ.get( 443 | "PULL_REQUEST_BODY", 444 | "This is an automated pull request to update from branch %s" % from_branch, 445 | ) 446 | 447 | print("::group::pull request body") 448 | print(pull_request_body) 449 | print("::endgroup::pull request body") 450 | 451 | # Pull request title (optional) 452 | pull_request_title = os.environ.get( 453 | "PULL_REQUEST_TITLE", "Update from %s" % from_branch 454 | ) 455 | print("::group::pull request title") 456 | print(pull_request_title) 457 | print("::endgroup::pull request title") 458 | 459 | # Create the pull request 460 | create_pull_request( 461 | target=pull_request_branch, 462 | source=from_branch, 463 | body=pull_request_body, 464 | title=pull_request_title, 465 | is_draft=pull_request_draft, 466 | can_modify=maintainer_can_modify, 467 | assignees=assignees, 468 | reviewers=reviewers, 469 | team_reviewers=team_reviewers, 470 | state=pull_request_state, 471 | ) 472 | 473 | 474 | if __name__ == "__main__": 475 | print("==========================================================================") 476 | print("START: Running Pull Request on Branch Update Action!") 477 | main() 478 | print("==========================================================================") 479 | print("END: Finished") 480 | --------------------------------------------------------------------------------