diff --git a/.github/scripts/revision-guard/helpers/constants.js b/.github/scripts/revision-guard/helpers/constants.js new file mode 100644 index 000000000..3e63d4dd8 --- /dev/null +++ b/.github/scripts/revision-guard/helpers/constants.js @@ -0,0 +1,29 @@ +const DEFAULT_MANAGED_LABELS = [ + 'queue:junior-committer', + 'queue:committers', + 'queue:maintainers', + 'status: ready-to-merge', + 'status: failed checks', + 'open to community review', +]; + +function parseManagedLabels(value) { + if (typeof value !== 'string' || value.trim().length === 0) { + return [...DEFAULT_MANAGED_LABELS]; + } + + const customLabels = value + .split(',') + .map(label => label.trim()) + .filter(Boolean); + + // Merge custom labels with defaults so that setting REVISION_GUARD_MANAGED_LABELS + // adds to the managed set rather than silently discarding the default labels. + const merged = new Set([...DEFAULT_MANAGED_LABELS, ...customLabels]); + return [...merged]; +} + +module.exports = { + DEFAULT_MANAGED_LABELS, + parseManagedLabels, +}; diff --git a/.github/scripts/revision-guard/helpers/draft.js b/.github/scripts/revision-guard/helpers/draft.js new file mode 100644 index 000000000..23c620383 --- /dev/null +++ b/.github/scripts/revision-guard/helpers/draft.js @@ -0,0 +1,29 @@ +function isBotAuthor(pr) { + const login = pr?.user?.login || ''; + return pr?.user?.type === 'Bot' || login.endsWith('[bot]'); +} + +function isDraft(pr) { + return pr?.draft === true; +} + +async function convertToDraft(github, pullRequestId) { + await github.graphql( + ` + mutation($pullRequestId: ID!) { + convertPullRequestToDraft(input: { pullRequestId: $pullRequestId }) { + pullRequest { + isDraft + } + } + } + `, + { pullRequestId } + ); +} + +module.exports = { + convertToDraft, + isBotAuthor, + isDraft, +}; diff --git a/.github/scripts/revision-guard/helpers/index.js b/.github/scripts/revision-guard/helpers/index.js new file mode 100644 index 000000000..872b3acef --- /dev/null +++ b/.github/scripts/revision-guard/helpers/index.js @@ -0,0 +1,14 @@ +const { DEFAULT_MANAGED_LABELS, parseManagedLabels } = require('./constants'); +const { convertToDraft, isBotAuthor, isDraft } = require('./draft'); +const { getManagedLabels, getPresentManagedLabels, removeManagedLabels } = require('./labels'); + +module.exports = { + DEFAULT_MANAGED_LABELS, + parseManagedLabels, + convertToDraft, + isBotAuthor, + isDraft, + getManagedLabels, + getPresentManagedLabels, + removeManagedLabels, +}; diff --git a/.github/scripts/revision-guard/helpers/labels.js b/.github/scripts/revision-guard/helpers/labels.js new file mode 100644 index 000000000..6a8bd23e6 --- /dev/null +++ b/.github/scripts/revision-guard/helpers/labels.js @@ -0,0 +1,38 @@ +const { parseManagedLabels } = require('./constants'); + +function getManagedLabels() { + return parseManagedLabels(process.env.REVISION_GUARD_MANAGED_LABELS); +} + +function getPresentManagedLabels(prLabels, managedLabels = getManagedLabels()) { + const currentLabels = Array.isArray(prLabels) + ? prLabels + .map(label => typeof label === 'string' ? label : label?.name) + .filter(Boolean) + : []; + + return managedLabels.filter(label => currentLabels.includes(label)); +} + +async function removeManagedLabels(github, { owner, repo, issueNumber, labels }) { + for (const name of labels) { + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: issueNumber, + name, + }); + } catch (error) { + if (error?.status !== 404) { + throw error; + } + } + } +} + +module.exports = { + getManagedLabels, + getPresentManagedLabels, + removeManagedLabels, +}; diff --git a/.github/scripts/revision-guard/index.js b/.github/scripts/revision-guard/index.js new file mode 100644 index 000000000..94c1ad6e8 --- /dev/null +++ b/.github/scripts/revision-guard/index.js @@ -0,0 +1,69 @@ +const { + convertToDraft, + getPresentManagedLabels, + isBotAuthor, + isDraft, + removeManagedLabels, +} = require('./helpers'); + +module.exports = async function revisionGuard({ github, context, core }) { + const payload = context?.payload; + const repo = context?.repo; + const reviewState = payload?.review?.state; + const pr = payload?.pull_request; + + if ( + !payload || + !repo?.owner || + !repo?.repo || + !pr || + typeof pr.number !== 'number' || + !pr.node_id || + reviewState !== 'changes_requested' + ) { + core?.info?.('Skipping revision guard due to missing or non-matching payload data.'); + return; + } + + if (isBotAuthor(pr)) { + core?.info?.(`Skipping PR #${pr.number} because it is bot-authored.`); + return; + } + + if (isDraft(pr)) { + core?.info?.(`Skipping PR #${pr.number} because it is already a draft.`); + return; + } + + try { + await convertToDraft(github, pr.node_id); + core?.info?.(`Converted PR #${pr.number} to draft.`); + } catch (error) { + core?.error?.(`Failed to convert PR #${pr.number} to draft: ${error.message}`); + throw error; + } + + const labelsToRemove = getPresentManagedLabels(pr.labels); + if (labelsToRemove.length === 0) { + core?.info?.(`No managed labels to remove for PR #${pr.number}.`); + return; + } + + try { + await removeManagedLabels(github, { + owner: repo.owner, + repo: repo.repo, + issueNumber: pr.number, + labels: labelsToRemove, + }); + core?.info?.( + `Removed managed labels from PR #${pr.number}: ${labelsToRemove.join(', ')}.` + ); + } catch (error) { + core?.error?.( + `Failed to remove labels from PR #${pr.number}: ${error.message}. ` + + `Labels to remove: ${labelsToRemove.join(', ')}.` + ); + // Don't re-throw; draft conversion succeeded and is the primary goal + } +}; diff --git a/.github/scripts/revision-guard/index.test.js b/.github/scripts/revision-guard/index.test.js new file mode 100644 index 000000000..08e8d3dd6 --- /dev/null +++ b/.github/scripts/revision-guard/index.test.js @@ -0,0 +1,144 @@ +const { describe, it, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); + +function freshRequire() { + const indexPath = require.resolve('./index.js'); + const helpersPath = require.resolve('./helpers/index.js'); + const labelsPath = require.resolve('./helpers/labels.js'); + const constantsPath = require.resolve('./helpers/constants.js'); + + delete require.cache[indexPath]; + delete require.cache[helpersPath]; + delete require.cache[labelsPath]; + delete require.cache[constantsPath]; + + return require('./index.js'); +} + +function createGithubMock() { + const removedLabels = []; + + return { + removedLabels, + graphqlCalls: [], + graphql: async function (_query, variables) { + this.graphqlCalls.push(variables); + }, + rest: { + issues: { + removeLabel: async ({ name }) => { + removedLabels.push(name); + }, + }, + }, + }; +} + +function createContext(overrides = {}) { + return { + repo: { owner: 'hiero-ledger', repo: 'hiero-sdk-python' }, + payload: { + review: { state: 'changes_requested' }, + pull_request: { + number: 42, + node_id: 'PR_node_42', + draft: false, + user: { login: 'contributor', type: 'User' }, + labels: [ + { name: 'queue:committers' }, + { name: 'status: ready-to-merge' }, + { name: 'some other label' }, + ], + }, + ...overrides, + }, + }; +} + +describe('revision-guard index', () => { + beforeEach(() => { + delete process.env.REVISION_GUARD_MANAGED_LABELS; + }); + + afterEach(() => { + delete process.env.REVISION_GUARD_MANAGED_LABELS; + }); + + it('converts a ready PR to draft and removes only managed labels', async () => { + const handler = freshRequire(); + const github = createGithubMock(); + const context = createContext(); + + await handler({ github, context, core: { info() {} } }); + + assert.deepEqual(github.graphqlCalls, [{ pullRequestId: 'PR_node_42' }]); + assert.deepEqual(github.removedLabels, [ + 'queue:committers', + 'status: ready-to-merge', + ]); + }); + + it('skips bot-authored PRs', async () => { + const handler = freshRequire(); + const github = createGithubMock(); + const context = createContext({ + pull_request: { + number: 43, + node_id: 'PR_node_43', + draft: false, + user: { login: 'github-actions[bot]', type: 'Bot' }, + labels: [{ name: 'queue:committers' }], + }, + }); + + await handler({ github, context, core: { info() {} } }); + + assert.equal(github.graphqlCalls.length, 0); + assert.equal(github.removedLabels.length, 0); + }); + + it('skips already-draft PRs', async () => { + const handler = freshRequire(); + const github = createGithubMock(); + const context = createContext({ + pull_request: { + number: 44, + node_id: 'PR_node_44', + draft: true, + user: { login: 'contributor', type: 'User' }, + labels: [{ name: 'queue:committers' }], + }, + }); + + await handler({ github, context, core: { info() {} } }); + + assert.equal(github.graphqlCalls.length, 0); + assert.equal(github.removedLabels.length, 0); + }); + + it('uses configurable managed labels and still removes defaults', async () => { + process.env.REVISION_GUARD_MANAGED_LABELS = 'custom: one, custom: two'; + const handler = freshRequire(); + const github = createGithubMock(); + const context = createContext({ + pull_request: { + number: 45, + node_id: 'PR_node_45', + draft: false, + user: { login: 'contributor', type: 'User' }, + labels: [ + { name: 'custom: one' }, + { name: 'queue:committers' }, + { name: 'custom: two' }, + ], + }, + }); + + await handler({ github, context, core: { info() {} } }); + + // Draft conversion must also fire for configurable-label scenarios. + assert.deepEqual(github.graphqlCalls, [{ pullRequestId: 'PR_node_45' }]); + // Custom labels AND the matching default (queue:committers) must both be removed. + assert.deepEqual(github.removedLabels, ['queue:committers', 'custom: one', 'custom: two']); + }); +}); diff --git a/.github/workflows/revision-guard.yml b/.github/workflows/revision-guard.yml new file mode 100644 index 000000000..54966c024 --- /dev/null +++ b/.github/workflows/revision-guard.yml @@ -0,0 +1,42 @@ +name: Revision Guard - Review Events + +on: + pull_request_review: + types: [submitted] + +permissions: + contents: read + +jobs: + guard: + if: github.event.review.state == 'changes_requested' + runs-on: hl-sdk-py-lin-md + permissions: + pull-requests: write + issues: write + contents: read + concurrency: + group: revision-guard-review-${{ github.event.pull_request.number }} + cancel-in-progress: false + steps: + - name: Harden Runner + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # Use trusted base commit, not PR head/merge ref, to prevent fork PRs + # from executing untrusted changes with a write-scoped token. + ref: ${{ github.event.pull_request.base.sha }} + sparse-checkout: .github/scripts + + - name: Handle changes requested + env: + REVISION_GUARD_MANAGED_LABELS: ${{ vars.REVISION_GUARD_MANAGED_LABELS }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const handler = require('./.github/scripts/revision-guard/index.js'); + await handler({ github, context, core });