diff --git a/slash-commands/README.md b/slash-commands/README.md index be8a157..6dadcc4 100644 --- a/slash-commands/README.md +++ b/slash-commands/README.md @@ -11,7 +11,9 @@ Listens for PR comments and push events, then: - **`/lgtm`** — **core maintainers only** (`always-allow-teams`). Adds an `accepted` label, submits an **APPROVE** review on behalf of the commenter, and **enables auto-merge**. The PR merges automatically once all required - checks pass. CODEOWNERS does _not_ grant `/lgtm` authorization. + checks pass. CODEOWNERS does _not_ grant `/lgtm` authorization. Optionally + removes staging labels (`remove-labels-on-accept`) and moves the PR to an + _Accepted_ status in a configured org Project (`project-number`). - **`/lgtm cancel`** — removes the `accepted` label, dismisses the approval, and disables auto-merge. - **`/lgtm force`** — same as `/lgtm`, but **only** members of @@ -215,6 +217,10 @@ jobs: | `submit-review` | | `'true'` | Submit an APPROVE review alongside the label on `/lgtm`. Set `'false'` to use label only. | | `enable-auto-merge` | | `'true'` | Enable auto-merge after `/lgtm` (disabled on `/lgtm cancel` and push invalidation). Requires repo setting "Allow auto-merge". | | `auto-merge-method` | | `squash` | Merge method for auto-merge: `squash`, `merge`, or `rebase` | +| `remove-labels-on-accept` | | _(empty)_ | Comma-separated label names to remove when `/lgtm` accepts (e.g. `in-review,draft,proposal`). Missing labels are ignored. | +| `project-number` | | _(empty)_ | Org-level Project (V2) number to update on accept. Empty = disabled. Adds the PR to the project if not already present. Requires the App to have **Organization Projects: write**. | +| `project-status-field` | | `Status` | Name of the single-select field to set on accept. | +| `project-accepted-option` | | `Accepted` | Name of the option to set in the status field on accept. | | `stageblog-workflow` | | _(empty)_ | Workflow file name (e.g. `stage-blog.yml`) to dispatch when `/stageblog` is invoked. Empty = command disabled. The workflow must accept `pr_number` and `head_sha` string inputs. | | `stageblog-paths` | | `blog/**` | Comma-separated CODEOWNERS-style glob patterns. `/stageblog` is refused if no changed file matches. | diff --git a/slash-commands/action.yml b/slash-commands/action.yml index 6a5b58c..60794fd 100644 --- a/slash-commands/action.yml +++ b/slash-commands/action.yml @@ -66,6 +66,27 @@ inputs: description: Merge method for auto-merge — one of squash, merge, rebase required: false default: squash + remove-labels-on-accept: + description: >- + Comma-separated label names to remove from the PR when /lgtm accepts it + (e.g. in-review,draft,proposal). Missing labels are ignored. + required: false + default: '' + project-number: + description: >- + Org-level Project (V2) number to update when /lgtm accepts the PR. Leave + empty to disable project updates. The PR is added to the project if not + already present. + required: false + default: '' + project-status-field: + description: Name of the single-select field to set on accept + required: false + default: Status + project-accepted-option: + description: Name of the option to set in the status field on accept + required: false + default: Accepted stageblog-workflow: description: >- Workflow file name (e.g. stage-blog.yml) to dispatch when /stageblog is @@ -108,6 +129,10 @@ runs: SUBMIT_REVIEW: ${{ inputs.submit-review }} ENABLE_AUTO_MERGE: ${{ inputs.enable-auto-merge }} AUTO_MERGE_METHOD: ${{ inputs.auto-merge-method }} + REMOVE_LABELS_ON_ACCEPT: ${{ inputs.remove-labels-on-accept }} + PROJECT_NUMBER: ${{ inputs.project-number }} + PROJECT_STATUS_FIELD: ${{ inputs.project-status-field }} + PROJECT_ACCEPTED_OPTION: ${{ inputs.project-accepted-option }} STAGEBLOG_WORKFLOW: ${{ inputs.stageblog-workflow }} STAGEBLOG_PATHS: ${{ inputs.stageblog-paths }} with: diff --git a/slash-commands/main.js b/slash-commands/main.js index 218d4b6..3c314e2 100644 --- a/slash-commands/main.js +++ b/slash-commands/main.js @@ -15,6 +15,11 @@ module.exports = async ({ github, context, core }) => { 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 reviewMarker = ''; function setResult(result, actor) { @@ -260,6 +265,62 @@ module.exports = async ({ github, context, core }) => { core.info(`Auto-merge disable skipped: ${e.message}`); } } + async function onAccept(prNumber, prNodeId) { + for (const label of removeLabelsOnAccept) { + await removeLabel(prNumber, label); + } + if (!projectNumber) 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: prNodeId }, + ); + 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; @@ -315,6 +376,7 @@ module.exports = async ({ github, context, core }) => { return setResult('force-unauthorized', actor); } await addLabel(prNumber, approvedLabel); + await onAccept(prNumber, pr.node_id); if (submitReview) await submitApproval(prNumber, actor, true); await react(commentId, '+1'); await enableAutoMerge(pr.node_id, prNumber); @@ -385,6 +447,7 @@ module.exports = async ({ github, context, core }) => { // Execute if (cmd === 'lgtm' && !cancel) { await addLabel(prNumber, approvedLabel); + await onAccept(prNumber, pr.node_id); if (submitReview) await submitApproval(prNumber, actor); await react(commentId, '+1'); await enableAutoMerge(pr.node_id, prNumber);