From 947c1f1a4d2ccd134cc1dea0001957e1b1863255 Mon Sep 17 00:00:00 2001 From: quality Date: Wed, 30 Sep 2026 02:35:28 -0400 Subject: [PATCH] test(bundle): drive /test and lgtm cancel (/lgtm cancel, /remove-lgtm) through dist/index.js Adds __tests__/bundle/triggerTestAndLgtmCancel.test.ts, a new file that reuses fakeGithub.ts and runBundle.ts to spawn the committed bundle for the two issue_comment paths the e2e suite never exercised: - /test: run table for no argument and ?, all, name and file matching, the no-match comment, 409 already-re-running, 403 actions: write refusal, non-member refusal, and the non-pull-request comment. - lgtm cancel: /lgtm cancel by a reviewer and /remove-lgtm by the author remove the label and reset prow/lgtm to pending; no label is a no-op; a non-reviewer non-author is refused. Closes #239 Signed-off-by: quality --- .../bundle/triggerTestAndLgtmCancel.test.ts | 343 ++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 __tests__/bundle/triggerTestAndLgtmCancel.test.ts diff --git a/__tests__/bundle/triggerTestAndLgtmCancel.test.ts b/__tests__/bundle/triggerTestAndLgtmCancel.test.ts new file mode 100644 index 0000000..d72918d --- /dev/null +++ b/__tests__/bundle/triggerTestAndLgtmCancel.test.ts @@ -0,0 +1,343 @@ +import type { FakeGithub } from './fakeGithub' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import issueCommentEvent from '../fixtures/issues/issueCommentEvent.json' +import pullReqOpenedEvent from '../fixtures/pullReq/pullReqOpenedEvent.json' +import { prCommentEvent, pullBody } from '../utils/ownersFixtures' +import { start } from './fakeGithub' +import { runBundle } from './runBundle' + +vi.setConfig({ testTimeout: 30_000 }) + +// the /test trigger command and the lgtm cancel spellings (/lgtm cancel, /remove-lgtm), +// driven through dist/index.js like /retest and /lgtm in bundle.test.ts +describe('dist/index.js /test and lgtm cancel', () => { + const repo = '/repos/Codertocat/Hello-World' + const token = { 'github-token': 'some-token' } + const commentPost = `POST ${repo}/issues/1/comments` + const rocket = `POST ${repo}/issues/comments/492700400/reactions` + const runsRead = `GET ${repo}/actions/runs?head_sha=headsha&per_page=100` + let gh: FakeGithub + + beforeAll(async () => { + gh = await start() + }) + beforeEach(() => gh.mergeQueueFallback({ pullRequestId: 'PR_none', headOid: pullReqOpenedEvent.pull_request.head.sha, enabled: false })) + afterEach(() => gh.reset()) + afterAll(() => gh.close()) + + function calls() { + return gh.requests.map(r => `${r.method} ${r.path}`) + } + + function comments() { + return gh.requestsMatching('POST', /\/issues\/1\/comments$/).map(r => (r.body as { body: string }).body) + } + + // the pull request, its files and an OWNERS-less base tree: the commenter is then authorized by membership + function routePullRequest(labels: string[] = []) { + gh.route('GET', `${repo}/pulls/1`, { status: 200, body: { ...pullBody, user: { login: 'some-author' }, requested_reviewers: [], assignees: [], labels: labels.map(name => ({ name })) } }) + gh.route('GET', `${repo}/pulls/1/files`, { status: 200, body: [{ filename: 'src/file1.txt', status: 'modified' }] }) + gh.route('GET', `${repo}/branches/master`, { status: 200, body: { name: 'master', commit: { sha: 'basesha' } } }) + gh.route('GET', `${repo}/git/trees/basesha`, { status: 200, body: { sha: 'basesha', truncated: false, tree: [] } }) + } + + const ownersReads = [ + `GET ${repo}/pulls/1`, + `GET ${repo}/pulls/1/files?per_page=100`, + `GET ${repo}/branches/master`, + `GET ${repo}/git/trees/basesha?recursive=true`, + ] + + function routeMember(login: string, member: boolean) { + gh.route('GET', `/orgs/Codertocat/members/${login}`, member ? { status: 204 } : { status: 404, body: { message: 'Not Found' } }) + gh.route('GET', `${repo}/collaborators/${login}`, { status: 404, body: { message: 'Not Found' } }) + } + + function membershipReads(login: string) { + return [`GET /orgs/Codertocat/members/${login}`, `GET ${repo}/collaborators/${login}`] + } + + // the OWNERS-less authorization of a reviewer: owners reads, then org membership and collaborator (unordered among themselves) + function expectAuthorized(rest: string[], login = 'Codertocat') { + const reads = [...ownersReads, ...membershipReads(login)] + const recorded = calls() + expect(recorded.slice(0, reads.length).sort()).toEqual([...reads].sort()) + expect(recorded.slice(reads.length)).toEqual(rest) + } + + describe('/test', () => { + // the Prow run carries the name of the workflow this command runs in, so it is never listed or re-run + const runsOnHead = { + total_count: 4, + workflow_runs: [ + { id: 1, name: 'CI', path: '.github/workflows/ci.yml', head_sha: 'headsha', status: 'completed', conclusion: 'failure' }, + { id: 2, name: 'Lint', path: '.github/workflows/lint.yml', head_sha: 'headsha', status: 'completed', conclusion: 'success' }, + { id: 3, name: 'Prow', path: '.github/workflows/prow.yml', head_sha: 'headsha', status: 'in_progress', conclusion: null }, + { id: 4, name: 'E2E', path: '.github/workflows/e2e.yml', head_sha: 'headsha', status: 'in_progress', conclusion: null }, + ], + } + + function routeTest() { + routePullRequest() + routeMember('Codertocat', true) + gh.route('GET', `${repo}/actions/runs`, { status: 200, body: runsOnHead }) + gh.route('POST', /\/actions\/runs\/\d+\/rerun$/, { status: 201 }) + gh.route('POST', `${repo}/issues/1/comments`, { status: 201, body: {} }) + gh.route('POST', `${repo}/issues/comments/492700400/reactions`, { status: 201, body: { content: 'rocket' } }) + } + + async function runTest(body: string, commenter?: string) { + return runBundle({ + eventName: 'issue_comment', + payload: prCommentEvent(body, commenter), + inputs: { ...token, 'prow-commands': '/test' }, + apiUrl: gh.url, + env: { GITHUB_WORKFLOW: 'Prow' }, + }) + } + + const table = [ + 'Workflow runs on `headsha`:', + '', + 'workflow | status | conclusion', + '--- | --- | ---', + '`CI` | completed | failure', + '`Lint` | completed | success', + '`E2E` | in_progress | ', + ].join('\n') + + it('with no argument lists the runs on the head, minus the current workflow, and re-runs nothing', async () => { + routeTest() + + const result = await runTest('/test') + + expect(result.status, result.stdout).toBe(0) + expect(result.errors).toEqual([]) + expectAuthorized([runsRead, commentPost]) + expect(comments()).toEqual([table]) + }) + + it('? lists the runs the same way', async () => { + routeTest() + + const result = await runTest('/test ?') + + expect(result.status, result.stdout).toBe(0) + expect(result.errors).toEqual([]) + expectAuthorized([runsRead, commentPost]) + expect(comments()).toEqual([table]) + }) + + it('all re-runs every completed run, in progress ones left alone, then reacts with a rocket; no sweep follows', async () => { + routeTest() + + const result = await runTest('/test all') + + expect(result.status, result.stdout).toBe(0) + expect(result.errors).toEqual([]) + expectAuthorized([runsRead, `POST ${repo}/actions/runs/1/rerun`, `POST ${repo}/actions/runs/2/rerun`, rocket]) + expect(gh.requestsMatching('POST', /\/reactions$/)[0].body).toEqual({ content: 'rocket' }) + expect(comments()).toEqual([]) + }) + + it(' re-runs the completed run whose workflow name matches, case-insensitively', async () => { + routeTest() + + const result = await runTest('/test LINT') + + expect(result.status, result.stdout).toBe(0) + expect(result.errors).toEqual([]) + expectAuthorized([runsRead, `POST ${repo}/actions/runs/2/rerun`, rocket]) + }) + + it(' re-runs the completed run whose workflow file matches, with or without the extension', async () => { + routeTest() + + const result = await runTest('/test ci') + + expect(result.status, result.stdout).toBe(0) + expect(result.errors).toEqual([]) + expectAuthorized([runsRead, `POST ${repo}/actions/runs/1/rerun`, rocket]) + }) + + it(' matching only an in-progress run comments the table of runs and re-runs nothing', async () => { + routeTest() + + const result = await runTest('/test e2e') + + expect(result.status, result.stdout).toBe(0) + expect(result.errors).toEqual([]) + expectAuthorized([runsRead, commentPost]) + expect(comments()).toEqual([`No completed GitHub Actions workflow run on \`headsha\` matches \`e2e\`.\n\n${table}`]) + }) + + it('all when every re-run answers 409 comments that the runs are already re-running, without a rocket', async () => { + routeTest() + gh.reset() + routePullRequest() + routeMember('Codertocat', true) + gh.route('GET', `${repo}/actions/runs`, { status: 200, body: runsOnHead }) + gh.route('POST', /\/actions\/runs\/\d+\/rerun$/, { status: 409, body: { message: 'This workflow is already running' } }) + gh.route('POST', `${repo}/issues/1/comments`, { status: 201, body: {} }) + + const result = await runTest('/test all') + + expect(result.status, result.stdout).toBe(0) + expect(result.errors).toEqual([]) + expectAuthorized([runsRead, `POST ${repo}/actions/runs/1/rerun`, `POST ${repo}/actions/runs/2/rerun`, commentPost]) + expect(comments()).toEqual(['The GitHub Actions workflow runs on `headsha` are already being re-run.']) + }) + + it('all when the re-run is forbidden names the permission to grant, comments it and fails the run', async () => { + routePullRequest() + routeMember('Codertocat', true) + gh.route('GET', `${repo}/actions/runs`, { status: 200, body: runsOnHead }) + gh.route('POST', /\/actions\/runs\/\d+\/rerun$/, { status: 403, body: { message: 'Resource not accessible by integration' } }) + gh.route('POST', `${repo}/issues/1/comments`, { status: 201, body: {} }) + + const result = await runTest('/test all') + + expect(result.status, result.stdout).toBe(1) + // the refusal is logged as an error, then the run fails with it + expect(result.errors).toEqual([ + 'cannot re-run workflows: grant `actions: write` to the workflow', + 'TypeError: error handling issue comment: Error: cannot re-run workflows: grant `actions: write` to the workflow', + ]) + expectAuthorized([runsRead, `POST ${repo}/actions/runs/1/rerun`, commentPost]) + expect(comments()).toEqual(['cannot re-run workflows: grant `actions: write` to the workflow']) + }) + + it('by a commenter who is neither owner, member nor collaborator is refused with a comment and fails the run', async () => { + routePullRequest() + routeMember('stranger', false) + gh.route('POST', `${repo}/issues/1/comments`, { status: 201, body: {} }) + + const result = await runTest('/test all', 'stranger') + + expect(result.status, result.stdout).toBe(1) + expect(result.errors).toEqual([ + 'Cannot /test because Error: stranger is not a org member or collaborator', + 'TypeError: error handling issue comment: Error: stranger is not a org member or collaborator', + ]) + expectAuthorized([commentPost], 'stranger') + expect(comments()).toEqual(['Cannot /test because Error: stranger is not a org member or collaborator']) + expect(gh.requestsMatching('GET', /\/actions\/runs/)).toEqual([]) + }) + + it('on an issue that is not a pull request comments that it only applies to pull requests, reading nothing', async () => { + gh.route('POST', `${repo}/issues/1/comments`, { status: 201, body: {} }) + + const result = await runBundle({ + eventName: 'issue_comment', + payload: { ...structuredClone(issueCommentEvent), comment: { ...issueCommentEvent.comment, body: '/test all' } }, + inputs: { ...token, 'prow-commands': '/test' }, + apiUrl: gh.url, + }) + + expect(result.status, result.stdout).toBe(0) + expect(result.errors).toEqual([]) + expect(calls()).toEqual([commentPost]) + expect(comments()).toEqual(['`/test` only applies to pull requests.']) + }) + }) + + describe('lgtm cancel', () => { + const labelsRead = `GET ${repo}/issues/1` + const unlabel = `DELETE ${repo}/issues/1/labels/lgtm` + const unbind = `POST ${repo}/statuses/headsha` + const pullRead = `GET ${repo}/pulls/1` + // tide's sweep: the pr (now without lgtm), whether its base has OWNERS files, and the merge queue state + const sweep = [pullRead, `GET ${repo}/git/trees/master?recursive=true`, 'POST /graphql'] + const configReads = [ + 'GET /repos/Codertocat/.project/contents/prow.yaml', + 'GET /repos/Codertocat/.github/contents/prow.yaml', + ...['.github/prow.yaml', '.github/prowlabels.yaml', 'prow.yaml', '.prowlabels.yaml', '.github/prow.yml', '.github/prowlabels.yml', 'prow.yml', '.prowlabels.yml'] + .map(path => `GET ${repo}/contents/${encodeURIComponent(path)}`), + ] + + // the issue read answers with `labels`; the pull request read (before and after the removal) never carries lgtm, + // as tide's sweep would see it once the label is gone + function routeCancel(labels: string[]) { + routePullRequest() + gh.route('GET', `${repo}/issues/1`, { status: 200, body: { labels: labels.map(name => ({ name })) } }) + gh.route('DELETE', `${repo}/issues/1/labels/lgtm`, { status: 200, body: [] }) + gh.route('POST', `${repo}/statuses/headsha`, { status: 201, body: {} }) + } + + async function runCancel(body: string, commenter?: string) { + return runBundle({ + eventName: 'issue_comment', + payload: prCommentEvent(body, commenter), + inputs: { ...token, 'prow-commands': '/lgtm' }, + apiUrl: gh.url, + }) + } + + // after the command: the configuration reads (unordered) for lgtm.bind_to_commit, then a fixed tail + function expectCommandThenConfig(command: string[], tail: string[], commandReads: string[]) { + const recorded = calls() + expect(recorded.slice(0, commandReads.length).sort()).toEqual([...commandReads].sort()) + const afterAuth = recorded.slice(commandReads.length) + expect(afterAuth.slice(0, command.length)).toEqual(command) + expect(afterAuth.slice(command.length, command.length + configReads.length).sort()).toEqual([...configReads].sort()) + expect(afterAuth.slice(command.length + configReads.length)).toEqual(tail) + } + + it('/lgtm cancel by a reviewer on an lgtm\'d pr: removes the label, voids the binding with a pending status, and the sweep skips the pr', async () => { + routeCancel(['lgtm']) + routeMember('Codertocat', true) + + const result = await runCancel('/lgtm cancel') + + expect(result.status, result.stdout).toBe(0) + expect(result.errors).toEqual([]) + expect(result.stdout).toContain('skipping pr #1: missing lgtm') + expect(gh.requestsMatching('POST', /\/statuses\/headsha$/)[0].body).toEqual({ state: 'pending', context: 'prow/lgtm', description: 'lgtm cancelled by Codertocat' }) + expect(gh.requestsMatching('PUT', /./)).toEqual([]) + // the reviewer is authorized (owners reads, membership), the label read and removed, the configuration read for the + // binding setting, the head (already read) unbound, then the sweep + expectCommandThenConfig([labelsRead, unlabel], [unbind, ...sweep], [...ownersReads, ...membershipReads('Codertocat')]) + }) + + it('/remove-lgtm by the pr author needs no membership: removes the label and voids the binding', async () => { + routeCancel(['lgtm']) + + const result = await runCancel('/remove-lgtm', 'some-author') + + expect(result.status, result.stdout).toBe(0) + expect(result.errors).toEqual([]) + expect(result.stdout).toContain('skipping pr #1: missing lgtm') + expect(gh.requestsMatching('POST', /\/statuses\/headsha$/)[0].body).toEqual({ state: 'pending', context: 'prow/lgtm', description: 'lgtm cancelled by some-author' }) + expect(gh.requestsMatching('GET', /\/orgs\/|\/collaborators\//)).toEqual([]) + // no authorization read: the label read and removed, the configuration read, then the pr and its OWNERS context read + // for the head (the same reads the authorization would have made), the head unbound, then the sweep + expectCommandThenConfig([labelsRead, unlabel], [...ownersReads, unbind, ...sweep], []) + }) + + it('/lgtm cancel on a pr without the label removes nothing and writes no status', async () => { + routeCancel([]) + routeMember('Codertocat', true) + + const result = await runCancel('/lgtm cancel') + + expect(result.status, result.stdout).toBe(0) + expect(result.errors).toEqual([]) + expect(gh.requestsMatching('DELETE', /./)).toEqual([]) + expect(gh.requestsMatching('POST', /\/statuses\//)).toEqual([]) + }) + + it('/lgtm cancel by a commenter who is neither author nor reviewer is refused and removes nothing', async () => { + routeCancel(['lgtm']) + routeMember('stranger', false) + gh.route('POST', `${repo}/issues/1/comments`, { status: 201, body: {} }) + + const result = await runCancel('/lgtm cancel', 'stranger') + + expect(result.status, result.stdout).toBe(1) + expect(result.errors.some(e => /stranger is not a org member or collaborator/.test(e))).toBe(true) + expect(gh.requestsMatching('DELETE', /./)).toEqual([]) + expect(gh.requestsMatching('POST', /\/statuses\//)).toEqual([]) + }) + }) +})