diff --git a/__tests__/cronJobTest/lgtmShortCircuits.test.ts b/__tests__/cronJobTest/lgtmShortCircuits.test.ts new file mode 100644 index 00000000..bcff3818 --- /dev/null +++ b/__tests__/cronJobTest/lgtmShortCircuits.test.ts @@ -0,0 +1,176 @@ +import { Buffer } from 'node:buffer' +import { http } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' + +import { cronLgtm } from '../../src/cronJobs/lgtm' +import labelFileContents from '../fixtures/labels/labelFileContentsResp.json' +import listPullReqs from '../fixtures/pullReq/pullReqListPulls.json' +import pullReqOpenedEvent from '../fixtures/pullReq/pullReqOpenedEvent.json' +import * as utils from '../testUtils' + +// cronLgtm's contracts that no merge request can show: the count it resolves +// to, and that a PR it skips before the shared merge path costs no further +// request. Both are only visible by calling cronLgtm directly and by watching +// the per-PR read that evaluateMerge would otherwise make. + +const repo = `${utils.api}/repos/Codertocat/Hello-World` + +function prowYaml(text: string) { + const file = structuredClone(labelFileContents) + file.content = Buffer.from(text).toString('base64') + return http.get(utils.contentsUrl('.github/prow.yaml'), utils.mockResponse(200, file)) +} + +function lgtmPr(number: number, ...extraLabels: string[]) { + const pr = structuredClone(listPullReqs[0]) + pr.number = number + pr.labels = ['lgtm', ...extraLabels].map(name => ({ ...pr.labels[0], name })) + return pr +} + +function withoutLgtm(number: number) { + const pr = lgtmPr(number) + pr.labels = [{ ...pr.labels[0], name: 'approved' }] + return pr +} + +const server = setupServer( + ...utils.noOrgOrRepoConfigExcept(), + utils.defaultBranchTree(), + utils.lgtmStatus(), + // no merge queue on the base branch: a merged PR is a direct merge + utils.mergeQueueGraphql({ enabled: false }).handler, +) +beforeAll(() => server.listen(utils.failOnUnhandledRequest)) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +/** serves the listing page by page, an empty page after the last, and each listed PR as clean and mergeable */ +function routePages(pages: ReturnType[][], listing?: utils.ObserveRequest) { + const all = pages.flat() + server.use( + http.get(`${repo}/pulls`, ({ request }) => { + if (listing) + listing.ref = request + const page = Number(new URL(request.url).searchParams.get('page')) + return utils.mockResponse(200, pages[page - 1] ?? [])({ request }) + }), + http.get(`${repo}/pulls/:number`, ({ params }) => { + const pr = all.find(item => item.number === Number(params.number)) + return pr === undefined + ? new Response(JSON.stringify({ message: 'Not Found' }), { status: 404, headers: { 'Content-Type': 'application/json' } }) + : new Response(JSON.stringify({ ...pr, mergeable: true, mergeable_state: 'clean' }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + }), + ) +} + +function acceptMerge(number: number) { + server.use(http.put(`${repo}/pulls/${number}/merge`, utils.mockResponse(200, { merged: true }))) +} + +function observeRead(number: number) { + const read = new utils.ObserveRequest() + const merge = new utils.ObserveRequest() + server.use( + http.get(`${repo}/pulls/${number}`, utils.mockResponse(200, lgtmPr(number), read)), + http.put(`${repo}/pulls/${number}/merge`, utils.mockResponse(200, null, merge)), + ) + return { read, merge } +} + +describe('cronLgtm', () => { + it('resolves to the number of pull requests it merged across every page, not the number it saw', async () => { + utils.setupJobsEnv('lgtm') + const context = new utils.MockContext(pullReqOpenedEvent) + routePages([ + [lgtmPr(11), withoutLgtm(12)], + [lgtmPr(13)], + ]) + acceptMerge(11) + acceptMerge(13) + + await expect(cronLgtm(1, context)).resolves.toBe(2) + }) + + it('resolves to zero when every listed pull request is skipped', async () => { + utils.setupJobsEnv('lgtm') + const context = new utils.MockContext(pullReqOpenedEvent) + routePages([[withoutLgtm(14), lgtmPr(15, 'do-not-merge/hold')]]) + + await expect(cronLgtm(1, context)).resolves.toBe(0) + }) + + it('carries the count of earlier pages into the pages that follow', async () => { + utils.setupJobsEnv('lgtm') + const context = new utils.MockContext(pullReqOpenedEvent) + routePages([[], [lgtmPr(16)]]) + acceptMerge(16) + + await expect(cronLgtm(2, context, { jobsDone: 3, failures: [] })).resolves.toBe(4) + }) + + it('lists only open pull requests', async () => { + utils.setupJobsEnv('lgtm') + const context = new utils.MockContext(pullReqOpenedEvent) + const listing = new utils.ObserveRequest() + routePages([], listing) + + await expect(cronLgtm(1, context)).resolves.toBe(0) + expect(listing.ref).not.toBeNull() + expect(new URL(listing.ref!.url).searchParams.get('state')).toBe('open') + }) + + it.each([ + ['missing lgtm', withoutLgtm(17)], + ['blocked by a do-not-merge label', lgtmPr(18, 'do-not-merge/work-in-progress')], + ])('does not read a pull request that fails the merge gate (%s)', async (_reason, pr) => { + utils.setupJobsEnv('lgtm') + const context = new utils.MockContext(pullReqOpenedEvent) + routePages([[pr]]) + const { read, merge } = observeRead(pr.number) + + await expect(cronLgtm(1, context)).resolves.toBe(0) + await expect(read.notCalled()).resolves.toBe('not called') + await expect(merge.notCalled()).resolves.toBe('not called') + }) + + it('does not read a locked pull request even when it passes the merge gate', async () => { + utils.setupJobsEnv('lgtm') + const context = new utils.MockContext(pullReqOpenedEvent) + const locked = lgtmPr(19) + locked.locked = true + routePages([[locked]]) + const { read, merge } = observeRead(19) + + await expect(cronLgtm(1, context)).resolves.toBe(0) + await expect(read.notCalled()).resolves.toBe('not called') + await expect(merge.notCalled()).resolves.toBe('not called') + }) + + it('honours the merge gate of the prow configuration before reading a pull request', async () => { + utils.setupJobsEnv('lgtm') + const context = new utils.MockContext(pullReqOpenedEvent) + server.use(prowYaml(` +tide: + labels: [lgtm, approved] +`)) + routePages([[lgtmPr(20)]]) + const { read, merge } = observeRead(20) + + await expect(cronLgtm(1, context)).resolves.toBe(0) + await expect(read.notCalled()).resolves.toBe('not called') + await expect(merge.notCalled()).resolves.toBe('not called') + }) + + it('requires the github-token input before listing anything', async () => { + utils.setupJobsEnv('lgtm') + delete process.env['INPUT_GITHUB-TOKEN'] + const context = new utils.MockContext(pullReqOpenedEvent) + const listing = new utils.ObserveRequest() + routePages([], listing) + + await expect(cronLgtm(1, context)).rejects.toThrow('Input required and not supplied: github-token') + expect(listing.ref).toBeNull() + }) +}) diff --git a/__tests__/issueCommentTest/milestoneAvailableList.test.ts b/__tests__/issueCommentTest/milestoneAvailableList.test.ts new file mode 100644 index 00000000..921dc4e6 --- /dev/null +++ b/__tests__/issueCommentTest/milestoneAvailableList.test.ts @@ -0,0 +1,45 @@ +import * as core from '@actions/core' +import { http } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import { handleIssueComment } from '../../src/issueComment/handleIssueComment' +import issueCommentEvent from '../fixtures/issues/issueCommentEvent.json' +import * as utils from '../testUtils' + +const server = setupServer() +beforeAll(() => server.listen(utils.failOnUnhandledRequest)) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +const repo = `${utils.api}/repos/Codertocat/Hello-World` + +describe('/milestone: the not-found error lists every open milestone', () => { + beforeEach(() => { + utils.setupActionsEnv('/milestone') + }) + + it('separates the available titles with a comma and a space, in the order the API returned them', async () => { + issueCommentEvent.comment.body = '/milestone v3' + + const observeReq = new utils.ObserveRequest() + server.use( + http.get(`${repo}/milestones`, utils.mockResponse(200, [ + { number: 1, title: 'v1' }, + { number: 2, title: 'v2' }, + { number: 3, title: 'backlog' }, + ])), + http.get(`${repo}/collaborators/Codertocat`, utils.mockResponse(204)), + http.patch(`${repo}/issues/1`, utils.mockResponse(200, null, observeReq)), + ) + + const setFailed = vi.spyOn(core, 'setFailed').mockImplementation(() => {}) + await handleIssueComment(new utils.MockContext(issueCommentEvent)) + + await expect(observeReq.notCalled()).resolves.toBe('not called') + expect(setFailed).toHaveBeenCalledTimes(1) + expect(setFailed).toHaveBeenCalledWith( + expect.stringContaining('milestone "v3" not found. Available milestones: v1, v2, backlog'), + ) + }) +}) diff --git a/__tests__/issueCommentTest/triggerGuards.test.ts b/__tests__/issueCommentTest/triggerGuards.test.ts new file mode 100644 index 00000000..e0350e83 --- /dev/null +++ b/__tests__/issueCommentTest/triggerGuards.test.ts @@ -0,0 +1,383 @@ +import type { HttpHandler } from 'msw' +import process from 'node:process' +import * as core from '@actions/core' +import { http } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import { handleIssueComment } from '../../src/issueComment/handleIssueComment' +import { approvePendingRuns, okToTestOnPullRequest, retest } from '../../src/issueComment/trigger' +import { newOctokit } from '../../src/utils/octokit' +import prOpenedEvent from '../fixtures/pullReq/pullReqOpenedEvent.json' +import * as utils from '../testUtils' +import { prCommentEvent, prHandlers, repo } from '../utils/ownersFixtures' + +// Guards of src/issueComment/trigger.ts that line coverage reaches but no +// other test would notice changing: each case here fails when the guard it +// names is removed or inverted. + +const server = setupServer() +beforeAll(() => server.listen(utils.failOnUnhandledRequest)) +afterEach(() => { + server.resetHandlers() + server.events.removeAllListeners() +}) +afterAll(() => server.close()) + +const headSha = 'headsha' +const reactionUrl = `${repo}/issues/comments/492700400/reactions` + +interface RunSpec { + id: number + name: string | null + path?: string + status: string + conclusion?: string | null +} + +function serveRuns(specs: RunSpec[]): HttpHandler { + const runs = specs.map(spec => ({ + id: spec.id, + name: spec.name, + path: spec.path ?? `.github/workflows/${(spec.name ?? 'run').toLowerCase().replace(/\s+/g, '-')}.yml`, + head_sha: headSha, + status: spec.status, + conclusion: spec.conclusion ?? null, + })) + return http.get(`${repo}/actions/runs`, utils.mockResponse(200, { total_count: runs.length, workflow_runs: runs })) +} + +// an org member with no OWNERS files in the repository +function memberAuth(login = 'Codertocat'): HttpHandler[] { + return [ + http.get(`${utils.api}/orgs/Codertocat/members/${login}`, utils.mockResponse(204)), + http.get(`${repo}/collaborators/${login}`, utils.mockResponse(404)), + ...prHandlers({}, ['src/file1.txt']), + ] +} + +function outsiderAuth(login = 'Codertocat'): HttpHandler[] { + return [ + http.get(`${utils.api}/orgs/Codertocat/members/${login}`, utils.mockResponse(404)), + http.get(`${repo}/collaborators/${login}`, utils.mockResponse(404)), + ...prHandlers({}, ['src/file1.txt']), + ] +} + +function recordRerun(reruns: number[], route: string): HttpHandler { + return http.post(`${repo}/actions/runs/:id/${route}`, ({ params }) => { + reruns.push(Number(params.id)) + return new Response(null, { status: 201 }) + }) +} + +let calls: string[] +beforeEach(() => { + calls = [] + server.events.on('request:start', ({ request }) => { + calls.push(`${request.method} ${new URL(request.url).pathname}`) + }) +}) + +function setup(command: string) { + utils.setupActionsEnv(command) + process.env.GITHUB_WORKFLOW = 'Prow' +} + +const failedRun: RunSpec = { id: 1, name: 'CI', status: 'completed', conclusion: 'failure' } + +describe('/retest', () => { + beforeEach(() => setup('/retest')) + + // react: `if (commentId === undefined) return` — without it the reaction is + // attempted against a comment id of undefined and only its failure is caught + it('a comment without an id attempts no reaction request and logs no warning', async () => { + const event = prCommentEvent('/retest') as { comment: { id?: number } } + delete event.comment.id + const reruns: number[] = [] + server.use(...memberAuth(), serveRuns([failedRun]), recordRerun(reruns, 'rerun-failed-jobs')) + const warning = vi.spyOn(core, 'warning').mockImplementation(() => {}) + const setFailed = vi.spyOn(core, 'setFailed').mockImplementation(() => {}) + + await handleIssueComment(new utils.MockContext(event as never)) + + expect(reruns).toEqual([1]) + expect(calls.filter(c => c.includes('/reactions'))).toEqual([]) + expect(warning).not.toHaveBeenCalled() + expect(setFailed).not.toHaveBeenCalled() + }) + + // refuse: the message is logged as an error annotation, not only commented + it('a refusal is logged as an error annotation with the full message', async () => { + server.use( + ...outsiderAuth(), + http.post(`${repo}/issues/1/comments`, utils.mockResponse(201, {})), + ...utils.noOrgOrRepoConfigExcept(), + ) + vi.spyOn(core, 'setFailed').mockImplementation(() => {}) + const error = vi.spyOn(core, 'error').mockImplementation(() => {}) + + await handleIssueComment(new utils.MockContext(prCommentEvent('/retest'))) + + expect(error).toHaveBeenCalledWith('Cannot /retest because Error: Codertocat is not a org member or collaborator') + }) + + // prepare: `context.payload.comment?.user?.login` — a comment whose user is + // gone is refused through the normal path rather than crashing on `.login` + it('a comment whose user is gone is refused with a comment and makes no Actions call', async () => { + const event = prCommentEvent('/retest') as { comment: { user: unknown } } + event.comment.user = null + const reply = new utils.ObserveRequest() + server.use( + // octokit drops the undefined login from both membership probes' paths + http.get(`${utils.api}/orgs/Codertocat/members`, utils.mockResponse(404)), + http.get(`${repo}/collaborators`, utils.mockResponse(404)), + ...prHandlers({}, ['src/file1.txt']), + http.post(`${repo}/issues/1/comments`, utils.mockResponse(201, {}, reply)), + ...utils.noOrgOrRepoConfigExcept(), + ) + const setFailed = vi.spyOn(core, 'setFailed').mockImplementation(() => {}) + vi.spyOn(core, 'error').mockImplementation(() => {}) + + await handleIssueComment(new utils.MockContext(event as never)) + + await expect(reply.called()).resolves.toBe('called') + expect((await reply.body()).body).toBe('Cannot /retest because Error: undefined is not a org member or collaborator') + expect(setFailed).toHaveBeenCalledWith(expect.stringContaining('is not a org member or collaborator')) + expect(setFailed).not.toHaveBeenCalledWith(expect.stringContaining('Cannot read properties')) + expect(calls.some(c => c.includes('/actions/runs'))).toBe(false) + }) + + // prepare: `core.getInput('github-token', { required: true })` fails fast + // instead of issuing unauthenticated requests with an empty token + it('fails before any request when the github-token input is missing', async () => { + delete process.env['INPUT_GITHUB-TOKEN'] + + await expect(retest(new utils.MockContext(prCommentEvent('/retest')))).rejects.toThrow('Input required and not supplied: github-token') + expect(calls).toEqual([]) + }) +}) + +describe('/test', () => { + beforeEach(() => setup('/test')) + + // `args.join(' ')`: the no-match comment echoes the arguments as typed + it('the no-match comment echoes several arguments separated by spaces', async () => { + const reply = new utils.ObserveRequest() + server.use( + ...memberAuth(), + serveRuns([failedRun]), + http.post(`${repo}/issues/1/comments`, utils.mockResponse(201, {}, reply)), + ) + + await handleIssueComment(new utils.MockContext(prCommentEvent('/test nope nah'))) + + await expect(reply.called()).resolves.toBe('called') + expect((await reply.body()).body).toContain('matches `nope nah`.') + }) + + // matchesWorkflow: `${arg}.yaml` is one of the file candidates + it('matches a workflow stored as .yaml by its basename', async () => { + const reruns: number[] = [] + server.use( + ...memberAuth(), + serveRuns([{ id: 7, name: 'Continuous Integration', path: '.github/workflows/ci.yaml', status: 'completed', conclusion: 'success' }]), + recordRerun(reruns, 'rerun'), + http.post(reactionUrl, utils.mockResponse(201, {})), + ) + + await handleIssueComment(new utils.MockContext(prCommentEvent('/test ci'))) + + expect(reruns).toEqual([7]) + }) + + // matchesWorkflow: `path === file` accepts the full workflow path, which no + // `endsWith('/…')` candidate would + it('matches a workflow by its full path', async () => { + const reruns: number[] = [] + server.use( + ...memberAuth(), + serveRuns([{ id: 7, name: 'Build and Test', path: '.github/workflows/build.yml', status: 'completed', conclusion: 'success' }]), + recordRerun(reruns, 'rerun'), + http.post(reactionUrl, utils.mockResponse(201, {})), + ) + + await handleIssueComment(new utils.MockContext(prCommentEvent('/test .github/workflows/build.yml'))) + + expect(reruns).toEqual([7]) + }) +}) + +describe('/ok-to-test', () => { + // the label write is followed by the post-command sweep: no prow.yaml, no + // OWNERS files, no merge queue, tide skips a pr without lgtm + beforeEach(() => { + setup('/ok-to-test') + server.use(...utils.noOrgOrRepoConfigExcept(), utils.defaultBranchTree(), utils.mergeQueueGraphql({ enabled: false }).handler) + }) + + const pendingRuns: RunSpec[] = [ + { id: 8, name: 'CI', status: 'action_required', conclusion: 'action_required' }, + { id: 2, name: 'Docs', status: 'completed', conclusion: 'success' }, + ] + + // `label.toLowerCase() === okToTestLabel`: only the ok-to-test label counts + // as already present, not any label the pull request carries + it('adds ok-to-test to a pull request that carries only unrelated labels', async () => { + const approved: number[] = [] + const label = new utils.ObserveRequest() + server.use( + ...memberAuth(), + serveRuns(pendingRuns), + recordRerun(approved, 'approve'), + http.get(`${repo}/issues/1`, utils.mockResponse(200, { labels: [{ name: 'kind/bug' }, { name: 'size/S' }] })), + utils.repoHasLabels(['ok-to-test']), + http.post(`${repo}/issues/1/labels`, utils.mockResponse(200, [], label)), + http.post(reactionUrl, utils.mockResponse(201, {})), + ) + const setFailed = vi.spyOn(core, 'setFailed').mockImplementation(() => {}) + + await handleIssueComment(new utils.MockContext(prCommentEvent('/ok-to-test'))) + + await expect(label.called()).resolves.toBe('called') + expect(await label.body()).toEqual({ labels: ['ok-to-test'] }) + expect(approved).toEqual([8]) + expect(setFailed).not.toHaveBeenCalled() + }) + + // `approved === 0 && alreadyLabeled`: the no-runs comment is only for a + // repeat with nothing to approve; approving runs reacts even when labeled + it('with the label already present and runs approved reacts instead of commenting', async () => { + const approved: number[] = [] + const reply = new utils.ObserveRequest() + const reaction = new utils.ObserveRequest() + server.use( + ...memberAuth(), + serveRuns(pendingRuns), + recordRerun(approved, 'approve'), + http.get(`${repo}/issues/1`, utils.mockResponse(200, { labels: [{ name: 'ok-to-test' }] })), + http.post(`${repo}/issues/1/comments`, utils.mockResponse(201, {}, reply)), + http.post(reactionUrl, utils.mockResponse(201, {}, reaction)), + ) + + await handleIssueComment(new utils.MockContext(prCommentEvent('/ok-to-test'))) + + expect(approved).toEqual([8]) + await expect(reaction.called()).resolves.toBe('called') + await expect(reply.notCalled()).resolves.toBe('not called') + }) + + // prepare: `context.payload.issue?.user?.login` — the author check tolerates + // a pull request whose author account is gone + it('on a pull request whose author is gone approves and reacts for a member', async () => { + const event = prCommentEvent('/ok-to-test') as { issue: { user: unknown } } + event.issue.user = null + const approved: number[] = [] + const reaction = new utils.ObserveRequest() + server.use( + ...memberAuth(), + serveRuns(pendingRuns), + recordRerun(approved, 'approve'), + http.get(`${repo}/issues/1`, utils.mockResponse(200, { labels: [{ name: 'ok-to-test' }] })), + http.post(reactionUrl, utils.mockResponse(201, {}, reaction)), + ) + const setFailed = vi.spyOn(core, 'setFailed').mockImplementation(() => {}) + + await handleIssueComment(new utils.MockContext(event as never)) + + expect(approved).toEqual([8]) + await expect(reaction.called()).resolves.toBe('called') + expect(setFailed).not.toHaveBeenCalled() + }) + + // approvePendingRuns: `run.status === 'action_required'` is a candidate on + // its own, before GitHub fills in a conclusion + it('approves a run that reports action_required by status alone', async () => { + const approved: number[] = [] + server.use( + serveRuns([{ id: 11, name: 'CI', status: 'action_required', conclusion: null }]), + recordRerun(approved, 'approve'), + ) + + await expect(approvePendingRuns(newOctokit('some-token'), new utils.MockContext(prCommentEvent('/ok-to-test')), 1, headSha)).resolves.toBe(1) + expect(approved).toEqual([11]) + }) + + // statusOf: `typeof error === 'object' && error !== null` — a null rejection + // carries no status and is reported by its string form, not a TypeError + it('a null approve rejection fails with its string form', async () => { + server.use(serveRuns(pendingRuns)) + const octokit = newOctokit('some-token') + vi.spyOn(octokit.actions, 'approveWorkflowRun').mockRejectedValue(null) + + await expect(approvePendingRuns(octokit, new utils.MockContext(prCommentEvent('/ok-to-test')), 1, headSha)) + .rejects + .toThrow('could not approve run 8 (CI): null') + }) +}) + +describe('okToTestOnPullRequest', () => { + beforeEach(() => setup('')) + + function pullEvent(action: string, labels: unknown) { + const event = structuredClone(prOpenedEvent) as { action: string, pull_request?: { labels: unknown } } + event.action = action + event.pull_request!.labels = labels + return event + } + + const labeled = [{ name: 'ok-to-test' }] + + // `action !== 'synchronize' && action !== 'reopened'`: a labeled pull request + // on any other action is left alone + it.each(['opened', 'labeled', 'closed', 'edited'])('makes no Actions call on %s even when labeled', async (action) => { + server.use(http.get(`${repo}/actions/runs`, utils.mockResponse(200, { total_count: 0, workflow_runs: [] }))) + + await expect(okToTestOnPullRequest(new utils.MockContext(pullEvent(action, labeled) as never))).resolves.toBeUndefined() + + expect(calls).toEqual([]) + }) + + // `pull?.labels`: a payload without a pull request returns quietly + it('a synchronize payload without a pull request returns quietly', async () => { + const event = pullEvent('synchronize', labeled) + delete event.pull_request + const debug = vi.spyOn(core, 'debug') + + await expect(okToTestOnPullRequest(new utils.MockContext(event as never))).resolves.toBeUndefined() + + expect(debug).toHaveBeenCalledWith('trigger: the pull request does not carry ok-to-test') + expect(calls).toEqual([]) + }) + + // `pull?.head?.sha`: a labeled pull request without a head object fails with + // the payload in the message, not a bare TypeError from `.sha` + it('a labeled pull request without a head object throws the missing-head error', async () => { + const event = pullEvent('synchronize', labeled) as { pull_request?: { head?: unknown } } + delete event.pull_request!.head + + await expect(okToTestOnPullRequest(new utils.MockContext(event as never))).rejects.toThrow('github context payload missing pull request head') + expect(calls).toEqual([]) + }) + + // `label?.name`: a null entry in the labels array is skipped, not dereferenced + it('skips a null label entry and still finds ok-to-test', async () => { + const approved: number[] = [] + server.use( + serveRuns([{ id: 8, name: 'CI', status: 'action_required', conclusion: 'action_required' }]), + recordRerun(approved, 'approve'), + ) + + await expect(okToTestOnPullRequest(new utils.MockContext(pullEvent('synchronize', [null, { name: 'ok-to-test' }]) as never))).resolves.toBeUndefined() + + expect(approved).toEqual([8]) + }) + + // `core.getInput('github-token', { required: true })` fails fast + it('fails before any request when the github-token input is missing', async () => { + delete process.env['INPUT_GITHUB-TOKEN'] + + await expect(okToTestOnPullRequest(new utils.MockContext(pullEvent('synchronize', labeled) as never))).rejects.toThrow('Input required and not supplied: github-token') + expect(calls).toEqual([]) + }) +}) diff --git a/__tests__/label/lgtmGuards.test.ts b/__tests__/label/lgtmGuards.test.ts new file mode 100644 index 00000000..1ae92ce0 --- /dev/null +++ b/__tests__/label/lgtmGuards.test.ts @@ -0,0 +1,77 @@ +import * as core from '@actions/core' +import { http } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import { lgtm } from '../../src/labels/lgtm' + +import issuePayload from '../fixtures/issues/issue.json' +import issueCommentEvent from '../fixtures/issues/issueCommentEvent.json' +import * as utils from '../testUtils' + +// Guards and log lines of src/labels/lgtm.ts that the rest of the suite +// reaches but never distinguishes from their mutation: each case here fails +// when the guarded expression is replaced by a constant or the log call is +// removed. The fixture's commenter is the issue author, so a plain /lgtm is +// refused before any authorization read, and a cancel needs no reviewer check. + +const server = setupServer() +beforeAll(() => server.listen(utils.failOnUnhandledRequest)) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +const issue = `${utils.api}/repos/Codertocat/Hello-World/issues/1` + +function comment(body: string) { + const payload = structuredClone(issueCommentEvent) + payload.comment.body = body + return new utils.MockContext(payload) +} + +function issueWithLabels(...names: string[]) { + const payload = structuredClone(issuePayload) + payload.labels = names.map(name => ({ ...payload.labels[0], name })) + return payload +} + +beforeEach(() => { + utils.setupActionsEnv() + vi.restoreAllMocks() +}) + +describe('/lgtm guards', () => { + it('requires the github-token input before reading anything', async () => { + delete process.env['INPUT_GITHUB-TOKEN'] + const observeComment = new utils.ObserveRequest() + server.use(http.post(`${issue}/comments`, utils.mockResponse(200, null, observeComment))) + + await expect(lgtm(comment('/lgtm'))).rejects.toThrow('Input required and not supplied: github-token') + + await expect(observeComment.notCalled()).resolves.toBe('not called') + }) + + it('a refusal is logged as an error with the same message it replies with', async () => { + const observeComment = new utils.ObserveRequest() + server.use(http.post(`${issue}/comments`, utils.mockResponse(200, null, observeComment))) + const error = vi.spyOn(core, 'error').mockImplementation(() => {}) + + await expect(lgtm(comment('/lgtm'))).rejects.toThrow('you cannot LGTM your own PR.') + + expect(await observeComment.body()).toEqual({ body: 'you cannot LGTM your own PR.' }) + expect(error).toHaveBeenCalledExactlyOnceWith('you cannot LGTM your own PR.') + }) + + it('a cancel on an issue without the label says so in the debug log and removes nothing', async () => { + const observeRemove = new utils.ObserveRequest() + server.use( + http.get(issue, utils.mockResponse(200, issueWithLabels('approved'))), + http.delete(`${issue}/labels/lgtm`, utils.mockResponse(200, [], observeRemove)), + ) + const debug = vi.spyOn(core, 'debug').mockImplementation(() => {}) + + await expect(lgtm(comment('/lgtm cancel'))).resolves.toBeUndefined() + + expect(debug).toHaveBeenCalledWith('could not find lgtm to remove') + await expect(observeRemove.notCalled()).resolves.toBe('not called') + }) +}) diff --git a/__tests__/plugins/approveGuards.test.ts b/__tests__/plugins/approveGuards.test.ts new file mode 100644 index 00000000..59050605 --- /dev/null +++ b/__tests__/plugins/approveGuards.test.ts @@ -0,0 +1,257 @@ +import type { ApprovalEvent, ApproveSettings } from '../../src/plugins/approve' +import type { PullRequestOwners } from '../../src/utils/pullRequestOwners' + +import * as core from '@actions/core' +import { http } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import { approvalEvents, approveOnPullRequest, approveOnReview, computeApproval, notifierMarker, renderNotifier } from '../../src/plugins/approve' +import { effectiveOwners, ownersDir, parseOwners } from '../../src/utils/owners' +import pullReqOpenedEvent from '../fixtures/pullReq/pullReqOpenedEvent.json' +import * as utils from '../testUtils' +import { prHandlers, repo } from '../utils/ownersFixtures' + +// Guards of src/plugins/approve.ts that line coverage reaches but no other +// test would notice changing: each case here fails when the guard it names is +// removed or inverted. + +const server = setupServer() +beforeAll(() => server.listen(utils.failOnUnhandledRequest)) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +const defaults: ApproveSettings = { require_self_approval: false, ignore_review_state: false, lgtm_acts_as_approve: false, github_review: false } + +const twoDirs = { + 'OWNERS': 'approvers:\n- alice\n', + 'sdk/OWNERS': 'approvers:\n- bob\n', + 'olm/OWNERS': 'options:\n no_parent_owners: true\napprovers:\n- carol\n', +} +const twoFiles = ['sdk/x.go', 'olm/y.go'] + +interface Actor { login: string, type?: string } +interface Comment { id?: number, body: string | null, user: Actor | null, created_at?: string } + +const bot: Actor = { login: 'github-actions[bot]', type: 'Bot' } + +function stamp(index: number): string { + return new Date(Date.UTC(2024, 0, 1, 0, 0, index)).toISOString() +} + +// the OWNERS of a pull request as loadPullRequestOwners would resolve them, without the network +function pullOwners(ownersFiles: Record, files: string[], author = 'author'): PullRequestOwners { + const parsed = Object.entries(ownersFiles).map(([path, contents]) => parseOwners(path, contents)) + const tree = { owners: new Map(parsed.map(file => [ownersDir(file.path), file])), hasOwners: parsed.length > 0 } + return { + number: 1, + baseSha: 'basesha', + author, + draft: false, + open: true, + requestedReviewers: [], + assignees: [], + labels: [], + files, + tree, + perFile: new Map(files.map(file => [file, effectiveOwners(file, tree.owners)])), + } +} + +let clock = 0 +function at(user: string, kind: ApprovalEvent['kind']): ApprovalEvent { + clock += 1000 + return { user, kind, at: new Date(clock) } +} + +interface Scenario { + owners?: Record + files?: string[] + author?: string + labels?: string[] + comments?: Comment[] + /** the default branch tree; defaults to the same OWNERS paths as `owners` */ + defaultBranch?: string[] +} + +// the reads and writes of one approval evaluation, with an observer per write +function serve(scenario: Scenario = {}) { + const { + owners = twoDirs, + files = twoFiles, + author = 'some-author', + labels = [], + comments = [], + defaultBranch = Object.keys(owners), + } = scenario + + const writes = { + addLabels: new utils.ObserveRequest(), + removeLabel: new utils.ObserveRequest(), + postComment: new utils.ObserveRequest(), + patchComment: new utils.ObserveRequest(), + } + + server.use( + ...utils.noOrgOrRepoConfigExcept(), + ...prHandlers(owners, files, { user: { login: author }, labels: labels.map(name => ({ name })) }), + utils.defaultBranchTree(defaultBranch), + utils.repoHasLabels(['approved', 'lgtm']), + http.get(`${repo}/issues/1/comments`, utils.mockResponse(200, comments.map((c, i) => ({ id: c.id ?? 100 + i, created_at: stamp(i + 1), ...c })))), + http.get(`${repo}/pulls/1/reviews`, utils.mockResponse(200, [])), + http.post(`${repo}/issues/1/labels`, utils.mockResponse(200, [], writes.addLabels)), + http.delete(`${repo}/issues/1/labels/approved`, utils.mockResponse(200, [], writes.removeLabel)), + http.post(`${repo}/issues/1/comments`, utils.mockResponse(201, {}, writes.postComment)), + http.patch(`${repo}/issues/comments/900`, utils.mockResponse(200, {}, writes.patchComment)), + ) + + return writes +} + +function prEvent(action: string, extra: Record = {}) { + return new utils.MockContext({ ...pullReqOpenedEvent, action, ...extra }) +} + +async function commentBody(observe: utils.ObserveRequest): Promise { + return (await observe.body()).body +} + +// the notifier evaluateApproval renders for the default scenario once bob and carol approved +function approvedNotifier(): string { + const owners = pullOwners(twoDirs, twoFiles, 'some-author') + return renderNotifier(computeApproval(owners, [at('bob', 'approve'), at('carol', 'approve')], defaults), owners, { owner: 'Codertocat', repo: 'Hello-World' }) +} + +let debug: ReturnType + +beforeEach(() => { + utils.setupActionsEnv() + vi.spyOn(core, 'setFailed').mockImplementation(() => {}) + debug = vi.spyOn(core, 'debug') +}) + +describe('approvalEvents', () => { + it('skips a github-actions[bot] comment even when its user carries no Bot type', () => { + expect(approvalEvents([ + { id: 1, body: '/approve', user: { login: 'github-actions[bot]' }, created_at: '2024-01-01T00:00:01Z' }, + { id: 2, body: '/approve', user: { login: 'github-actions[bot]', type: 'User' }, created_at: '2024-01-01T00:00:02Z' }, + ], [])).toEqual([]) + }) +}) + +describe('computeApproval', () => { + it('still suggests an approver when another changed file has no OWNERS coverage at all', () => { + const owners = pullOwners({ 'sdk/OWNERS': 'approvers:\n- bob\n' }, ['sdk/x.go', 'README.md']) + + const state = computeApproval(owners, [], defaults) + + expect(state.uncoveredFiles).toEqual(['sdk/x.go', 'README.md']) + expect(state.suggested).toEqual(['bob']) + }) + + it('logs an approver who covers none of the changed files, and nothing for a pull request without an author', () => { + const owners = pullOwners({ 'sdk/OWNERS': 'approvers:\n- bob\n- carol\n' }, ['sdk/x.go'], '') + + const state = computeApproval(owners, [at('bob', 'approve'), at('dave', 'approve')], defaults) + + expect([...state.approvers]).toEqual(['bob']) + expect(debug).toHaveBeenCalledWith('approve: dave approves none of the changed files of #1; ignored') + expect(debug).not.toHaveBeenCalledWith('approve: bob approves none of the changed files of #1; ignored') + expect(debug).not.toHaveBeenCalledWith('approve: approves none of the changed files of #1; ignored') + }) +}) + +describe('renderNotifier', () => { + it('a pull request with no changed files renders exactly the verdict, the explanation and the marker', () => { + const owners = pullOwners({ OWNERS: 'approvers:\n- alice\n' }, []) + + const body = renderNotifier(computeApproval(owners, [], defaults), owners, { owner: 'Codertocat', repo: 'Hello-World' }) + + expect(body).toBe([ + '[APPROVALNOTIFIER] This PR is **NOT APPROVED**', + '', + 'This pull request changes no files, so there is nothing to approve.', + '', + ].join('\n')) + }) +}) + +describe('approveOnPullRequest', () => { + it('unlabeled with another label makes no api call at all', async () => { + const observeTree = new utils.ObserveRequest() + server.use(utils.defaultBranchTree(['OWNERS'], observeTree)) + + await approveOnPullRequest(prEvent('unlabeled', { label: { name: 'lgtm' } })) + + await expect(observeTree.notCalled()).resolves.toBe('not called') + expect(debug).toHaveBeenCalledWith('approve: unlabeled lgtm does not concern approval') + }) + + it('probes the OWNERS of the base branch, not of the default branch: a base with OWNERS is evaluated even when the default branch has none', async () => { + const writes = serve({ owners: { OWNERS: 'approvers:\n- some-author\n' }, files: ['src/a.go'], defaultBranch: [] }) + const observeBase = new utils.ObserveRequest() + server.use(http.get(`${repo}/git/trees/release-1.0`, utils.mockResponse(200, { sha: 'release-1.0', truncated: false, tree: [{ path: 'OWNERS', mode: '100644', type: 'blob', sha: 'blob-OWNERS' }] }, observeBase))) + const base = { ...pullReqOpenedEvent.pull_request.base, ref: 'release-1.0' } + + await approveOnPullRequest(prEvent('opened', { pull_request: { ...pullReqOpenedEvent.pull_request, base } })) + + await expect(observeBase.called()).resolves.toBe('called') + + await expect(writes.addLabels.called()).resolves.toBe('called') + await expect(writes.postComment.called()).resolves.toBe('called') + expect(await commentBody(writes.postComment)).toContain('This PR is **APPROVED**') + }) + + it('fails without the github-token input before reading anything', async () => { + delete process.env['INPUT_GITHUB-TOKEN'] + const observeTree = new utils.ObserveRequest() + server.use(utils.defaultBranchTree(['OWNERS'], observeTree)) + + await expect(approveOnPullRequest(prEvent('opened'))).rejects.toThrow('Input required and not supplied: github-token') + + await expect(observeTree.notCalled()).resolves.toBe('not called') + }) + + it('a stale notifier by a github-actions[bot] user without a Bot type is edited in place, past a comment whose user is gone', async () => { + const stale: Comment = { id: 900, body: `[APPROVALNOTIFIER] This PR is **NOT APPROVED**\n\nstale\n${notifierMarker}`, user: { login: 'github-actions[bot]' } } + const writes = serve({ comments: [{ body: 'hello from a deleted account', user: null }, stale, { body: '/approve', user: { login: 'bob' } }] }) + + await approveOnPullRequest(prEvent('opened')) + + await expect(writes.patchComment.called()).resolves.toBe('called') + await expect(writes.postComment.notCalled()).resolves.toBe('not called') + }) + + it('a notifier that differs only by surrounding whitespace is up to date', async () => { + const writes = serve({ + labels: ['approved'], + comments: [ + { id: 900, body: `${approvedNotifier()}\n`, user: bot }, + { body: '/approve', user: { login: 'bob' } }, + { body: '/approve', user: { login: 'carol' } }, + ], + }) + + await approveOnPullRequest(prEvent('synchronize')) + + expect(debug).toHaveBeenCalledWith('approve: the notifier on #1 is up to date') + await expect(writes.patchComment.notCalled()).resolves.toBe('not called') + await expect(writes.postComment.notCalled()).resolves.toBe('not called') + }) + + it('a bot comment without the marker is not mistaken for the notifier, and the posted notifier ends with the literal marker', async () => { + const writes = serve({ comments: [{ id: 900, body: '[APPROVALNOTIFIER] something else entirely', user: bot }, { body: '/approve', user: { login: 'bob' } }] }) + + await approveOnPullRequest(prEvent('opened')) + + await expect(writes.postComment.called()).resolves.toBe('called') + expect((await commentBody(writes.postComment)).endsWith('\n')).toBe(true) + await expect(writes.patchComment.notCalled()).resolves.toBe('not called') + }) +}) + +describe('approveOnReview', () => { + it('a submitted payload without a review or a pull request fails on the missing pull request, not on the missing review', async () => { + await expect(approveOnReview(new utils.MockContext({ action: 'submitted' }))).rejects.toThrow('github context payload missing pull request') + }) +}) diff --git a/__tests__/plugins/lgtmBindingGuards.test.ts b/__tests__/plugins/lgtmBindingGuards.test.ts new file mode 100644 index 00000000..b4796e77 --- /dev/null +++ b/__tests__/plugins/lgtmBindingGuards.test.ts @@ -0,0 +1,108 @@ +import type { Octokit } from '@octokit/rest' +import { Buffer } from 'node:buffer' +import * as core from '@actions/core' +import { http } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import { bindLgtm, isLgtmBound, lgtmOnPullRequest } from '../../src/plugins/lgtmBinding' +import labelFileContents from '../fixtures/labels/labelFileContentsResp.json' +import pullReqOpenedEvent from '../fixtures/pullReq/pullReqOpenedEvent.json' +import * as utils from '../testUtils' + +// Guards and log lines of src/plugins/lgtmBinding.ts that the rest of the +// suite reaches but never distinguishes from their mutation: each case here +// fails when the guarded expression is replaced by a constant, an operator is +// flipped, or the log call is removed. + +const server = setupServer() +beforeAll(() => server.listen(utils.failOnUnhandledRequest)) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +const repo = `${utils.api}/repos/Codertocat/Hello-World` +const headSha = pullReqOpenedEvent.pull_request.head.sha +const context = new utils.MockContext(pullReqOpenedEvent) + +function labeled(payload: Record = pullReqOpenedEvent) { + return new utils.MockContext({ ...payload, action: 'labeled', label: { name: 'lgtm' }, sender: { login: 'alice', type: 'User' } }) +} + +function prowYaml(text: string) { + const file = structuredClone(labelFileContents) + file.content = Buffer.from(text).toString('base64') + return http.get(utils.contentsUrl('.github/prow.yaml'), utils.mockResponse(200, file)) +} + +// an octokit whose statuses endpoints reject with a bare value: a hook or +// plugin can reject with anything, and isForbidden must not read .status off it +function rejectingWith(reason: unknown) { + return { + repos: { + createCommitStatus: () => Promise.reject(reason), + getCombinedStatusForRef: () => Promise.reject(reason), + }, + } as unknown as Octokit +} + +beforeEach(() => { + utils.setupActionsEnv() + vi.restoreAllMocks() +}) + +describe('lgtmOnPullRequest guards', () => { + it('requires the github-token input before reading the configuration', async () => { + delete process.env['INPUT_GITHUB-TOKEN'] + const status = new utils.ObserveRequest() + server.use(http.post(`${repo}/statuses/${headSha}`, utils.mockResponse(201, {}, status))) + + await expect(lgtmOnPullRequest(labeled())).rejects.toThrow('Input required and not supplied: github-token') + + await expect(status.notCalled()).resolves.toBe('not called') + }) + + it('throws the missing-head error when the pull request has no head, not a property read failure', async () => { + await expect(lgtmOnPullRequest(labeled({ pull_request: { number: 1 } }))).rejects.toThrow(/^github context payload missing pull request head: /) + }) + + it('bind_to_commit: false says so in the debug log', async () => { + const status = new utils.ObserveRequest() + server.use( + ...utils.noOrgOrRepoConfigExcept('.github/prow.yaml'), + prowYaml('lgtm:\n bind_to_commit: false\n'), + http.post(`${repo}/statuses/${headSha}`, utils.mockResponse(201, {}, status)), + ) + const debug = vi.spyOn(core, 'debug').mockImplementation(() => {}) + + await expect(lgtmOnPullRequest(labeled())).resolves.toBeUndefined() + + expect(debug).toHaveBeenCalledWith('lgtm: bind_to_commit is false') + await expect(status.notCalled()).resolves.toBe('not called') + }) +}) + +describe('isForbidden on a rejection that is not an object', () => { + it.each([ + ['null', null, 'null'], + ['a string', 'boom', 'boom'], + ['a number', 403, '403'], + ])('bindLgtm wraps %s without the permission hint', async (_, reason, text) => { + await expect(bindLgtm(rejectingWith(reason), context, 'abc', 'alice')).rejects.toThrow(`could not bind lgtm to abc: ${text}`) + }) + + it.each([ + ['null', null, 'null'], + ['a string', 'boom', 'boom'], + ['a number', 403, '403'], + ])('isLgtmBound wraps %s without the permission hint', async (_, reason, text) => { + await expect(isLgtmBound(rejectingWith(reason), context, 'abc')).rejects.toThrow(`could not read the prow/lgtm status of abc: ${text}`) + }) + + it('an object rejection without a status is not forbidden', async () => { + await expect(bindLgtm(rejectingWith(new Error('plain')), context, 'abc', 'alice')).rejects.toThrow('could not bind lgtm to abc: Error: plain') + }) + + it('a status of 403 on a plain object is forbidden', async () => { + await expect(bindLgtm(rejectingWith({ status: 403 }), context, 'abc', 'alice')).rejects.toThrow('cannot bind lgtm to the commit: grant `statuses: write`') + }) +}) diff --git a/__tests__/plugins/tideGuards.test.ts b/__tests__/plugins/tideGuards.test.ts new file mode 100644 index 00000000..378c90bb --- /dev/null +++ b/__tests__/plugins/tideGuards.test.ts @@ -0,0 +1,172 @@ +import * as core from '@actions/core' +import { http } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import { evaluateMerge, fetchMergeability, getPull, mergeOnce, tryMergePullRequest } from '../../src/plugins/tide' +import { resolveTide } from '../../src/utils/config' +import { newOctokit } from '../../src/utils/octokit' +import * as sleepModule from '../../src/utils/sleep' +import pullReqOpenedEvent from '../fixtures/pullReq/pullReqOpenedEvent.json' +import * as utils from '../testUtils' + +// Guards of src/plugins/tide.ts that line coverage reaches but no other test +// would notice changing: each case here fails when the guard it names is +// removed or inverted. + +const server = setupServer() +beforeAll(() => server.listen(utils.failOnUnhandledRequest)) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +const repo = `${utils.api}/repos/Codertocat/Hello-World` +const tide = resolveTide({ merge_method: 'squash' }) + +function pull(labels: string[], overrides: Record = {}) { + return { + number: 1, + state: 'open', + locked: false, + draft: false, + merged: false, + mergeable: true, + mergeable_state: 'clean', + labels: labels.map(name => ({ name })), + base: { ref: 'master', sha: 'basesha' }, + head: { sha: 'headsha' }, + ...overrides, + } +} + +function forkPull(labels: string[] = ['lgtm']) { + return pull(labels, { + head: { sha: 'headsha', repo: { full_name: 'dave/Hello-World' } }, + base: { ref: 'master', sha: 'basesha', repo: { full_name: 'Codertocat/Hello-World' } }, + }) +} + +// serves GET /pulls/1 with one body per call, repeating the last one +function servePull(...bodies: Record[]) { + const gets: string[] = [] + server.use( + http.get(`${repo}/pulls/1`, () => { + const body = bodies[Math.min(gets.length, bodies.length - 1)] + gets.push('get') + return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }) + }), + ) + return gets +} + +function observeMerge(status = 200, body: unknown = { merged: true }) { + const observe = new utils.ObserveRequest() + server.use(http.put(`${repo}/pulls/1/merge`, utils.mockResponse(status, body, observe))) + return observe +} + +function serveDiff(behind: string[], own: string[]) { + const observe = { compare: new utils.ObserveRequest(), comment: new utils.ObserveRequest() } + server.use( + http.get(`${repo}/compare/headsha...master`, utils.mockResponse(200, { files: behind.map(filename => ({ filename, status: 'added' })) }, observe.compare)), + http.get(`${repo}/pulls/1/files`, utils.mockResponse(200, own.map(filename => ({ filename, status: 'modified' })))), + http.get(`${repo}/issues/1/comments`, utils.mockResponse(200, [])), + http.post(`${repo}/issues/1/comments`, utils.mockResponse(201, {}, observe.comment)), + ) + return observe +} + +function serveGraphql(queue: utils.MergeQueueFixture = {}) { + const { handler, calls } = utils.mergeQueueGraphql(queue) + server.use(handler) + return calls +} + +let sleep: ReturnType +let octokit: ReturnType +let context: utils.MockContext + +beforeEach(() => { + utils.setupActionsEnv() + server.use(utils.lgtmStatus()) + sleep = vi.spyOn(sleepModule, 'sleep').mockResolvedValue(undefined) + octokit = newOctokit('some-token') + context = new utils.MockContext(pullReqOpenedEvent) + context.eventName = 'pull_request' +}) + +describe('mergeOnce reads the status of a refused merge defensively', () => { + it('a null rejection is reported as failed, not rethrown', async () => { + vi.spyOn(octokit.pulls, 'merge').mockRejectedValue(null) + + await expect(mergeOnce(octokit, context, 1, tide, 'headsha')).resolves.toEqual({ result: 'failed', message: 'null', status: undefined }) + }) + + it('a status that is not a number is dropped, the message kept', async () => { + vi.spyOn(octokit.pulls, 'merge').mockRejectedValue(Object.assign(new Error('moved'), { status: '409' })) + + await expect(mergeOnce(octokit, context, 1, tide, 'headsha')).resolves.toEqual({ result: 'failed', message: 'moved', status: undefined }) + }) +}) + +describe('the fork workflow diagnosis is reserved for a 403', () => { + it('a fork pull request whose merge fails with another status is reported failed without comparing', async () => { + servePull(forkPull()) + observeMerge(500, { message: 'Server Error' }) + serveGraphql({ enabled: false }) + const diff = serveDiff(['.github/workflows/ci.yml'], []) + const error = vi.spyOn(core, 'error').mockImplementation(() => {}) + + await expect(tryMergePullRequest(octokit, context, 1, tide)).resolves.toBe('failed') + await expect(diff.compare.notCalled()).resolves.toBe('not called') + await expect(diff.comment.notCalled()).resolves.toBe('not called') + expect(error).toHaveBeenCalledWith(expect.stringContaining('Server Error')) + }) + + it('lists the workflow files the branch lacks in sorted order, whatever order the compare returned them', async () => { + servePull(forkPull()) + observeMerge(403, { message: 'Resource not accessible by integration' }) + serveGraphql({ enabled: false }) + const diff = serveDiff(['.github/workflows/release.yml', 'README.md', '.github/workflows/ci.yml'], []) + vi.spyOn(core, 'warning').mockImplementation(() => {}) + + await expect(tryMergePullRequest(octokit, context, 1, tide)).resolves.toBe('skipped') + const body = (await diff.comment.body()).body as string + expect(body).toContain('workflow files (`.github/workflows/ci.yml`, `.github/workflows/release.yml`) that the branch does not contain.') + }) +}) + +describe('an unknown mergeable_state is retried on its own', () => { + it('re-reads when GitHub answers mergeable_state unknown with mergeable already set', async () => { + const gets = servePull(pull([], { mergeable_state: 'unknown', mergeable: true }), pull([], { mergeable_state: 'clean' })) + + await expect(fetchMergeability(octokit, context, 1, { retryIf: () => true, initial: await getPull(octokit, context, 1) })).resolves.toMatchObject({ state: 'clean', mergeable: true }) + expect(gets).toHaveLength(2) + expect(sleep.mock.calls.map(call => call[0])).toEqual([1000]) + }) +}) + +describe('on a merge queue branch', () => { + it('stops retrying an unknown state once the gate breaks on the re-read, and does not enqueue', async () => { + const unknown = { mergeable: null, mergeable_state: 'unknown' } + const gets = servePull(pull(['lgtm'], unknown), pull([], unknown)) + const calls = serveGraphql({ enabled: true }) + const info = vi.spyOn(core, 'info') + + await expect(evaluateMerge(octokit, context, 1, tide)).resolves.toEqual({ result: 'skipped', reason: 'missing lgtm' }) + expect(gets).toHaveLength(2) + expect(sleep.mock.calls.map(call => call[0])).toEqual([1000]) + expect(utils.graphqlMutations(calls, 'enqueuePullRequest')).toHaveLength(0) + expect(info).toHaveBeenCalledWith('skipping pr #1: missing lgtm') + expect(info).not.toHaveBeenCalledWith(expect.stringContaining('still unknown')) + }) + + it('a gate break on a pr that is in the queue without an entry is left alone as an unknown actor', async () => { + servePull(pull([])) + const calls = serveGraphql({ inQueue: true, entry: null }) + const debug = vi.spyOn(core, 'debug') + + await expect(evaluateMerge(octokit, context, 1, tide)).resolves.toEqual({ result: 'skipped', reason: 'missing lgtm' }) + expect(debug).toHaveBeenCalledWith('pr #1 was enqueued by an unknown actor; leaving it in the queue') + expect(utils.graphqlMutations(calls, 'dequeuePullRequest')).toHaveLength(0) + }) +}) diff --git a/__tests__/pullReqTest/readOnlyForkPayloads.test.ts b/__tests__/pullReqTest/readOnlyForkPayloads.test.ts new file mode 100644 index 00000000..84b26265 --- /dev/null +++ b/__tests__/pullReqTest/readOnlyForkPayloads.test.ts @@ -0,0 +1,42 @@ +import * as core from '@actions/core' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { skipReadOnlyForkRun } from '../../src/utils/events' +import pullReqOpenedEvent from '../fixtures/pullReq/pullReqOpenedEvent.json' +import * as utils from '../testUtils' + +const fork = 'octocat/Hello-World' + +function context(eventName: string, payload: Record) { + const ctx = new utils.MockContext(payload) + ctx.eventName = eventName + return ctx +} + +let notice: ReturnType + +beforeEach(() => { + notice = vi.spyOn(core, 'notice').mockImplementation(() => {}) +}) + +describe('skipReadOnlyForkRun on a payload missing the fields it compares', () => { + const forkHead = { ...pullReqOpenedEvent.pull_request, head: { ...pullReqOpenedEvent.pull_request.head, repo: { full_name: fork } } } + + it.each([ + ['no pull_request at all', { action: 'opened' }], + ['a pull_request without head', { action: 'opened', pull_request: { number: 2 }, repository: pullReqOpenedEvent.repository }], + ['a fork head but no repository', { action: 'opened', pull_request: forkHead }], + ['a fork head but a repository without full_name', { action: 'opened', pull_request: forkHead, repository: { name: 'Hello-World' } }], + ['a fork head but a non-string repository full_name', { action: 'opened', pull_request: forkHead, repository: { full_name: 42 } }], + ])('%s: not skipped, no notice, no throw', (_name, payload) => { + for (const eventName of ['pull_request', 'pull_request_review']) { + expect(skipReadOnlyForkRun(context(eventName, payload))).toBe(false) + } + expect(notice).not.toHaveBeenCalled() + }) + + it('still skips once both names are present and differ', () => { + expect(skipReadOnlyForkRun(context('pull_request', { action: 'opened', pull_request: forkHead, repository: pullReqOpenedEvent.repository }))).toBe(true) + expect(notice).toHaveBeenCalledTimes(1) + }) +}) diff --git a/__tests__/utils/authGuards.test.ts b/__tests__/utils/authGuards.test.ts new file mode 100644 index 00000000..eb3ae6c6 --- /dev/null +++ b/__tests__/utils/authGuards.test.ts @@ -0,0 +1,118 @@ +import { Buffer } from 'node:buffer' + +import * as core from '@actions/core' +import { Octokit } from '@octokit/rest' +import { http } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + assertAuthorizedByOwnersOrMembership, + checkIssueComments, + checkOrgMember, + policyAllows, +} from '../../src/utils/auth' +import issueCommentEvent from '../fixtures/issues/issueCommentEvent.json' + +import * as utils from '../testUtils' + +// Guards of src/utils/auth.ts that the rest of the suite reaches but never +// distinguishes from their mutation: each case here fails when the guarded +// expression is replaced by a constant or its operator is flipped. + +const server = setupServer() +beforeAll(() => { + utils.setupActionsEnv() + server.listen(utils.failOnUnhandledRequest) +}) +beforeEach(() => vi.clearAllMocks()) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +const octokit = new Octokit({ auth: 'some-token' }) +const context = new utils.MockContext(issueCommentEvent) +const repo = `${utils.api}/repos/Codertocat/Hello-World` + +function rootOwners(body: Record) { + return http.get(`${repo}/contents/OWNERS`, utils.mockResponse(200, { type: 'file', size: 4096, name: 'OWNERS', path: 'OWNERS', ...body })) +} + +function membership(login: string) { + return [ + http.get(`${utils.api}/orgs/Codertocat/members/${login}`, utils.mockResponse(404)), + http.get(`${repo}/collaborators/${login}`, utils.mockResponse(404)), + ] +} + +describe('checkOrgMember', () => { + it('refuses a payload without a repository quietly, without a warning annotation', async () => { + const noRepository = new utils.MockContext(issueCommentEvent) + delete noRepository.payload.repository + + await expect(checkOrgMember(octokit, noRepository, 'some-user')).resolves.toBe(false) + expect(core.warning).not.toHaveBeenCalled() + expect(core.debug).toHaveBeenCalledWith('checkOrgMember error: context payload repository undefined') + }) +}) + +describe('checkIssueComments', () => { + it('skips a comment whose user is gone and keeps scanning', async () => { + server.use( + http.get(`${repo}/issues/1/comments`, utils.mockResponse(200, [ + { id: 1, user: null, body: 'from a deleted account' }, + { id: 2, user: { login: 'some-user' }, body: 'still here' }, + ])), + ) + + await expect(checkIssueComments(octokit, context, 1, 'some-user')).resolves.toBe(true) + expect(core.warning).not.toHaveBeenCalled() + }) +}) + +describe('the root OWNERS read', () => { + it.each([ + ['a string', 'socket hang up', 'socket hang up'], + ['null', null, 'null'], + ])('reports %s rejection of the contents read as an OWNERS lookup error', async (_, rejection, text) => { + vi.spyOn(octokit.repos, 'getContent').mockRejectedValueOnce(rejection) + + await expect(assertAuthorizedByOwnersOrMembership(octokit, context, 'reviewers', 'Alice')) + .rejects + .toThrow(`error checking for an OWNERS file at the root of the repository: ${text}`) + }) + + it('rejects a contents response that carries content but no encoding instead of decoding it', async () => { + server.use(rootOwners({ content: Buffer.from('reviewers:\n - alice\n').toString('base64') })) + + await expect(assertAuthorizedByOwnersOrMembership(octokit, context, 'reviewers', 'Alice')) + .rejects + .toThrow('invalid OWNERS file returned from GitHub API') + }) + + it('rejects a contents response that carries an encoding but no content', async () => { + server.use(rootOwners({ encoding: 'base64' })) + + await expect(assertAuthorizedByOwnersOrMembership(octokit, context, 'reviewers', 'Alice')) + .rejects + .toThrow('invalid OWNERS file returned from GitHub API') + }) + + it('names the root OWNERS file when a role is not a list', async () => { + server.use(rootOwners({ encoding: 'base64', content: Buffer.from('approvers: alice\n').toString('base64') })) + + await expect(assertAuthorizedByOwnersOrMembership(octokit, context, 'reviewers', 'Alice')) + .rejects + .toThrow('OWNERS at OWNERS: approvers must be a list of GitHub usernames') + }) + + it('names the root OWNERS file when the trusted policy falls through to a malformed file', async () => { + server.use( + ...membership('Alice'), + rootOwners({ encoding: 'base64', content: Buffer.from('reviewers: alice\n').toString('base64') }), + ) + + await expect(policyAllows(octokit, context, 'trusted', 'Alice', [])) + .rejects + .toThrow('OWNERS at OWNERS: reviewers must be a list of GitHub usernames') + }) +}) diff --git a/__tests__/utils/commentsGuards.test.ts b/__tests__/utils/commentsGuards.test.ts new file mode 100644 index 00000000..a4075f67 --- /dev/null +++ b/__tests__/utils/commentsGuards.test.ts @@ -0,0 +1,65 @@ +import { http } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' + +import { createComment, createCommentOnce, isBotUser } from '../../src/utils/comments' +import { newOctokit } from '../../src/utils/octokit' +import issueCommentEvent from '../fixtures/issues/issueCommentEvent.json' +import * as utils from '../testUtils' + +const server = setupServer() +beforeAll(() => server.listen(utils.failOnUnhandledRequest)) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +const commentsUrl = `${utils.api}/repos/Codertocat/Hello-World/issues/1/comments` +const marker = '' + +describe('isBotUser', () => { + it.each([ + ['the Bot type under any login', { login: 'some-app[bot]', type: 'Bot' }, true], + ['the github-actions[bot] login when the type is missing', { login: 'github-actions[bot]' }, true], + ['the github-actions[bot] login when the type says User', { login: 'github-actions[bot]', type: 'User' }, true], + ['a human', { login: 'Codertocat', type: 'User' }, false], + ['a login that only starts with github-actions', { login: 'github-actions', type: 'User' }, false], + ['an empty user', {}, false], + ['null', null, false], + ['undefined', undefined, false], + ])('%s', (_name, user, expected) => { + expect(isBotUser(user)).toBe(expected) + }) +}) + +describe('createComment', () => { + const context = new utils.MockContext(issueCommentEvent) + + beforeEach(() => { + utils.setupActionsEnv() + }) + + it('rewraps a refused post as "could not add comment" with the HttpError inside', async () => { + server.use(http.post(commentsUrl, utils.mockResponse(403, { message: 'Resource not accessible by integration' }))) + + const posted = createComment(newOctokit('some-token'), context, 1, 'hello') + await expect(posted).rejects.toThrow(/^could not add comment: HttpError: Resource not accessible by integration/) + }) +}) + +describe('createCommentOnce', () => { + const context = new utils.MockContext(issueCommentEvent) + + beforeEach(() => { + utils.setupActionsEnv() + }) + + it('treats a github-actions[bot] comment without a user type as its own and does not post again', async () => { + const observeReq = new utils.ObserveRequest() + server.use( + http.get(commentsUrl, utils.mockResponse(200, [{ id: 1, user: { login: 'github-actions[bot]' }, body: `earlier\n\n${marker}` }])), + http.post(commentsUrl, utils.mockResponse(201, {}, observeReq)), + ) + + await expect(createCommentOnce(newOctokit('some-token'), context, 1, marker, 'hello')).resolves.toBe(false) + await expect(observeReq.notCalled()).resolves.toBe('not called') + }) +}) diff --git a/__tests__/utils/mergeQueueGuards.test.ts b/__tests__/utils/mergeQueueGuards.test.ts new file mode 100644 index 00000000..2b99609c --- /dev/null +++ b/__tests__/utils/mergeQueueGuards.test.ts @@ -0,0 +1,63 @@ +import type { Octokit } from '@octokit/rest' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { enqueue, enqueuedByBot, queueState } from '../../src/utils/mergeQueue' +import pullReqOpenedEvent from '../fixtures/pullReq/pullReqOpenedEvent.json' + +import * as utils from '../testUtils' + +// Guards of src/utils/mergeQueue.ts that line coverage reaches but that no test +// in mergeQueue.test.ts would notice changing. This file deliberately does not +// call setupActionsEnv() or resetMergeQueueWarnings(): the once-per-run warning +// is pinned on a module instance nothing has reset. + +const context = new utils.MockContext(pullReqOpenedEvent) +const state = { pullRequestId: 'PR_kwDOtest', headOid: 'headsha', enabled: true, inQueue: false } + +function rejectingWith(reason: unknown): Octokit { + return { graphql: vi.fn().mockRejectedValue(reason) } as unknown as Octokit +} + +afterEach(() => vi.resetModules()) + +describe('errorMessage', () => { + it('reports a null rejection of the state query as "null" instead of throwing', async () => { + const core = await import('@actions/core') + + await expect(queueState(rejectingWith(null), context, 7)).resolves.toBeUndefined() + + expect(core.warning).toHaveBeenCalledWith('could not read the merge queue state of pr #7; falling back to a direct merge: null') + }) + + it('classifies an undefined rejection of the enqueue mutation as other with the message "undefined"', async () => { + await expect(enqueue(rejectingWith(undefined), context, state, 'headsha')).resolves.toEqual({ ok: false, message: 'undefined', kind: 'other' }) + }) + + it('does not treat an errors property that is not an array as GraphQL errors', async () => { + await expect(enqueue(rejectingWith({ errors: 'not an array' }), context, state, 'headsha')).resolves.toEqual({ ok: false, message: '[object Object]', kind: 'other' }) + }) +}) + +describe('queueState warning', () => { + it('warns on the first failure of a run without any reset having happened', async () => { + const [core, fresh] = await Promise.all([import('@actions/core'), import('../../src/utils/mergeQueue')]) + vi.mocked(core.warning).mockClear() + + await expect(fresh.queueState(rejectingWith(new Error('first failure')), context, 7)).resolves.toBeUndefined() + await expect(fresh.queueState(rejectingWith(new Error('second failure')), context, 8)).resolves.toBeUndefined() + + expect(core.warning).toHaveBeenCalledTimes(1) + expect(core.warning).toHaveBeenCalledWith(expect.stringContaining('pr #7; falling back to a direct merge: first failure')) + }) +}) + +describe('enqueuedByBot', () => { + it.each([ + ['dependabot[bot]-fan', false], + ['[bot]renovate', false], + ['github-actions[bot]', true], + ])('%s -> %s: [bot] counts only as a suffix', (login, expected) => { + expect(enqueuedByBot({ state: 'QUEUED', position: 1, enqueuer: login })).toBe(expected) + }) +}) diff --git a/__tests__/utils/ownersGuards.test.ts b/__tests__/utils/ownersGuards.test.ts new file mode 100644 index 00000000..60d185df --- /dev/null +++ b/__tests__/utils/ownersGuards.test.ts @@ -0,0 +1,170 @@ +import type { Octokit } from '@octokit/rest' +import * as core from '@actions/core' +import { Octokit as RealOctokit } from '@octokit/rest' +import { http } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + branchHasOwners, + expandGroups, + loadOwnersTree, + parseGroup, + parseOwners, + repoHasOwners, + resetOwnersCaches, +} from '../../src/utils/owners' +import * as utils from '../testUtils' +import { repo } from './ownersFixtures' + +// Each test here pins a contract of src/utils/owners.ts that a mutation of the +// source survived under the rest of the suite: a bare `options:` key, the +// per-team group cache key, parseGroup's leading anchor, decode's +// missing-content error, the repository-less payload fallback of +// defaultBranch, isNotFound on a string or null rejection, the path-less tree +// entry guard and the per-repository OWNERS-presence cache key. + +const server = setupServer() +beforeAll(() => server.listen(utils.failOnUnhandledRequest)) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +const octokit = new RealOctokit({ auth: 'some-token' }) + +function treeResponse(entries: Array<{ path?: string, type: string, sha: string }>, truncated = false) { + return { sha: 'x', truncated, tree: entries } +} + +describe('parseOwners', () => { + it('treats a bare options key (yaml null) as no options', () => { + expect(parseOwners('OWNERS', 'options:\napprovers:\n- alice\n')).toMatchObject({ approvers: ['alice'], noParentOwners: false }) + }) +}) + +describe('expandGroups', () => { + const context = { repo: { owner: 'MyOrg', repo: 'r' } } as never + + function client(listed: Record) { + const paginate = vi.fn(async (_fn: unknown, params: { team_slug: string }) => { + const members = listed[params.team_slug] + if (members === undefined) { + throw new Error('404') + } + return members.map(login => ({ login })) + }) + return { paginate, teams: { listMembersInOrg: vi.fn() } } as never + } + + beforeEach(() => resetOwnersCaches()) + + it('reads each of two same-org teams separately: the second does not reuse the first team\'s members', async () => { + const api = client({ approvers: ['alice'], reviewers: ['bob'] }) + + const result = await expandGroups(api, context, ['@myorg/approvers', '@myorg/reviewers']) + + expect([...result].sort()).toEqual(['alice', 'bob']) + expect((api as { paginate: ReturnType }).paginate).toHaveBeenCalledTimes(2) + }) + + it('keeps an entry with an @ in the middle as a plain login and reads no team', async () => { + const api = client({ team: ['bob'] }) + + const result = await expandGroups(api, context, ['alice@myorg/team']) + + expect([...result]).toEqual(['alice@myorg/team']) + expect((api as { paginate: ReturnType }).paginate).not.toHaveBeenCalled() + expect(parseGroup('alice@myorg/team')).toBeUndefined() + }) + + it('names the group and the supported organization when ignoring a group of another organization', async () => { + await expandGroups(client({}), context, ['@other/team']) + + expect(core.warning).toHaveBeenCalledTimes(1) + expect(core.warning).toHaveBeenCalledWith('OWNERS: ignoring group @other/team: only groups of myorg are supported') + }) +}) + +describe('loadOwnersTree', () => { + const context = new utils.MockContext({ repository: { owner: { login: 'Codertocat' }, name: 'Hello-World' } }) + + beforeEach(() => { + utils.setupActionsEnv() + resetOwnersCaches() + }) + + it('rejects a blob that names an encoding but carries no content as an invalid OWNERS file', async () => { + server.use( + http.get(`${repo}/git/trees/base-sha`, utils.mockResponse(200, treeResponse([{ path: 'OWNERS', type: 'blob', sha: 'blob-owners' }]))), + http.get(`${repo}/git/blobs/blob-owners`, utils.mockResponse(200, { sha: 'blob-owners', encoding: 'base64' })), + ) + + await expect(loadOwnersTree(octokit, context, 'base-sha', ['README.md'])).rejects.toThrow( + 'error loading OWNERS files at base-sha: Error: invalid OWNERS file returned from GitHub API for OWNERS', + ) + }) +}) + +describe('repoHasOwners', () => { + beforeEach(() => { + utils.setupActionsEnv() + resetOwnersCaches() + }) + + it('reads the default branch from the api when the payload has no repository at all', async () => { + // a schedule payload: context.repo comes from the runner's GITHUB_REPOSITORY, not the payload + process.env.GITHUB_REPOSITORY = 'Codertocat/Hello-World' + const observeRepo = new utils.ObserveRequest() + server.use( + http.get(repo, utils.mockResponse(200, { default_branch: 'trunk' }, observeRepo)), + http.get(`${repo}/git/trees/trunk`, utils.mockResponse(200, treeResponse([{ path: 'OWNERS', type: 'blob', sha: 'a' }]))), + ) + + await expect(repoHasOwners(octokit, new utils.MockContext({}))).resolves.toBe(true) + expect(observeRepo.ref).not.toBeNull() + }) + + it('memoizes per repository: a second repository without OWNERS files is not answered from the first', async () => { + const other = `${utils.api}/repos/Codertocat/Other` + server.use( + utils.defaultBranchTree(['OWNERS']), + http.get(other, utils.mockResponse(200, { default_branch: 'main' })), + http.get(`${other}/git/trees/main`, utils.mockResponse(200, treeResponse([{ path: 'README.md', type: 'blob', sha: 'a' }]))), + ) + const first = new utils.MockContext({ repository: { owner: { login: 'Codertocat' }, name: 'Hello-World', default_branch: 'master' } }) + const second = new utils.MockContext({ repository: { owner: { login: 'Codertocat' }, name: 'Other' } }) + + await expect(repoHasOwners(octokit, first)).resolves.toBe(true) + await expect(repoHasOwners(octokit, second)).resolves.toBe(false) + }) +}) + +describe('branchHasOwners', () => { + beforeEach(() => { + utils.setupActionsEnv() + resetOwnersCaches() + }) + + it.each([ + ['boom', 'a string'], + [null, 'null'], + ])('reports a tree rejection of %j (%s) as a listing error, not as a missing tree', async (reason) => { + // Octokit itself always rejects with a RequestError; the guard is what keeps a + // bare rejection from being mistaken for a 404 (or from throwing on `in`) + const bare = { git: { getTree: vi.fn().mockRejectedValue(reason) } } as unknown as Octokit + const context = new utils.MockContext({ repository: { owner: { login: 'Codertocat' }, name: 'Hello-World' } }) + + await expect(branchHasOwners(bare, context, 'main')).rejects.toThrow(`error listing the tree of main: ${reason}`) + }) + + it('skips a tree entry without a path and still finds the OWNERS file after it', async () => { + server.use( + http.get(`${repo}/git/trees/main`, utils.mockResponse(200, treeResponse([ + { type: 'blob', sha: 'no-path' }, + { path: 'OWNERS', type: 'blob', sha: 'b' }, + ]))), + ) + const context = new utils.MockContext({ repository: { owner: { login: 'Codertocat' }, name: 'Hello-World' } }) + + await expect(branchHasOwners(octokit, context, 'main')).resolves.toBe(true) + }) +})