diff --git a/__tests__/cronJobTest/labelSync.test.ts b/__tests__/cronJobTest/labelSync.test.ts index aeee821..25d251e 100644 --- a/__tests__/cronJobTest/labelSync.test.ts +++ b/__tests__/cronJobTest/labelSync.test.ts @@ -1,15 +1,21 @@ 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 { handleCronJobs } from '../../src/cronJobs/handleCronJob' import { labelSync } from '../../src/cronJobs/labelSync' +import * as octokitFactory from '../../src/utils/octokit' import labelFileContents from '../fixtures/labels/labelFileContentsResp.json' import listPullReqs from '../fixtures/pullReq/pullReqListPulls.json' import * as utils from '../testUtils' +// the github client only ever rejects with an Error over HTTP, so the +// non-Error fallback of a refused write is only reachable through a stub +vi.mock('../../src/utils/octokit', { spy: true }) + const server = setupServer() beforeAll(() => server.listen({ @@ -270,6 +276,38 @@ describe('label-sync job', () => { expect(setFailed).toHaveBeenCalledWith(expect.stringContaining('could not load prow config')) }) + it('logs that only the built-in defaults apply when no configuration is found', async () => { + server.use(...utils.noOrgOrRepoConfigExcept()) + recordWrites([]) + const debug = vi.spyOn(core, 'debug').mockImplementation(() => {}) + vi.spyOn(core, 'info').mockImplementation(() => {}) + + await labelSync(dispatchContext()) + + expect(debug).toHaveBeenCalledWith(expect.stringContaining('labels from the built-in defaults only')) + }) + + it('records a write refused with a non-Error value by its string form', async () => { + serveConfig() + const writes = recordWrites([]) + vi.mocked(octokitFactory.newOctokit).mockImplementationOnce((token) => { + const real = new Octokit({ auth: token, baseUrl: utils.api }) + const refused = vi.fn().mockRejectedValue('offline') + const issues = Object.create(real.issues, { + createLabel: { value: (params: { name: string }) => (params.name === 'kind/bug' ? refused() : real.issues.createLabel(params)) }, + }) + return Object.create(real, { issues: { value: issues } }) + }) + const logError = vi.spyOn(core, 'error').mockImplementation(() => {}) + vi.spyOn(core, 'info').mockImplementation(() => {}) + + const run = labelSync(dispatchContext()) + + await expect(run).rejects.toThrow('1 label(s) could not be synced: kind/bug (offline)') + expect(logError).toHaveBeenCalledWith('label-sync: could not sync kind/bug: offline') + expect(writes.map(w => (w.body as { name: string }).name)).toEqual(complete.map(l => l.name).filter(n => n !== 'kind/bug')) + }) + it('runs next to an unknown job, which still fails the run', async () => { utils.setupJobsEnv('label-sync bogus') serveConfig() diff --git a/__tests__/issueCommentTest/approve.test.ts b/__tests__/issueCommentTest/approve.test.ts index a72a895..807d319 100644 --- a/__tests__/issueCommentTest/approve.test.ts +++ b/__tests__/issueCommentTest/approve.test.ts @@ -726,6 +726,23 @@ reviewers: expect(setFailed).toHaveBeenCalledWith(expect.stringContaining('you cannot approve your own PR')) }) + it('require_self_approval: a payload whose issue has no user is not treated as the author', async () => { + const writes = serve({ + owners: { OWNERS: 'approvers:\n- alice\n' }, + files: ['src/a.go'], + prowYaml: 'approve:\n require_self_approval: true\n', + comments: [{ body: '/approve', user: { login: 'alice' } }], + }) + const event = prCommentEvent('/approve', 'alice') + delete (event.issue as { user?: unknown }).user + + await handleIssueComment(new utils.MockContext(event)) + + await expect(writes.addLabels.called()).resolves.toBe('called') + expect(await writes.addLabels.body()).toEqual({ labels: ['approved'] }) + expect(setFailed).not.toHaveBeenCalled() + }) + it('fails when the repository lacks the approved label', async () => { const writes = serve({ owners: { OWNERS: 'approvers:\n- alice\n' }, files: ['src/a.go'], comments: [{ body: '/approve', user: { login: 'alice' } }], repoLabels: ['lgtm'] }) diff --git a/__tests__/label/lgtm.test.ts b/__tests__/label/lgtm.test.ts index 354fda7..89173d4 100644 --- a/__tests__/label/lgtm.test.ts +++ b/__tests__/label/lgtm.test.ts @@ -7,6 +7,7 @@ import { setupServer } from 'msw/node' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { handleIssueComment } from '../../src/issueComment/handleIssueComment' +import * as lgtmBinding from '../../src/plugins/lgtmBinding' import issuePayload from '../fixtures/issues/issue.json' @@ -15,6 +16,10 @@ import labelFileContents from '../fixtures/labels/labelFileContentsResp.json' import * as utils from '../testUtils' import { prCommentEvent, prHandlers } from '../utils/ownersFixtures' +// bindLgtm wraps every HTTP failure in an Error; its non-Error fallback in +// /lgtm is only reachable when the binding itself rejects with a bare value +vi.mock('../../src/plugins/lgtmBinding', { spy: true }) + const server = setupServer() beforeAll(() => server.listen({ @@ -783,6 +788,26 @@ reviewers: expect(setFailed).toHaveBeenCalledWith(expect.stringContaining('could not bind lgtm to headsha')) }) + it('a binding refused with a non-Error value fails the command with its string form and applies no label', async () => { + const label = new utils.ObserveRequest() + const reply = new utils.ObserveRequest() + server.use( + ...prHandlers({}, ['src/file1.txt']), + http.post(`${repo}/issues/1/labels`, utils.mockResponse(200, [], label)), + http.post(`${repo}/issues/1/comments`, utils.mockResponse(201, {}, reply)), + ) + vi.mocked(lgtmBinding.bindLgtm).mockRejectedValueOnce('status api offline') + const setFailed = vi.spyOn(core, 'setFailed').mockImplementation(() => {}) + vi.spyOn(core, 'error').mockImplementation(() => {}) + + await handleIssueComment(new utils.MockContext(prCommentEvent('/lgtm'))) + + await expect(reply.called()).resolves.toBe('called') + expect(await reply.body().then(body => body.body)).toBe('status api offline') + await expect(label.notCalled()).resolves.toBe('not called') + expect(setFailed).toHaveBeenCalledWith(expect.stringContaining('status api offline')) + }) + it('lgtm.bind_to_commit: false applies the label with no status call at all', async () => { const file = structuredClone(labelFileContents) file.content = Buffer.from('lgtm:\n bind_to_commit: false\n').toString('base64') diff --git a/__tests__/utils/mergeQueue.test.ts b/__tests__/utils/mergeQueue.test.ts index fd9afb0..6de0673 100644 --- a/__tests__/utils/mergeQueue.test.ts +++ b/__tests__/utils/mergeQueue.test.ts @@ -2,7 +2,7 @@ import * as core from '@actions/core' import { Octokit } from '@octokit/rest' import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { dequeue, @@ -115,6 +115,14 @@ describe('queueState', () => { expect(core.warning).toHaveBeenCalledWith(expect.stringContaining('could not read the merge queue state of pr #7; falling back to a direct merge: Field isMergeQueueEnabled doesn\'t exist on type PullRequest')) }) + it('reports a non-Error rejection of the query by its string form', async () => { + const offline = { graphql: vi.fn().mockRejectedValue('offline') } as unknown as Octokit + + await expect(queueState(offline, context, 7)).resolves.toBeUndefined() + + expect(core.warning).toHaveBeenCalledWith(expect.stringContaining('falling back to a direct merge: offline')) + }) + it('warns again after the warning state is reset', async () => { graphql(null, 'boom')