actions/slash-commands/main.js
Den Delimarsky 63db292169
slash-commands: remove commit-status gate, document GitHub App flow
The merge gate is now solely the App's APPROVE review satisfying branch
protection's required-reviews rule, plus auto-merge. The commit-status
mechanism is removed entirely:

- delete slash-commands/status sub-action
- drop status-context input and setCommitStatus() from main.js
- drop the now-unused hadApproved/hadHold label snapshot

README rewritten to document the GitHub App setup (MCP_COMMANDER_APP_ID
+ MCP_COMMANDER_APP_KEY secrets, actions/create-github-app-token to mint
the installation token) as the primary flow. The App token is passed as
both github-token (team checks) and bot-token (approval/auto-merge) so
approvals come from the App identity and always count toward required
reviews, independent of the "Allow GitHub Actions to approve" repo
setting.
2026-03-26 23:21:16 +00:00

409 lines
16 KiB
JavaScript

// Handler for /lgtm, /hold, and /stageblog slash commands. Called by actions/github-script
// via `require(github.action_path + '/main.js')({ github, context, core })`.
// See action.yml for input wiring via env vars.
module.exports = async ({ github, context, core }) => {
const { owner, repo } = context.repo;
const approvedLabel = process.env.APPROVED_LABEL;
const holdLabel = process.env.HOLD_LABEL;
const alwaysAllowTeams = process.env.ALWAYS_ALLOW_TEAMS
.split(',').map(s => s.trim()).filter(Boolean);
const forceAllowTeams = process.env.FORCE_ALLOW_TEAMS
.split(',').map(s => s.trim()).filter(Boolean);
const codeownersPath = process.env.CODEOWNERS_PATH;
const invalidateOnPush = process.env.INVALIDATE_ON_PUSH === 'true';
const submitReview = process.env.SUBMIT_REVIEW === 'true';
const autoMerge = process.env.ENABLE_AUTO_MERGE === 'true';
const autoMergeMethod = process.env.AUTO_MERGE_METHOD.toUpperCase();
const reviewMarker = '<!-- slash-commands-lgtm -->';
// `github` is authed with bot-token (defaults to GITHUB_TOKEN) so comments,
// reactions, labels, and reviews show as github-actions[bot]. The PAT is
// passed via env and used ONLY for the raw-fetch team-membership check
// below — we cannot construct a second Octokit client here because
// @actions/github is bundled into github-script's dist/index.js and not
// resolvable, nor does a per-request `headers.authorization` override work
// (Octokit's auth hook overwrites it).
const orgToken = process.env.ORG_TOKEN;
function setResult(result, actor) {
core.setOutput('result', result);
core.setOutput('actor', actor || '');
core.info(`Result: ${result}${actor ? ` (actor: ${actor})` : ''}`);
}
// --- CODEOWNERS pattern → regex ---------------------------------
// Supports: leading-/ anchoring, trailing-/ directory match,
// `**` (any depth), `*` (single segment), `?`. No `!` negation.
function patternToRegex(pattern) {
let p = pattern;
const anchored = p.startsWith('/');
if (anchored) p = p.slice(1);
const dirMatch = p.endsWith('/');
if (dirMatch) p = p.slice(0, -1);
// Escape regex meta chars except our glob tokens * and ?
let re = p.replace(/[.+^${}()|[\]\\]/g, '\\$&');
// Handle globs. Use placeholders so single-* replacement doesn't
// catch ** sequences. `**/` = zero or more directories.
re = re.replace(/\*\*\//g, '\u0001');
re = re.replace(/\*\*/g, '\u0000');
re = re.replace(/\*/g, '[^/]*');
re = re.replace(/\?/g, '[^/]');
re = re.replace(/\u0001/g, '(?:.*/)?');
re = re.replace(/\u0000/g, '.*');
// Anchoring: leading `/` → match from repo root; otherwise
// match at any path depth (start or after a `/`).
const prefix = anchored ? '^' : '(^|/)';
// Trailing `/` → directory prefix (match anything under it).
// Otherwise must match to end of string.
const suffix = dirMatch ? '/.*$' : '$';
return new RegExp(prefix + re + suffix);
}
function parseCodeowners(content) {
const rules = [];
for (const raw of content.split('\n')) {
const line = raw.replace(/#.*$/, '').trim();
if (!line) continue;
const parts = line.split(/\s+/);
const pattern = parts[0];
const owners = parts.slice(1);
if (!owners.length) continue;
rules.push({ regex: patternToRegex(pattern), owners });
}
return rules;
}
function ownersForFile(rules, file) {
// Last match wins.
let owners = null;
for (const r of rules) {
if (r.regex.test(file)) owners = r.owners;
}
return owners || [];
}
// --- Auth helpers -----------------------------------------------
const teamMembershipCache = new Map();
async function isTeamMember(user, teamSlug) {
const key = `${teamSlug}:${user}`;
if (teamMembershipCache.has(key)) return teamMembershipCache.get(key);
const res = await fetch(
`https://api.github.com/orgs/${encodeURIComponent(owner)}/teams/${encodeURIComponent(teamSlug)}/memberships/${encodeURIComponent(user)}`,
{
headers: {
authorization: `Bearer ${orgToken}`,
accept: 'application/vnd.github+json',
'x-github-api-version': '2022-11-28',
},
},
);
if (res.status === 404) {
teamMembershipCache.set(key, false);
return false;
}
if (res.status === 403) {
// Almost always means the token lacks read:org.
core.warning(`Team membership check for @${owner}/${teamSlug} returned 403 — the provided github-token likely lacks read:org (Organization Members:read) scope. Treating as not-a-member.`);
teamMembershipCache.set(key, false);
return false;
}
if (!res.ok) {
const body = await res.text();
throw new Error(`Team membership check failed: ${res.status} ${body}`);
}
const data = await res.json();
const ok = data.state === 'active';
teamMembershipCache.set(key, ok);
return ok;
}
async function isInAlwaysAllowTeam(user) {
for (const team of alwaysAllowTeams) {
if (await isTeamMember(user, team)) {
core.info(`@${user} authorized via always-allow team @${owner}/${team}`);
return true;
}
}
return false;
}
async function isAuthorized(user, pr) {
// 1. always-allow teams
if (await isInAlwaysAllowTeam(user)) return true;
// 2. CODEOWNERS of the PR's changed files (from BASE ref)
let content;
try {
const { data } = await github.rest.repos.getContent({
owner, repo, path: codeownersPath, ref: pr.base.ref,
});
content = Buffer.from(data.content, data.encoding).toString('utf8');
} catch (e) {
if (e.status === 404) {
core.info(`No CODEOWNERS found at ${codeownersPath} on ${pr.base.ref}`);
return false;
}
throw e;
}
const rules = parseCodeowners(content);
const files = await github.paginate(github.rest.pulls.listFiles, {
owner, repo, pull_number: pr.number, per_page: 100,
});
const ownerSet = new Set();
for (const f of files) {
for (const o of ownersForFile(rules, f.filename)) ownerSet.add(o);
}
core.info(`CODEOWNERS for this PR's files: ${[...ownerSet].join(', ') || '(none)'}`);
for (const o of ownerSet) {
if (o.includes('/')) {
// @org/team — we require the org to match our repo's org
const [oOrg, oTeam] = o.replace(/^@/, '').split('/');
if (oOrg.toLowerCase() !== owner.toLowerCase()) continue;
if (await isTeamMember(user, oTeam)) {
core.info(`@${user} authorized via CODEOWNER team ${o}`);
return true;
}
} else {
// @user
if (o.replace(/^@/, '').toLowerCase() === user.toLowerCase()) {
core.info(`@${user} authorized via CODEOWNER user entry`);
return true;
}
}
}
return false;
}
// --- Label & review helpers -------------------------------------
async function addLabel(prNumber, label) {
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: [label],
});
}
async function removeLabel(prNumber, label) {
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: prNumber, name: label,
});
} catch (e) {
if (e.status !== 404) throw e;
}
}
async function react(commentId, content) {
await github.rest.reactions.createForIssueComment({
owner, repo, comment_id: commentId, content,
});
}
async function submitApproval(prNumber, onBehalfOf, forced) {
const via = forced ? '/lgtm force' : '/lgtm';
await github.rest.pulls.createReview({
owner, repo, pull_number: prNumber, event: 'APPROVE',
body: `${reviewMarker}\nApproved on behalf of @${onBehalfOf} via \`${via}\`.`,
});
}
async function dismissBotApprovals(prNumber, reason) {
const reviews = await github.paginate(github.rest.pulls.listReviews, {
owner, repo, pull_number: prNumber, per_page: 100,
});
for (const r of reviews) {
if (r.state !== 'APPROVED') continue;
if (!r.body || !r.body.includes(reviewMarker)) continue;
try {
await github.rest.pulls.dismissReview({
owner, repo, pull_number: prNumber, review_id: r.id,
message: reason,
});
} catch (e) {
core.warning(`Failed to dismiss review ${r.id}: ${e.message}`);
}
}
}
async function enableAutoMerge(prNodeId, prNumber) {
if (!autoMerge) return;
try {
await github.graphql(
`mutation($id: ID!, $m: PullRequestMergeMethod!) {
enablePullRequestAutoMerge(input: {pullRequestId: $id, mergeMethod: $m}) {
pullRequest { autoMergeRequest { enabledAt } }
}
}`,
{ id: prNodeId, m: autoMergeMethod },
);
core.info(`Auto-merge enabled (${autoMergeMethod})`);
} catch (e) {
const msg = e.message || String(e);
// enablePullRequestAutoMerge rejects PRs that are already mergeable
// ("clean status") because there is nothing to wait for. Merge directly.
if (/clean status/i.test(msg)) {
core.info('PR already mergeable — merging directly');
await github.rest.pulls.merge({
owner, repo, pull_number: prNumber,
merge_method: autoMergeMethod.toLowerCase(),
});
} else {
core.warning(`Could not enable auto-merge: ${msg}`);
}
}
}
async function disableAutoMerge(prNodeId) {
if (!autoMerge) return;
try {
await github.graphql(
`mutation($id: ID!) {
disablePullRequestAutoMerge(input: {pullRequestId: $id}) {
pullRequest { id }
}
}`,
{ id: prNodeId },
);
core.info('Auto-merge disabled');
} catch (e) {
core.info(`Auto-merge disable skipped: ${e.message}`);
}
}
// --- Dispatch ---------------------------------------------------
const ev = context.eventName;
// Invalidation on push
if (ev === 'pull_request_target' && context.payload.action === 'synchronize') {
if (!invalidateOnPush) return setResult('noop');
const pr = context.payload.pull_request;
const hasApproved = pr.labels.some(l => l.name === approvedLabel);
if (!hasApproved) return setResult('noop');
await removeLabel(pr.number, approvedLabel);
await dismissBotApprovals(pr.number, 'New commits pushed — approval invalidated.');
await disableAutoMerge(pr.node_id);
await github.rest.issues.createComment({
owner, repo, issue_number: pr.number,
body: `New commits were pushed — removed the \`${approvedLabel}\` label. Re-approve with \`/lgtm\`.`,
});
return setResult('invalidated');
}
// Comment commands
if (ev !== 'issue_comment') return setResult('noop');
if (!context.payload.issue.pull_request) return setResult('noop');
const commentBody = context.payload.comment.body || '';
const commentId = context.payload.comment.id;
const actor = context.payload.comment.user.login;
const firstLine = commentBody.split('\n')[0].trim();
const m = firstLine.match(/^\/(lgtm|hold|unhold|stageblog)\b\s*(cancel|force)?\s*$/i);
if (!m) return setResult('noop', actor);
const cmd = m[1].toLowerCase();
const arg = (m[2] || '').toLowerCase();
const cancel = arg === 'cancel' || cmd === 'unhold';
// `force` is only meaningful for /lgtm — reject `/hold force` etc. as noop
// rather than silently treating it as the base command.
if (arg === 'force' && cmd !== 'lgtm') return setResult('noop', actor);
const force = arg === 'force';
const prNumber = context.payload.issue.number;
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
if (pr.state !== 'open') return setResult('noop', actor);
// /lgtm force — restricted to force-allow-teams. Bypasses the self-approval
// guard and the CODEOWNERS path entirely; only team membership grants this.
if (cmd === 'lgtm' && force) {
let allowed = false;
for (const team of forceAllowTeams) {
if (await isTeamMember(actor, team)) { allowed = true; break; }
}
if (!allowed) {
await react(commentId, '-1');
return setResult('force-unauthorized', actor);
}
await addLabel(prNumber, approvedLabel);
if (submitReview) await submitApproval(prNumber, actor, true);
await react(commentId, '+1');
await enableAutoMerge(pr.node_id, prNumber);
return setResult('lgtm-forced', actor);
}
// Self-approval guard (only for /lgtm, not /hold)
if (cmd === 'lgtm' && !cancel && actor.toLowerCase() === pr.user.login.toLowerCase()) {
await react(commentId, '-1');
return setResult('self-lgtm-blocked', actor);
}
// Auth check. /lgtm is restricted to always-allow-teams (core maintainers)
// only — CODEOWNERS does not grant it. /hold and /stageblog still accept
// CODEOWNERS of the PR's changed files.
const authorized = cmd === 'lgtm'
? await isInAlwaysAllowTeam(actor)
: await isAuthorized(actor, pr);
if (!authorized) {
await react(commentId, '-1');
return setResult('unauthorized', actor);
}
// /stageblog — dispatch a caller-defined workflow with pinned head SHA
if (cmd === 'stageblog') {
const stageblogWorkflow = process.env.STAGEBLOG_WORKFLOW;
if (!stageblogWorkflow) {
await react(commentId, 'confused');
return setResult('stageblog-disabled', actor);
}
// cancel is meaningless here — just ignore silently
if (cancel) return setResult('noop', actor);
// Ack early — listFiles pagination + workflow dispatch can take a few
// seconds and the commenter otherwise gets no feedback until then.
await react(commentId, 'eyes');
// Gate: PR must touch blog paths
const blogRegexes = process.env.STAGEBLOG_PATHS
.split(',').map(s => s.trim()).filter(Boolean).map(patternToRegex);
const files = await github.paginate(github.rest.pulls.listFiles, {
owner, repo, pull_number: prNumber, per_page: 100,
});
const touchesBlog = files.some(f => blogRegexes.some(r => r.test(f.filename)));
if (!touchesBlog) {
await react(commentId, '-1');
await github.rest.issues.createComment({
owner, repo, issue_number: prNumber,
body: `\`/stageblog\` refused — this PR does not touch any blog paths (\`${process.env.STAGEBLOG_PATHS}\`).`,
});
return setResult('stageblog-not-blog', actor);
}
// Dispatch — pin head SHA at time of comment
const headSha = pr.head.sha;
await github.rest.actions.createWorkflowDispatch({
owner, repo, workflow_id: stageblogWorkflow, ref: pr.base.ref,
inputs: { pr_number: String(prNumber), head_sha: headSha },
});
await react(commentId, 'rocket');
await github.rest.issues.createComment({
owner, repo, issue_number: prNumber,
body: `Blog staging triggered by @${actor} for \`${headSha.slice(0, 7)}\`. Watch the [**${stageblogWorkflow}** workflow](../actions/workflows/${stageblogWorkflow}) for the preview link.`,
});
return setResult('stageblog-dispatched', actor);
}
// Execute
if (cmd === 'lgtm' && !cancel) {
await addLabel(prNumber, approvedLabel);
if (submitReview) await submitApproval(prNumber, actor);
await react(commentId, '+1');
await enableAutoMerge(pr.node_id, prNumber);
return setResult('lgtm-added', actor);
}
if (cmd === 'lgtm' && cancel) {
await removeLabel(prNumber, approvedLabel);
await dismissBotApprovals(prNumber, `@${actor} cancelled approval via /lgtm cancel.`);
await disableAutoMerge(pr.node_id);
await react(commentId, '+1');
return setResult('lgtm-removed', actor);
}
if ((cmd === 'hold' && !cancel)) {
await addLabel(prNumber, holdLabel);
await react(commentId, '+1');
return setResult('hold-added', actor);
}
// /hold cancel or /unhold
await removeLabel(prNumber, holdLabel);
await react(commentId, '+1');
return setResult('hold-removed', actor);
};