ci: don't flag private org members as external contributors#2546
Conversation
author_association only reports MEMBER for *public* org members, so a teammate with private (concealed) membership was classified as external, alerted to Slack, and labeled. Resolve actual repo access via the collaborator-permission endpoint as a fallback. Threshold on granted access (triage and up) rather than permission != 'none', since public repos grant everyone implicit read. Uses the built-in GITHUB_TOKEN; no new secret or permission required. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
|
The latest updates on your projects. Learn more about Vercel for GitHub. 2 Skipped Deployments
|
🟢 Tier 1 — TrivialDocs, images, lock files, or a dependency bump. No functional code changes detected. Why this tier:
Review process: Auto-merge once CI passes. No human review required. Stats
|
| const p = data.user?.permissions ?? {}; | ||
| internal = !!(p.admin || p.maintain || p.push || p.triage); |
There was a problem hiding this comment.
The permission check relies on
data.user?.permissions, but the GitHub API documents data.user as nullable and user.permissions.triage / user.permissions.maintain as optional fields. If data.user is null, or if those individual fields are absent, p falls back to {} and all boolean tests return false — meaning a private org member with a triage or maintain role would still be classified as external, silently defeating the whole fix. The top-level data.permission string (e.g. "triage", "write", "maintain", "admin") is marked required by the GitHub schema and is far more reliable for this check. Note: the API returns "write" (not "push") for the top-level string, so the allowed-set should use "write" instead of "push".
| const p = data.user?.permissions ?? {}; | |
| internal = !!(p.admin || p.maintain || p.push || p.triage); | |
| const TRIAGE_PLUS = ['triage', 'write', 'maintain', 'admin']; | |
| internal = TRIAGE_PLUS.includes(data.permission); |
| const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ | ||
| owner, repo, username: item.user.login, | ||
| }); |
There was a problem hiding this comment.
Every other reference to
item.user in this file uses optional chaining (item.user?.login, item.user?.type), but this call site accesses item.user.login without it. If item.user is somehow null (e.g. a deleted account triggering an event), this throws a TypeError inside the try/catch; since a TypeError has no .status, e.status !== 404 is true and the error is re-thrown, failing the workflow run rather than quietly passing. Using item.user?.login is consistent with the rest of the file and avoids the hard throw.
| const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ | |
| owner, repo, username: item.user.login, | |
| }); | |
| const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ | |
| owner, repo, username: item.user?.login, | |
| }); |
E2E Test Results✅ All tests passed • 222 passed • 3 skipped • 1502s
Tests ran across 4 shards in parallel. |
Teammates with private (concealed) organization membership are no longer misflagged as external contributors. They previously got a Slack alert and an
externallabel on every issue/PR they opened, becauseauthor_associationonly reportsMEMBERfor publicly visible members — a private member shows up asCONTRIBUTOR/NONE, which the workflow treated as external. This is what happened on #2545 (authorknudttyis a private org member).The internal check now keeps
author_associationas a free fast path, then falls back to resolving the author's actual repository permission viarepos.getCollaboratorPermissionLevel, which sees access granted through private teams.Two design points worth noting:
permission !== 'none'. This is a public repo, so GitHub grants every user implicitread. A naive!= 'none'check would mark every genuine external contributor as internal. The check requirestriage/push/maintain/admininstead.GITHUB_TOKENcan call the endpoint under the existingissues:write/pull-requests:writescope.Both behaviors were verified in a throwaway Actions run (in a sibling repo with the identical workflow) before writing this: a private member with
writeaccess resolved to INTERNAL with no403, and a non-collaborator (implicitread) correctly resolved to EXTERNAL.