actions/slash-commands/main.js
Den Delimarsky 225d66d1f2
slash-commands: gate project status update on a required PR label
Add project-gate-label input. When set, the project status update on
/lgtm accept only runs if the PR carries that label — lets callers
restrict project-board tracking to a subset of PRs (e.g. only SEPs go
to the SEP Review Pipeline, not every accepted PR).

onAccept now takes the full PR object instead of (number, nodeId) so it
can inspect labels without a second fetch.

Label removal via remove-labels-on-accept is unchanged — it runs on
every accept regardless of the gate.

🏠 Remote-Dev: homespace
2026-03-28 01:46:32 +00:00

477 lines
19 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 removeLabelsOnAccept = process.env.REMOVE_LABELS_ON_ACCEPT
.split(',').map(s => s.trim()).filter(Boolean);
const projectNumber = parseInt(process.env.PROJECT_NUMBER, 10) || 0;
const projectStatusField = process.env.PROJECT_STATUS_FIELD;
const projectAcceptedOption = process.env.PROJECT_ACCEPTED_OPTION;
const projectGateLabel = process.env.PROJECT_GATE_LABEL;
const reviewMarker = '<!-- slash-commands-lgtm -->';
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);
let ok;
try {
const { data } = await github.rest.teams.getMembershipForUserInOrg({
org: owner, team_slug: teamSlug, username: user,
});
ok = data.state === 'active';
} catch (e) {
if (e.status === 404) {
ok = false;
} else if (e.status === 403) {
core.warning(`Team membership check for @${owner}/${teamSlug} returned 403 — the provided github-token likely lacks Organization Members:read. Treating as not-a-member.`);
ok = false;
} else {
throw e;
}
}
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;
async function mergeDirect(reason) {
core.info(`${reason} — merging directly`);
try {
await github.rest.pulls.merge({
owner, repo, pull_number: prNumber,
merge_method: autoMergeMethod.toLowerCase(),
});
core.info('Merged');
} catch (e) {
core.warning(`Direct merge failed: ${e.message}. If branch protection requires code-owner review, add this GitHub App to the ruleset bypass list.`);
}
}
try {
const result = await github.graphql(
`mutation($id: ID!, $m: PullRequestMergeMethod!) {
enablePullRequestAutoMerge(input: {pullRequestId: $id, mergeMethod: $m}) {
pullRequest { autoMergeRequest { enabledAt } reviewDecision }
}
}`,
{ id: prNodeId, m: autoMergeMethod },
);
core.info(`Auto-merge enabled (${autoMergeMethod})`);
// Auto-merge does not inherit the ruleset-bypass privileges of the
// actor who enabled it — it only fires once the PR is clean for
// everyone. If we submitted an approval and reviewDecision is still
// REVIEW_REQUIRED, the bot's approval did not satisfy
// require_code_owner_review (GitHub Apps cannot be team members), so
// auto-merge will never fire. Merge directly instead — a direct merge
// DOES use the bot's bypass.
const decision = result.enablePullRequestAutoMerge.pullRequest.reviewDecision;
if (submitReview && decision === 'REVIEW_REQUIRED') {
return mergeDirect('Bot approval did not satisfy review requirement');
}
} catch (e) {
const msg = e.message || String(e);
// enablePullRequestAutoMerge rejects PRs that are already mergeable
// ("clean status") because there is nothing to wait for.
if (/clean status/i.test(msg)) {
return mergeDirect('PR already mergeable');
}
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}`);
}
}
async function onAccept(pr) {
for (const label of removeLabelsOnAccept) {
await removeLabel(pr.number, label);
}
if (!projectNumber) return;
if (projectGateLabel && !pr.labels.some(l => l.name === projectGateLabel)) {
core.info(`Skipping project update — PR lacks '${projectGateLabel}' label`);
return;
}
try {
const lookup = await github.graphql(
`query($org: String!, $num: Int!, $field: String!) {
organization(login: $org) {
projectV2(number: $num) {
id
field(name: $field) {
... on ProjectV2SingleSelectField { id options { id name } }
}
}
}
}`,
{ org: owner, num: projectNumber, field: projectStatusField },
);
const project = lookup.organization?.projectV2;
if (!project) {
return core.warning(`Project #${projectNumber} not found in org ${owner}`);
}
const field = project.field;
if (!field?.id) {
return core.warning(`Field '${projectStatusField}' not found (or not single-select) in project #${projectNumber}`);
}
const option = field.options.find(o => o.name === projectAcceptedOption);
if (!option) {
return core.warning(`Option '${projectAcceptedOption}' not found in field '${projectStatusField}'`);
}
// addProjectV2ItemById is idempotent — returns the existing item if
// the PR is already in the project.
const add = await github.graphql(
`mutation($proj: ID!, $pr: ID!) {
addProjectV2ItemById(input: {projectId: $proj, contentId: $pr}) {
item { id }
}
}`,
{ proj: project.id, pr: pr.node_id },
);
const itemId = add.addProjectV2ItemById.item.id;
await github.graphql(
`mutation($proj: ID!, $item: ID!, $field: ID!, $opt: String!) {
updateProjectV2ItemFieldValue(input: {
projectId: $proj, itemId: $item, fieldId: $field,
value: { singleSelectOptionId: $opt }
}) { projectV2Item { id } }
}`,
{ proj: project.id, item: itemId, field: field.id, opt: option.id },
);
core.info(`Moved PR to '${projectAcceptedOption}' in project #${projectNumber}`);
} catch (e) {
core.warning(`Project update failed: ${e.message}. Ensure the GitHub App has Organization Projects:write permission.`);
}
}
// --- 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);
await onAccept(pr);
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);
await onAccept(pr);
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);
};