Merge pull request #5 from modelcontextprotocol/den/stageblog-command
slash-commands: early ack reaction + post as github-actions[bot]
This commit is contained in:
commit
658d01c6d7
3 changed files with 47 additions and 23 deletions
|
|
@ -48,12 +48,14 @@ and variables → Actions → New repository secret**.
|
||||||
|
|
||||||
| Repo secret name | Action input | Value |
|
| Repo secret name | Action input | Value |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `SLASH_COMMANDS_TOKEN` | `github-token` | Fine-grained PAT (or GitHub App installation token) with **Organization → Members: read** plus **Repository → Contents: read, Pull requests: write, Issues: write**. If `/stageblog` is enabled, additionally **Repository → Actions: write**. |
|
| `SLASH_COMMANDS_TOKEN` | `github-token` | Fine-grained PAT (or GitHub App installation token) with **Organization → Members: read** plus **Repository → Contents: read**. If `/stageblog` is enabled, additionally **Repository → Actions: write**. |
|
||||||
|
|
||||||
> **The default `${{ github.token }}` will NOT work.** Team-membership checks
|
> **The default `${{ github.token }}` will NOT work** for `github-token` —
|
||||||
> (`GET /orgs/{org}/teams/{team}/memberships/{user}`) require `read:org` scope,
|
> team-membership checks (`GET /orgs/{org}/teams/{team}/memberships/{user}`)
|
||||||
> which the automatic `GITHUB_TOKEN` never has — even with a `permissions:`
|
> require `read:org` scope, which the automatic `GITHUB_TOKEN` never has. You
|
||||||
> block. You _must_ provide a PAT or App token.
|
> _must_ provide a PAT or App token. Comments, reactions, labels, and reviews
|
||||||
|
> use a separate `bot-token` (defaults to `GITHUB_TOKEN`) so they show as
|
||||||
|
> authored by `github-actions[bot]`.
|
||||||
|
|
||||||
### `SLASH_COMMANDS_TOKEN`
|
### `SLASH_COMMANDS_TOKEN`
|
||||||
|
|
||||||
|
|
@ -67,13 +69,12 @@ tokens → Generate new token**. Configure:
|
||||||
| Resource owner | `modelcontextprotocol` (or your org) |
|
| Resource owner | `modelcontextprotocol` (or your org) |
|
||||||
| Repository access | **Only select repositories** → pick the caller repo |
|
| Repository access | **Only select repositories** → pick the caller repo |
|
||||||
| Repository permissions → Contents | Read-only |
|
| Repository permissions → Contents | Read-only |
|
||||||
| Repository permissions → Pull requests | Read and write |
|
|
||||||
| Repository permissions → Issues | Read and write |
|
|
||||||
| Organization permissions → Members | Read-only |
|
| Organization permissions → Members | Read-only |
|
||||||
| Repository permissions → Actions | Read and write _(only if `stageblog-workflow` is set)_ |
|
| Repository permissions → Actions | Read and write _(only if `stageblog-workflow` is set)_ |
|
||||||
|
|
||||||
Do not grant any write permissions beyond Pull requests + Issues. **Contents**
|
The PAT does not need write permissions — all writes (comments, reactions,
|
||||||
must remain read-only — the action never writes code.
|
labels, reviews) use the workflow's `GITHUB_TOKEN`. **Contents** must remain
|
||||||
|
read-only — the action never writes code.
|
||||||
|
|
||||||
**Option B — GitHub App installation token:**
|
**Option B — GitHub App installation token:**
|
||||||
|
|
||||||
|
|
@ -189,7 +190,8 @@ jobs:
|
||||||
|
|
||||||
| Input | Required | Default | Description |
|
| Input | Required | Default | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `github-token` | ✅ | — | PAT or App token with `read:org` + repo `pull-requests: write` + `issues: write` + `contents: read`. **Default `GITHUB_TOKEN` will not work** for team checks. |
|
| `github-token` | ✅ | — | PAT or App token with `read:org` + repo `contents: read` (+ `actions: write` for `/stageblog`). Used for team membership checks, CODEOWNERS fetch, and workflow dispatch. **Default `GITHUB_TOKEN` will not work** for team checks. |
|
||||||
|
| `bot-token` | | `${{ github.token }}` | Token for user-visible side effects (comments, reactions, labels, reviews) — defaults to `GITHUB_TOKEN` so these show as `github-actions[bot]`. Rarely needs overriding. |
|
||||||
| `approved-label` | | `approved` | Label added by `/lgtm` |
|
| `approved-label` | | `approved` | Label added by `/lgtm` |
|
||||||
| `hold-label` | | `do-not-merge/hold` | Label added by `/hold` |
|
| `hold-label` | | `do-not-merge/hold` | Label added by `/hold` |
|
||||||
| `always-allow-teams` | | `core-maintainers` | Comma-separated team slugs (in the repo's org) whose members can `/lgtm` or `/hold` any PR regardless of CODEOWNERS |
|
| `always-allow-teams` | | `core-maintainers` | Comma-separated team slugs (in the repo's org) whose members can `/lgtm` or `/hold` any PR regardless of CODEOWNERS |
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,21 @@ description: >-
|
||||||
inputs:
|
inputs:
|
||||||
github-token:
|
github-token:
|
||||||
description: >-
|
description: >-
|
||||||
Token for API calls. MUST have read:org scope to check team membership —
|
Token for org team-membership checks and workflow dispatch. MUST have
|
||||||
the default GITHUB_TOKEN cannot do this. Use a fine-grained PAT with
|
read:org scope — the default GITHUB_TOKEN cannot do this. Use a
|
||||||
Organization Members:read + Repository Contents:read + Pull requests:write
|
fine-grained PAT with Organization Members:read (+ Actions:write if
|
||||||
+ Issues:write, or a GitHub App installation token with equivalent perms.
|
using /stageblog), or a GitHub App installation token with equivalent
|
||||||
|
perms.
|
||||||
required: true
|
required: true
|
||||||
|
bot-token:
|
||||||
|
description: >-
|
||||||
|
Token used for user-visible side effects (comments, reactions, labels,
|
||||||
|
reviews). Defaults to the automatic GITHUB_TOKEN so these show as
|
||||||
|
authored by github-actions[bot]. The caller workflow must grant this
|
||||||
|
token the necessary repo permissions (pull-requests: write,
|
||||||
|
issues: write).
|
||||||
|
required: false
|
||||||
|
default: ${{ github.token }}
|
||||||
approved-label:
|
approved-label:
|
||||||
description: Label added by /lgtm and used by the status job as the merge gate
|
description: Label added by /lgtm and used by the status job as the merge gate
|
||||||
required: false
|
required: false
|
||||||
|
|
@ -85,6 +95,7 @@ runs:
|
||||||
SUBMIT_REVIEW: ${{ inputs.submit-review }}
|
SUBMIT_REVIEW: ${{ inputs.submit-review }}
|
||||||
STAGEBLOG_WORKFLOW: ${{ inputs.stageblog-workflow }}
|
STAGEBLOG_WORKFLOW: ${{ inputs.stageblog-workflow }}
|
||||||
STAGEBLOG_PATHS: ${{ inputs.stageblog-paths }}
|
STAGEBLOG_PATHS: ${{ inputs.stageblog-paths }}
|
||||||
|
BOT_TOKEN: ${{ inputs.bot-token }}
|
||||||
with:
|
with:
|
||||||
github-token: ${{ inputs.github-token }}
|
github-token: ${{ inputs.github-token }}
|
||||||
script: |
|
script: |
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,13 @@ module.exports = async ({ github, context, core }) => {
|
||||||
const submitReview = process.env.SUBMIT_REVIEW === 'true';
|
const submitReview = process.env.SUBMIT_REVIEW === 'true';
|
||||||
const reviewMarker = '<!-- slash-commands-lgtm -->';
|
const reviewMarker = '<!-- slash-commands-lgtm -->';
|
||||||
|
|
||||||
|
// Separate client for user-visible side effects (comments, reactions,
|
||||||
|
// labels, reviews). Uses github.token so everything shows as authored by
|
||||||
|
// github-actions[bot] instead of the PAT's identity. The PAT (`github`)
|
||||||
|
// is still used for team-membership checks (needs read:org), CODEOWNERS
|
||||||
|
// fetch, listFiles, pulls.get, and workflow dispatch.
|
||||||
|
const bot = require('@actions/github').getOctokit(process.env.BOT_TOKEN);
|
||||||
|
|
||||||
function setResult(result, actor) {
|
function setResult(result, actor) {
|
||||||
core.setOutput('result', result);
|
core.setOutput('result', result);
|
||||||
core.setOutput('actor', actor || '');
|
core.setOutput('actor', actor || '');
|
||||||
|
|
@ -150,13 +157,13 @@ module.exports = async ({ github, context, core }) => {
|
||||||
|
|
||||||
// --- Label & review helpers -------------------------------------
|
// --- Label & review helpers -------------------------------------
|
||||||
async function addLabel(prNumber, label) {
|
async function addLabel(prNumber, label) {
|
||||||
await github.rest.issues.addLabels({
|
await bot.rest.issues.addLabels({
|
||||||
owner, repo, issue_number: prNumber, labels: [label],
|
owner, repo, issue_number: prNumber, labels: [label],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async function removeLabel(prNumber, label) {
|
async function removeLabel(prNumber, label) {
|
||||||
try {
|
try {
|
||||||
await github.rest.issues.removeLabel({
|
await bot.rest.issues.removeLabel({
|
||||||
owner, repo, issue_number: prNumber, name: label,
|
owner, repo, issue_number: prNumber, name: label,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -164,25 +171,25 @@ module.exports = async ({ github, context, core }) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function react(commentId, content) {
|
async function react(commentId, content) {
|
||||||
await github.rest.reactions.createForIssueComment({
|
await bot.rest.reactions.createForIssueComment({
|
||||||
owner, repo, comment_id: commentId, content,
|
owner, repo, comment_id: commentId, content,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async function submitApproval(prNumber, onBehalfOf) {
|
async function submitApproval(prNumber, onBehalfOf) {
|
||||||
await github.rest.pulls.createReview({
|
await bot.rest.pulls.createReview({
|
||||||
owner, repo, pull_number: prNumber, event: 'APPROVE',
|
owner, repo, pull_number: prNumber, event: 'APPROVE',
|
||||||
body: `${reviewMarker}\nApproved on behalf of @${onBehalfOf} via \`/lgtm\`.`,
|
body: `${reviewMarker}\nApproved on behalf of @${onBehalfOf} via \`/lgtm\`.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async function dismissBotApprovals(prNumber, reason) {
|
async function dismissBotApprovals(prNumber, reason) {
|
||||||
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
const reviews = await bot.paginate(bot.rest.pulls.listReviews, {
|
||||||
owner, repo, pull_number: prNumber, per_page: 100,
|
owner, repo, pull_number: prNumber, per_page: 100,
|
||||||
});
|
});
|
||||||
for (const r of reviews) {
|
for (const r of reviews) {
|
||||||
if (r.state !== 'APPROVED') continue;
|
if (r.state !== 'APPROVED') continue;
|
||||||
if (!r.body || !r.body.includes(reviewMarker)) continue;
|
if (!r.body || !r.body.includes(reviewMarker)) continue;
|
||||||
try {
|
try {
|
||||||
await github.rest.pulls.dismissReview({
|
await bot.rest.pulls.dismissReview({
|
||||||
owner, repo, pull_number: prNumber, review_id: r.id,
|
owner, repo, pull_number: prNumber, review_id: r.id,
|
||||||
message: reason,
|
message: reason,
|
||||||
});
|
});
|
||||||
|
|
@ -203,7 +210,7 @@ module.exports = async ({ github, context, core }) => {
|
||||||
if (!hasApproved) return setResult('noop');
|
if (!hasApproved) return setResult('noop');
|
||||||
await removeLabel(pr.number, approvedLabel);
|
await removeLabel(pr.number, approvedLabel);
|
||||||
await dismissBotApprovals(pr.number, 'New commits pushed — approval invalidated.');
|
await dismissBotApprovals(pr.number, 'New commits pushed — approval invalidated.');
|
||||||
await github.rest.issues.createComment({
|
await bot.rest.issues.createComment({
|
||||||
owner, repo, issue_number: pr.number,
|
owner, repo, issue_number: pr.number,
|
||||||
body: `New commits were pushed — removed the \`${approvedLabel}\` label. Re-approve with \`/lgtm\`.`,
|
body: `New commits were pushed — removed the \`${approvedLabel}\` label. Re-approve with \`/lgtm\`.`,
|
||||||
});
|
});
|
||||||
|
|
@ -250,6 +257,10 @@ module.exports = async ({ github, context, core }) => {
|
||||||
// cancel is meaningless here — just ignore silently
|
// cancel is meaningless here — just ignore silently
|
||||||
if (cancel) return setResult('noop', actor);
|
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
|
// Gate: PR must touch blog paths
|
||||||
const blogRegexes = process.env.STAGEBLOG_PATHS
|
const blogRegexes = process.env.STAGEBLOG_PATHS
|
||||||
.split(',').map(s => s.trim()).filter(Boolean).map(patternToRegex);
|
.split(',').map(s => s.trim()).filter(Boolean).map(patternToRegex);
|
||||||
|
|
@ -259,7 +270,7 @@ module.exports = async ({ github, context, core }) => {
|
||||||
const touchesBlog = files.some(f => blogRegexes.some(r => r.test(f.filename)));
|
const touchesBlog = files.some(f => blogRegexes.some(r => r.test(f.filename)));
|
||||||
if (!touchesBlog) {
|
if (!touchesBlog) {
|
||||||
await react(commentId, '-1');
|
await react(commentId, '-1');
|
||||||
await github.rest.issues.createComment({
|
await bot.rest.issues.createComment({
|
||||||
owner, repo, issue_number: prNumber,
|
owner, repo, issue_number: prNumber,
|
||||||
body: `\`/stageblog\` refused — this PR does not touch any blog paths (\`${process.env.STAGEBLOG_PATHS}\`).`,
|
body: `\`/stageblog\` refused — this PR does not touch any blog paths (\`${process.env.STAGEBLOG_PATHS}\`).`,
|
||||||
});
|
});
|
||||||
|
|
@ -273,7 +284,7 @@ module.exports = async ({ github, context, core }) => {
|
||||||
inputs: { pr_number: String(prNumber), head_sha: headSha },
|
inputs: { pr_number: String(prNumber), head_sha: headSha },
|
||||||
});
|
});
|
||||||
await react(commentId, 'rocket');
|
await react(commentId, 'rocket');
|
||||||
await github.rest.issues.createComment({
|
await bot.rest.issues.createComment({
|
||||||
owner, repo, issue_number: prNumber,
|
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.`,
|
body: `Blog staging triggered by @${actor} for \`${headSha.slice(0, 7)}\`. Watch the [**${stageblogWorkflow}** workflow](../actions/workflows/${stageblogWorkflow}) for the preview link.`,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue