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 |
|
||||
|---|---|---|
|
||||
| `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
|
||||
> (`GET /orgs/{org}/teams/{team}/memberships/{user}`) require `read:org` scope,
|
||||
> which the automatic `GITHUB_TOKEN` never has — even with a `permissions:`
|
||||
> block. You _must_ provide a PAT or App token.
|
||||
> **The default `${{ github.token }}` will NOT work** for `github-token` —
|
||||
> team-membership checks (`GET /orgs/{org}/teams/{team}/memberships/{user}`)
|
||||
> require `read:org` scope, which the automatic `GITHUB_TOKEN` never has. You
|
||||
> _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`
|
||||
|
||||
|
|
@ -67,13 +69,12 @@ tokens → Generate new token**. Configure:
|
|||
| Resource owner | `modelcontextprotocol` (or your org) |
|
||||
| Repository access | **Only select repositories** → pick the caller repo |
|
||||
| Repository permissions → Contents | Read-only |
|
||||
| Repository permissions → Pull requests | Read and write |
|
||||
| Repository permissions → Issues | Read and write |
|
||||
| Organization permissions → Members | Read-only |
|
||||
| Repository permissions → Actions | Read and write _(only if `stageblog-workflow` is set)_ |
|
||||
|
||||
Do not grant any write permissions beyond Pull requests + Issues. **Contents**
|
||||
must remain read-only — the action never writes code.
|
||||
The PAT does not need write permissions — all writes (comments, reactions,
|
||||
labels, reviews) use the workflow's `GITHUB_TOKEN`. **Contents** must remain
|
||||
read-only — the action never writes code.
|
||||
|
||||
**Option B — GitHub App installation token:**
|
||||
|
||||
|
|
@ -189,7 +190,8 @@ jobs:
|
|||
|
||||
| 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` |
|
||||
| `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 |
|
||||
|
|
|
|||
|
|
@ -9,11 +9,21 @@ description: >-
|
|||
inputs:
|
||||
github-token:
|
||||
description: >-
|
||||
Token for API calls. MUST have read:org scope to check team membership —
|
||||
the default GITHUB_TOKEN cannot do this. Use a fine-grained PAT with
|
||||
Organization Members:read + Repository Contents:read + Pull requests:write
|
||||
+ Issues:write, or a GitHub App installation token with equivalent perms.
|
||||
Token for org team-membership checks and workflow dispatch. MUST have
|
||||
read:org scope — the default GITHUB_TOKEN cannot do this. Use a
|
||||
fine-grained PAT with Organization Members:read (+ Actions:write if
|
||||
using /stageblog), or a GitHub App installation token with equivalent
|
||||
perms.
|
||||
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:
|
||||
description: Label added by /lgtm and used by the status job as the merge gate
|
||||
required: false
|
||||
|
|
@ -85,6 +95,7 @@ runs:
|
|||
SUBMIT_REVIEW: ${{ inputs.submit-review }}
|
||||
STAGEBLOG_WORKFLOW: ${{ inputs.stageblog-workflow }}
|
||||
STAGEBLOG_PATHS: ${{ inputs.stageblog-paths }}
|
||||
BOT_TOKEN: ${{ inputs.bot-token }}
|
||||
with:
|
||||
github-token: ${{ inputs.github-token }}
|
||||
script: |
|
||||
|
|
|
|||
|
|
@ -13,6 +13,13 @@ module.exports = async ({ github, context, core }) => {
|
|||
const submitReview = process.env.SUBMIT_REVIEW === 'true';
|
||||
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) {
|
||||
core.setOutput('result', result);
|
||||
core.setOutput('actor', actor || '');
|
||||
|
|
@ -150,13 +157,13 @@ module.exports = async ({ github, context, core }) => {
|
|||
|
||||
// --- Label & review helpers -------------------------------------
|
||||
async function addLabel(prNumber, label) {
|
||||
await github.rest.issues.addLabels({
|
||||
await bot.rest.issues.addLabels({
|
||||
owner, repo, issue_number: prNumber, labels: [label],
|
||||
});
|
||||
}
|
||||
async function removeLabel(prNumber, label) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
await bot.rest.issues.removeLabel({
|
||||
owner, repo, issue_number: prNumber, name: label,
|
||||
});
|
||||
} catch (e) {
|
||||
|
|
@ -164,25 +171,25 @@ module.exports = async ({ github, context, core }) => {
|
|||
}
|
||||
}
|
||||
async function react(commentId, content) {
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
await bot.rest.reactions.createForIssueComment({
|
||||
owner, repo, comment_id: commentId, content,
|
||||
});
|
||||
}
|
||||
async function submitApproval(prNumber, onBehalfOf) {
|
||||
await github.rest.pulls.createReview({
|
||||
await bot.rest.pulls.createReview({
|
||||
owner, repo, pull_number: prNumber, event: 'APPROVE',
|
||||
body: `${reviewMarker}\nApproved on behalf of @${onBehalfOf} via \`/lgtm\`.`,
|
||||
});
|
||||
}
|
||||
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,
|
||||
});
|
||||
for (const r of reviews) {
|
||||
if (r.state !== 'APPROVED') continue;
|
||||
if (!r.body || !r.body.includes(reviewMarker)) continue;
|
||||
try {
|
||||
await github.rest.pulls.dismissReview({
|
||||
await bot.rest.pulls.dismissReview({
|
||||
owner, repo, pull_number: prNumber, review_id: r.id,
|
||||
message: reason,
|
||||
});
|
||||
|
|
@ -203,7 +210,7 @@ module.exports = async ({ github, context, core }) => {
|
|||
if (!hasApproved) return setResult('noop');
|
||||
await removeLabel(pr.number, approvedLabel);
|
||||
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,
|
||||
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
|
||||
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);
|
||||
|
|
@ -259,7 +270,7 @@ module.exports = async ({ github, context, core }) => {
|
|||
const touchesBlog = files.some(f => blogRegexes.some(r => r.test(f.filename)));
|
||||
if (!touchesBlog) {
|
||||
await react(commentId, '-1');
|
||||
await github.rest.issues.createComment({
|
||||
await bot.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber,
|
||||
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 },
|
||||
});
|
||||
await react(commentId, 'rocket');
|
||||
await github.rest.issues.createComment({
|
||||
await bot.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.`,
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue