ci: label external-contributor PRs (#9641)

## Description
Adds a new GitHub Actions workflow, `Label External Contributors`, that
runs whenever a pull request is opened and applies the existing
`external-contributor` label when:

* the PR is **not** authored by a bot (`user.type == 'Bot'` or login
ending in `[bot]`), **and**
* either of the following is true (logical OR):
* the author is **not** a member of the `warpdotdev` GitHub
organization, **or**
* the PR head repository is a fork (`pr.head.repo.full_name !=
pr.base.repo.full_name`).

The workflow triggers on `pull_request_target` so the `GITHUB_TOKEN` has
the `pull-requests: write` permission needed to label PRs that come from
forks. The top-level workflow token stays read-only and only the
labeling job widens permissions. The script never checks out the PR's
code — it only reads the event payload and calls a couple of REST
endpoints — so the `pull_request_target` trigger is safe.

Org membership is determined via the GitHub REST membership API. If that
call cannot be performed (for example, when `GITHUB_TOKEN` lacks
org-membership context for a private member), we fall back to the PR's
`author_association` field (`MEMBER` or `OWNER` is treated as internal).

The same heuristic was applied retroactively to all currently-open PRs
in the repo. The retrospective pass evaluated 148 open PRs, skipped 20
bot-authored PRs, labeled 89 external-contributor PRs (all of which were
forks from non-org members), and left 39 internal-author PRs untouched.

## Linked Issue
N/A — direct request from the team Slack channel.
- [x] Where appropriate, screenshots or a short video of the
implementation are included below (especially for user-visible or UI
changes).

## Screenshots / Videos
Not applicable: this PR only adds a CI workflow.

## Testing
* Validated the workflow YAML parses cleanly.
* Exercised the heuristic locally across all 148 currently-open PRs (89
expected to be labeled external, 20 bots skipped, 39 internal — all
matched expectations).
* Verified after retrospectively applying labels that `gh pr list
--state open --label external-contributor` now returns 89 PRs, matching
the dry-run prediction.

## Agent Mode
- [x] Warp Agent Mode - This PR was created via Warp's AI Agent Mode

_Conversation:
https://staging.warp.dev/conversation/cf25ce7e-4d2d-4880-a3a3-8c4242f7c0d5_

_Run:
https://oz.staging.warp.dev/runs/019ddf8f-1365-7da4-9c47-75aee57c151c_

_This PR was generated with [Oz](https://warp.dev/oz)._

---------

Co-authored-by: Oz <oz-agent@warp.dev>
This commit is contained in:
Safia Abdalla
2026-04-30 21:14:35 -05:00
committed by GitHub
parent 10ec3d1339
commit 0fca61d799

View File

@@ -0,0 +1,96 @@
# ======================================================================================
# Workflow: Label External Contributors
# ======================================================================================
# Usage:
# - Runs whenever a pull request is opened.
# - Adds the `external-contributor` label to the PR if either of the following is
# true (logical OR), and the PR is not authored by a bot:
# 1. The PR author is not a member of the `warpdotdev` GitHub organization.
# 2. The PR head repository is a fork (i.e. it does not belong to the same
# repository as the base).
#
# Notes:
# - The workflow triggers on `pull_request_target` rather than `pull_request` so
# that it has the `pull-requests: write` permission needed to apply labels even
# when the PR is opened from a fork. Because we never check out the PR's code
# and only read the event payload, this trigger is safe.
# - Org membership is determined via the GitHub REST API. We fall back to the
# PR's `author_association` field when the API call cannot be performed (for
# example, when the GITHUB_TOKEN lacks org-membership context for private
# members).
# ======================================================================================
name: Label External Contributors
on:
pull_request_target:
types: [opened]
# Default to a read-only token. The job below widens permissions explicitly.
permissions:
contents: read
jobs:
label-external-contributor:
name: Label external-contributor PRs
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Determine and apply external-contributor label
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const pr = context.payload.pull_request;
const author = pr.user.login;
// Ignore PRs authored by bots.
if (pr.user.type === 'Bot' || author.endsWith('[bot]')) {
console.log(`Skipping bot user: ${author}`);
return;
}
// The PR comes from a fork if its head repo differs from its base repo.
const isFork =
!pr.head.repo ||
pr.head.repo.full_name !== pr.base.repo.full_name;
// Check whether the author is a member of the warpdotdev org. The
// membership API requires the requester to be a member of the org.
// When it cannot return a definitive result, fall back to the PR's
// author_association field, which GitHub computes based on the
// author's relationship to the repository.
let isOrgMember =
pr.author_association === 'MEMBER' ||
pr.author_association === 'OWNER';
try {
await github.rest.orgs.checkMembershipForUser({
org: 'warpdotdev',
username: author,
});
isOrgMember = true;
} catch (error) {
console.log(
`checkMembershipForUser failed (${error.status}); ` +
`falling back to author_association="${pr.author_association}"`,
);
}
const isExternal = !isOrgMember || isFork;
console.log(
`PR #${pr.number} by ${author}: ` +
`isFork=${isFork}, isOrgMember=${isOrgMember}, ` +
`isExternal=${isExternal}`,
);
if (!isExternal) {
return;
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: ['external-contributor'],
});
console.log(`Labeled PR #${pr.number} as external-contributor`);