Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions .github/workflows/approve-contributor.yml
Original file line number Diff line number Diff line change
@@ -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') });
91 changes: 91 additions & 0 deletions .github/workflows/pr-gate.yml
Original file line number Diff line number Diff line change
@@ -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' });
24 changes: 24 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
------------------

Expand Down
Loading