diff --git a/.github/workflows/approve-contributor.yml b/.github/workflows/approve-contributor.yml new file mode 100644 index 000000000..4d67c423c --- /dev/null +++ b/.github/workflows/approve-contributor.yml @@ -0,0 +1,103 @@ +name: Approve Contributor + +# Approves contributors to open pull requests (see `pr-gate.yml`). +# +# A user with write access approves the author of an issue or pull request by commenting on it with +# a line that only contains `lgtm`. To approve other users instead, list them after it: +# `lgtm @alice @bob`. +# +# Approved users are appended to `APPROVED_FILE` on `APPROVED_BRANCH`, which is kept off the default +# branch so that approvals don't need a pull request. + +on: + issue_comment: # zizmor: ignore[dangerous-triggers] comment body is only parsed in JS, never run + types: [created] + +permissions: {} + +env: + APPROVED_BRANCH: approved-contributors + APPROVED_FILE: APPROVED_CONTRIBUTORS + +jobs: + approve: + if: contains(github.event.comment.body, 'lgtm') + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + steps: + - name: Add users to approved contributors + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { owner, repo } = context.repo; + const { comment, issue } = context.payload; + const branch = process.env.APPROVED_BRANCH; + const path = process.env.APPROVED_FILE; + + const match = comment.body.match(/^[ \t]*lgtm((?:[ \t]+@[A-Za-z0-9-]+)*)[ \t\r]*$/im); + if (!match) { + core.info('No `lgtm` line found'); + return; + } + + const commenter = comment.user.login; + const { data: permission } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username: commenter, + }); + if (!['admin', 'maintain', 'write'].includes(permission.permission)) { + core.info(`${commenter} does not have write access`); + return; + } + + const mentions = match[1].trim().split(/\s+/).filter(Boolean).map((mention) => mention.slice(1)); + const targets = mentions.length > 0 ? mentions : [issue.user.login]; + + let sha; + let content = ''; + try { + const { data } = await github.rest.repos.getContent({ owner, repo, path, ref: branch }); + sha = data.sha; + content = Buffer.from(data.content, 'base64').toString('utf8'); + } catch (error) { + if (error.status !== 404) throw error; + } + + const approved = new Set( + content + .split('\n') + .map((line) => line.trim().toLowerCase()) + .filter((line) => line && !line.startsWith('#')), + ); + const added = []; + for (const user of targets) { + if (approved.has(user.toLowerCase())) continue; + approved.add(user.toLowerCase()); + added.push(user); + } + const alreadyApproved = targets.filter((user) => !added.includes(user)); + + if (added.length > 0) { + if (content && !content.endsWith('\n')) content += '\n'; + content += added.map((user) => `${user}\n`).join(''); + await github.rest.repos.createOrUpdateFileContents({ + owner, + repo, + path, + branch, + sha, + message: `chore: approve ${added.join(', ')} from #${issue.number}`, + content: Buffer.from(content).toString('base64'), + }); + core.info(`Approved ${added.join(', ')}`); + } + + const format = (users) => users.map((user) => `@${user}`).join(', '); + const lines = []; + if (added.length > 0) lines.push(`${format(added)} can now open pull requests.`); + if (alreadyApproved.length > 0) lines.push(`Already approved: ${format(alreadyApproved)}.`); + await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body: lines.join('\n') }); diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml new file mode 100644 index 000000000..870d687d4 --- /dev/null +++ b/.github/workflows/pr-gate.yml @@ -0,0 +1,91 @@ +name: PR Gate + +# Closes pull requests from contributors who are not approved. +# +# A PR is allowed if its head branch lives in this repository (bots and maintainers), if the user who +# opened or reopened it has write access, or if its author is listed in `APPROVED_FILE` on +# `APPROVED_BRANCH`. Contributors are approved with the `approve-contributor.yml` workflow. +# +# This uses `pull_request_target` to get a token that can close PRs from forks. It only reads PR +# metadata and never checks out or runs code from the PR. + +on: + pull_request_target: # zizmor: ignore[dangerous-triggers] only reads PR metadata, never PR code + types: [opened, reopened] + +permissions: {} + +env: + APPROVED_BRANCH: approved-contributors + APPROVED_FILE: APPROVED_CONTRIBUTORS + +jobs: + check-author: + if: github.event.pull_request.head.repo.full_name != github.repository + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Close PR if author is not approved + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { owner, repo } = context.repo; + const pr = context.payload.pull_request; + const author = pr.user.login; + const sender = context.payload.sender.login; + const branch = process.env.APPROVED_BRANCH; + const path = process.env.APPROVED_FILE; + + async function hasWriteAccess(username) { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username }); + return ['admin', 'maintain', 'write'].includes(data.permission); + } + + async function readApprovedContributors() { + let data; + try { + ({ data } = await github.rest.repos.getContent({ owner, repo, path, ref: branch })); + } catch (error) { + if (error.status === 404) { + throw new Error(`${path} not found on branch ${branch}`); + } + throw error; + } + return new Set( + Buffer.from(data.content, 'base64') + .toString('utf8') + .split('\n') + .map((line) => line.trim().toLowerCase()) + .filter((line) => line && !line.startsWith('#')), + ); + } + + // On `opened` the sender is the author. On `reopened` it may be a maintainer reopening + // the PR, which counts as approval for this PR. + if (await hasWriteAccess(sender)) { + core.info(`${sender} has write access`); + return; + } + + if ((await readApprovedContributors()).has(author.toLowerCase())) { + core.info(`${author} is approved`); + return; + } + + core.info(`${author} is not approved, closing PR`); + const contributing = `https://github.com/${owner}/${repo}/blob/${context.payload.repository.default_branch}/CONTRIBUTING.md#getting-approved-to-open-pull-requests`; + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: [ + 'Thanks for your interest in contributing! This PR was closed automatically because only approved contributors can open pull requests.', + '', + 'To get approved, open an issue describing the change you would like to make. If a maintainer replies `lgtm`, you can open a new pull request and your future pull requests will stay open.', + '', + `See [CONTRIBUTING.md](${contributing}).`, + ].join('\n'), + }); + await github.rest.pulls.update({ owner, repo, pull_number: pr.number, state: 'closed' }); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 85d157d43..5693691c6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,6 +34,9 @@ To contribute a patch, the workflow is as follows: 2. Create topic branch 3. Commit patches +If you are not yet approved to open pull requests, see +[Getting Approved to Open Pull Requests](#getting-approved-to-open-pull-requests) first. + In general commits should be atomic and diffs should be easy to read. For this reason do not mix any formatting fixes or code moves with actual code changes. Further, each commit, individually, should compile and pass tests, in @@ -57,6 +60,27 @@ comment suggesting that you're working on it. If someone is already assigned, don't hesitate to ask if the assigned party or previous commenter are still working on it if it has been a while. +Getting Approved to Open Pull Requests +-------------------------------------- + +To keep the review queue manageable, pull requests from contributors who are not yet approved are +closed automatically. To get approved: + + 1. Open an issue describing the bug you want to fix or the change you want to make, or comment on + an existing issue you want to work on. + 2. If a maintainer agrees the change is wanted, they reply `lgtm`. This approves you to open + pull requests. Wait for the bot to confirm the approval on the issue. + 3. Open your pull request. If your earlier pull request was closed automatically, open a new one. + Your future pull requests will stay open as well. + +Approved contributors are listed in +[`APPROVED_CONTRIBUTORS`](https://github.com/bitcoindevkit/bdk/blob/approved-contributors/APPROVED_CONTRIBUTORS) +on the `approved-contributors` branch. + +Maintainers approve the author of an issue or pull request by commenting on it with a line +containing only `lgtm`, or approve other users with `lgtm @user1 @user2`. To accept a single pull +request that was closed automatically without approving its author, reopen it. + Deprecation policy ------------------