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..016ba2860 --- /dev/null +++ b/.github/scripts/revision-guard/helpers/draft.js @@ -0,0 +1,30 @@ +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 ConvertPullRequestToDraft($pullRequestId: ID!) { + convertPullRequestToDraft(input: { pullRequestId: $pullRequestId }) { + pullRequest { + id + 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..2be4b3052 --- /dev/null +++ b/.github/scripts/revision-guard/index.js @@ -0,0 +1,78 @@ +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' || + 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; + } + + const labelsToRemove = getPresentManagedLabels(pr.labels); + + if (!isDraft(pr)) { + if (process.env.REVIEWBOT_TOKEN && typeof pr.node_id === 'string') { + try { + await convertToDraft(github, { + pullRequestId: 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}. ` + + 'Continuing with managed label cleanup.' + ); + } + } else { + core?.info?.( + `Skipping draft conversion for PR #${pr.number}; REVIEWBOT_TOKEN is not configured.` + ); + } + } else { + core?.info?.(`PR #${pr.number} is already a draft.`); + } + + 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(', ')}.` + ); + throw error; + } +}; diff --git a/.github/scripts/revision-guard/index.test.js b/.github/scripts/revision-guard/index.test.js new file mode 100644 index 000000000..75e33bf92 --- /dev/null +++ b/.github/scripts/revision-guard/index.test.js @@ -0,0 +1,196 @@ +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'); + const draftPath = require.resolve('./helpers/draft.js'); + + delete require.cache[indexPath]; + delete require.cache[helpersPath]; + delete require.cache[labelsPath]; + delete require.cache[constantsPath]; + delete require.cache[draftPath]; + + return require('./index.js'); +} + +function createGithubMock() { + const removedLabels = []; + const graphqlCalls = []; + let graphqlError; + + return { + removedLabels, + graphqlCalls, + failGraphql(error) { + graphqlError = error; + }, + graphql: async (query, variables) => { + graphqlCalls.push({ query, variables }); + if (graphqlError) { + throw graphqlError; + } + return { + convertPullRequestToDraft: { + pullRequest: { + id: variables.pullRequestId, + isDraft: true, + }, + }, + }; + }, + 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; + delete process.env.REVIEWBOT_TOKEN; + }); + + afterEach(() => { + delete process.env.REVISION_GUARD_MANAGED_LABELS; + delete process.env.REVIEWBOT_TOKEN; + }); + + it('removes only managed labels without draft conversion when no review bot token is configured', async () => { + const handler = freshRequire(); + const github = createGithubMock(); + const context = createContext(); + + await handler({ github, context, core: { info() {} } }); + + assert.equal(github.graphqlCalls.length, 0); + 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('removes managed labels from already-draft PRs without converting again', 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.deepEqual(github.removedLabels, ['queue:committers']); + }); + + it('converts to draft when a review bot token is configured', async () => { + process.env.REVIEWBOT_TOKEN = 'token'; + const handler = freshRequire(); + const github = createGithubMock(); + const context = createContext(); + + await handler({ github, context, core: { info() {} } }); + + assert.equal(github.graphqlCalls.length, 1); + assert.match(github.graphqlCalls[0].query, /convertPullRequestToDraft/); + assert.deepEqual(github.graphqlCalls[0].variables, { pullRequestId: 'PR_node_42' }); + assert.deepEqual(github.removedLabels, [ + 'queue:committers', + 'status: ready-to-merge', + ]); + }); + + it('still removes managed labels when draft conversion fails', async () => { + process.env.REVIEWBOT_TOKEN = 'token'; + const handler = freshRequire(); + const github = createGithubMock(); + github.failGraphql(new Error('Resource not accessible by integration')); + const context = createContext(); + + await handler({ github, context, core: { error() {}, info() {} } }); + + assert.equal(github.graphqlCalls.length, 1); + assert.deepEqual(github.removedLabels, [ + 'queue:committers', + 'status: ready-to-merge', + ]); + }); + + 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() {} } }); + + assert.equal(github.graphqlCalls.length, 0); + // 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/scripts/revision-guard/package.json b/.github/scripts/revision-guard/package.json new file mode 100644 index 000000000..9328210f0 --- /dev/null +++ b/.github/scripts/revision-guard/package.json @@ -0,0 +1,10 @@ +{ + "name": "revision-guard", + "version": "1.0.0", + "description": "Revision guard automation scripts and tests", + "private": true, + "main": "index.js", + "scripts": { + "test": "node --test index.test.js" + } +} diff --git a/.github/workflows/revision-guard.yml b/.github/workflows/revision-guard.yml new file mode 100644 index 000000000..8ae3a9e77 --- /dev/null +++ b/.github/workflows/revision-guard.yml @@ -0,0 +1,44 @@ +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 }} + REVIEWBOT_TOKEN: ${{ secrets.REVIEWBOT_TOKEN || '' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ secrets.REVIEWBOT_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const handler = require('./.github/scripts/revision-guard/index.js'); + await handler({ github, context, core }); diff --git a/.github/workflows/test-revision-guard.yml b/.github/workflows/test-revision-guard.yml new file mode 100644 index 000000000..52ce3690a --- /dev/null +++ b/.github/workflows/test-revision-guard.yml @@ -0,0 +1,38 @@ +name: Test Revision Guard + +on: + push: + paths: + - '.github/scripts/revision-guard/**' + - '.github/workflows/test-revision-guard.yml' + pull_request: + paths: + - '.github/scripts/revision-guard/**' + - '.github/workflows/test-revision-guard.yml' + +permissions: + contents: read + +jobs: + test: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false + runs-on: hl-sdk-py-lin-md + permissions: + contents: read + steps: + - name: Harden the runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '20' + + - name: Run Tests + working-directory: .github/scripts/revision-guard + run: npm test