feat(app): unify alert actions and make the alert source legible #207
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Vouch | |
| # Trust gate for outside contributions, built on https://github.com/mitchellh/vouch. | |
| # | |
| # Anyone with write access (org members, collaborators) and bots are always | |
| # allowed. For everyone else this does two things: | |
| # | |
| # 1. Volume. Outside contributors get one open PR at a time, three once they | |
| # are on the vouch list. Anything over that is closed, asking them to | |
| # finish what they have open first. Drafts don't count. | |
| # 2. Authenticity. A PR from someone not in .github/VOUCHED.td stays open but | |
| # gets a `needs-vouch` label and a comment asking them to introduce | |
| # themselves. | |
| # | |
| # Authors explicitly denounced in the list are always closed. Issues are NOT | |
| # gated — opening one is how a new contributor introduces themselves. | |
| # | |
| # Maintainers manage the list from any issue or PR comment. The keyword has to | |
| # be first on the first line: | |
| # /vouch @user [reason] /denounce @user [reason] /unvouch @user | |
| # | |
| # The list edit arrives as a pull request because `main` is protected and | |
| # GITHUB_TOKEN cannot bypass that. Note the bot's PR does not trigger `Main` — | |
| # GitHub does not start workflow runs for events raised by GITHUB_TOKEN — so it | |
| # needs a maintainer approval rather than a green CI run. This is the same path | |
| # the changesets release PR already takes. | |
| on: | |
| pull_request_target: | |
| types: [opened, reopened, ready_for_review] | |
| issue_comment: | |
| types: [created] | |
| permissions: {} | |
| jobs: | |
| check-pr: | |
| # Drafts are skipped and re-checked on ready_for_review, matching | |
| # external-contributor-alerts.yml so the two agree on what counts. | |
| if: >- | |
| github.event_name == 'pull_request_target' && | |
| !github.event.pull_request.draft | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| issues: write | |
| pull-requests: write | |
| steps: | |
| # require-vouch: false means only denounced authors are blocked. Everyone | |
| # unvouched comes back as `allowed`, which the next step picks up. No | |
| # checkout needed — the action reads VOUCHED.td from the default branch | |
| # over the API, and the denounce message is upstream's, not a file here. | |
| - id: vouch | |
| uses: mitchellh/vouch/action/check-pr@d66fa29a64600490892131ad87597c30c91fcac4 # v1.5.0 | |
| with: | |
| pr-number: ${{ github.event.pull_request.number }} | |
| require-vouch: false | |
| auto-close: true | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| # One step covers both checks so an author who is unvouched *and* over | |
| # the PR limit gets a single comment rather than two. | |
| - uses: actions/github-script@v9 | |
| env: | |
| VOUCH_STATUS: ${{ steps.vouch.outputs.status }} | |
| with: | |
| script: | | |
| const LABEL = 'needs-vouch'; | |
| const { owner, repo } = context.repo; | |
| const pr = context.payload.pull_request; | |
| const status = process.env.VOUCH_STATUS; | |
| // skipped = bot. closed = denounced, already handled upstream. | |
| if (status === 'skipped' || status === 'closed') return; | |
| // `vouched` covers both collaborators and listed outsiders, so it | |
| // cannot tell us who is internal. Same check as | |
| // external-contributor-alerts.yml, including the slow path that | |
| // catches org members with concealed membership. | |
| let internal = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(pr.author_association); | |
| if (!internal) { | |
| try { | |
| const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ | |
| owner, repo, username: pr.user.login, | |
| }); | |
| const p = data.user?.permissions ?? {}; | |
| internal = !!(p.admin || p.maintain || p.push || p.triage); | |
| } catch (e) { | |
| if (e.status !== 404) throw e; // 404 = not a collaborator => external | |
| } | |
| } | |
| if (internal) { core.info(`#${pr.number} is internal; nothing to do.`); return; } | |
| // Volume cap, per Jaeger's model: contributing more earns more | |
| // concurrent PRs. Being on the vouch list is the reputation signal. | |
| const allowance = status === 'vouched' ? 3 : 1; | |
| const open = await github.paginate(github.rest.pulls.list, { | |
| owner, repo, state: 'open', per_page: 100, | |
| }); | |
| // Drafts do not count — they are not asking for review yet, which | |
| // is the queue we are actually protecting. | |
| const theirs = open.filter(p => | |
| p.user?.login === pr.user.login && p.number !== pr.number && !p.draft); | |
| if (theirs.length >= allowance) { | |
| const list = theirs.map(p => `#${p.number}`).join(', '); | |
| await github.rest.issues.createComment({ | |
| owner, repo, issue_number: pr.number, | |
| body: [ | |
| `Hi @${pr.user.login}, thanks for this.`, | |
| ``, | |
| `We limit outside contributors to ${allowance} open pull request${allowance === 1 ? '' : 's'} at a time, and you already have ${list} open. Reviewing several changes from one author at once is more than we can give proper attention to, and it tends to mean none of them land.`, | |
| ``, | |
| `I have closed this one to keep the queue honest — **your branch and your work are untouched**. Once ${theirs.length === 1 ? 'that PR' : 'one of those'} is merged or closed, reopen this and it will go through.`, | |
| ].join('\n'), | |
| }); | |
| await github.rest.pulls.update({ owner, repo, pull_number: pr.number, state: 'closed' }); | |
| core.info(`Closed #${pr.number}: ${theirs.length} already open, allowance ${allowance}.`); | |
| return; | |
| } | |
| if (status !== 'allowed') return; // vouched and within the limit | |
| // The label doubles as the "already asked" marker so a reopen or a | |
| // ready_for_review doesn't post the same comment a second time. | |
| if (pr.labels?.some(l => l.name === LABEL)) { | |
| core.info(`#${pr.number} already labelled; not commenting again.`); | |
| return; | |
| } | |
| try { | |
| await github.rest.issues.createLabel({ | |
| owner, repo, name: LABEL, color: 'FBCA04', | |
| description: 'Author needs a maintainer to vouch for them', | |
| }); | |
| } catch (e) { | |
| if (e.status !== 422) throw e; // 422 = already exists | |
| } | |
| await github.rest.issues.addLabels({ | |
| owner, repo, issue_number: pr.number, labels: [LABEL], | |
| }); | |
| const body = [ | |
| `Hi @${pr.user.login}, thanks for the pull request!`, | |
| ``, | |
| `Before we review code from a first-time contributor we ask that a maintainer vouches for you, and you're not on our list yet. **This PR stays open** — it just isn't in the review queue until someone vouches.`, | |
| ``, | |
| `To get vouched, open an issue saying hello and what you're working on:`, | |
| ``, | |
| `https://github.com/${owner}/${repo}/issues/new?template=introduce-yourself.md`, | |
| ``, | |
| `A maintainer will usually reply within a day or two, and then this PR gets picked up as normal. More detail in [our contributing guide](https://github.com/${owner}/${repo}/blob/${pr.base.repo.default_branch}/CONTRIBUTING.md#getting-vouched).`, | |
| ].join('\n'); | |
| await github.rest.issues.createComment({ | |
| owner, repo, issue_number: pr.number, body, | |
| }); | |
| core.info(`Labelled and commented on #${pr.number}.`); | |
| manage: | |
| # Role-gated before the runner starts. The action re-checks the commenter's | |
| # permission authoritatively; this only stops anyone on the internet from | |
| # spinning up a write-token runner by typing /vouch. `contains` rather than | |
| # `startsWith` so a leading space doesn't silently drop a real command. | |
| if: >- | |
| github.event_name == 'issue_comment' && | |
| contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), | |
| github.event.comment.author_association) && | |
| (contains(github.event.comment.body, '/vouch') || | |
| contains(github.event.comment.body, '/unvouch') || | |
| contains(github.event.comment.body, '/denounce')) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| # Keyed per comment. A shared group would let a newly queued run cancel a | |
| # pending one, silently dropping a vouch; each run cuts its own branch, so | |
| # there is nothing to serialise. | |
| concurrency: | |
| group: vouch-manage-${{ github.event.comment.id }} | |
| cancel-in-progress: false | |
| permissions: | |
| contents: write | |
| issues: write | |
| pull-requests: write | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: mitchellh/vouch/action/manage-by-issue@d66fa29a64600490892131ad87597c30c91fcac4 # v1.5.0 | |
| with: | |
| issue-id: ${{ github.event.issue.number }} | |
| comment-id: ${{ github.event.comment.id }} | |
| vouch-keyword: /vouch | |
| denounce-keyword: /denounce | |
| unvouch-keyword: /unvouch | |
| pull-request: true | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| # The action is silent on every failure path — no comment, no reaction — | |
| # and issue_comment runs are not surfaced on the issue timeline, so a | |
| # maintainer would otherwise assume a failed /vouch worked. | |
| - if: failure() | |
| uses: actions/github-script@v9 | |
| with: | |
| script: | | |
| await github.rest.issues.createComment({ | |
| ...context.repo, | |
| issue_number: context.payload.issue.number, | |
| body: `The vouch command failed. [See the run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}) — the list was not changed.`, | |
| }); |