diff --git a/__tests__/bundle/blunderbussDelayWithoutOwners.test.ts b/__tests__/bundle/blunderbussDelayWithoutOwners.test.ts new file mode 100644 index 0000000..5b0f636 --- /dev/null +++ b/__tests__/bundle/blunderbussDelayWithoutOwners.test.ts @@ -0,0 +1,68 @@ +import type { FakeGithub } from './fakeGithub' +import { Buffer } from 'node:buffer' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +import labelFileContents from '../fixtures/labels/labelFileContentsResp.json' +import pullReqListPulls from '../fixtures/pullReq/pullReqListPulls.json' +import { start } from './fakeGithub' +import { repo, token } from './helpers' +import { runBundle } from './runBundle' + +vi.setConfig({ testTimeout: 30_000 }) + +// The delayed-reviewers loop of the sweep (src/cronJobs/sweep.ts) probes the +// base branch for OWNERS files before `requestReviewersIfDue`; without any, a +// due pull request is left alone and none of blunderbuss' reads happen. +describe('dist/index.js delayed blunderbuss on a base branch without OWNERS files', () => { + let gh: FakeGithub + + beforeAll(async () => { + gh = await start() + }) + afterEach(() => gh.reset()) + afterAll(() => gh.close()) + + function ago(minutes: number) { + return new Date(Date.now() - minutes * 60_000).toISOString() + } + + it('probes the base branch once and requests no reviewers for a due pull request', async () => { + const file = structuredClone(labelFileContents) + file.content = Buffer.from('blunderbuss:\n delay: 1h\n').toString('base64') + gh.route('GET', '/repos/Codertocat/.project/contents/prow.yaml', { status: 200, body: file }) + gh.route('GET', new RegExp(`^${repo}/pulls\\?`), { + status: 200, + body: [{ + ...structuredClone(pullReqListPulls[0]), + number: 1, + created_at: ago(70), + updated_at: ago(70), + labels: [], + requested_reviewers: [], + requested_teams: [], + assignees: [], + draft: false, + user: { login: 'Codertocat' }, + base: { ref: 'master', sha: 'basesha' }, + head: { sha: 'sha1', repo: { full_name: 'Codertocat/Hello-World' } }, + }], + }) + gh.route('GET', `${repo}/git/trees/master`, { status: 200, body: { sha: 'master', truncated: false, tree: [] } }) + + const result = await runBundle({ eventName: 'schedule', payload: {}, inputs: { ...token, jobs: 'sweep' }, apiUrl: gh.url }) + + expect(result.status, result.stdout).toBe(0) + expect(result.errors).toEqual([]) + expect(result.stdout).toContain('sweep: 0 candidates updated since') + expect(result.stdout).toContain('sweep: 1 more checked for delayed reviewers') + expect(gh.requestsMatching('GET', /\/git\/trees\/master\?recursive=true$/)).toHaveLength(1) + expect(gh.requestsMatching('GET', /\/timeline\?/)).toEqual([]) + expect(gh.requestsMatching('GET', /\/reviews\?/)).toEqual([]) + expect(gh.requests.filter(r => r.method !== 'GET')).toEqual([]) + // nothing but the configuration probes, the pull list and the one tree read + expect(gh.requests.map(r => r.path).filter(path => !/\/contents\//.test(path))).toEqual([ + `${repo}/pulls?state=open&sort=updated&direction=desc&per_page=100&page=1`, + `${repo}/git/trees/master?recursive=true`, + ]) + }) +}) diff --git a/__tests__/cronJobTest/sweepDelayWithoutOwners.test.ts b/__tests__/cronJobTest/sweepDelayWithoutOwners.test.ts new file mode 100644 index 0000000..c33906e --- /dev/null +++ b/__tests__/cronJobTest/sweepDelayWithoutOwners.test.ts @@ -0,0 +1,84 @@ +import { Buffer } from 'node:buffer' +import { http } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import { sweep } from '../../src/cronJobs/sweep' +import { resetOwnersCaches } from '../../src/utils/owners' +import labelFileContents from '../fixtures/labels/labelFileContentsResp.json' +import pullReqListPulls from '../fixtures/pullReq/pullReqListPulls.json' +import pullReqOpenedEvent from '../fixtures/pullReq/pullReqOpenedEvent.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` +const now = new Date('2026-09-16T12:00:00Z') + +function ago(minutes: number): string { + return new Date(now.getTime() - minutes * 60_000).toISOString() +} + +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)) +} + +// Covers the delayed-reviewers loop of `sweep` (src/cronJobs/sweep.ts, the +// `branchHasOwners` guard ahead of `requestReviewersIfDue`) on a base branch +// without OWNERS files: the only pull requests in scope are the delayed ones, +// so the skip is observable as the absence of every blunderbuss read and write. +describe('sweep with blunderbuss.delay on a base branch without OWNERS files', () => { + let context: utils.MockContext + + beforeEach(() => { + utils.setupJobsEnv('sweep') + vi.useFakeTimers({ now, toFake: ['Date'] }) + resetOwnersCaches() + context = new utils.MockContext(pullReqOpenedEvent) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('probes the base branch once and requests no reviewers for a due pull request', async () => { + const tree = new utils.ObserveRequest() + const timeline = new utils.ObserveRequest() + const reviews = new utils.ObserveRequest() + const requested = new utils.ObserveRequest() + const due = { + ...structuredClone(pullReqListPulls[0]), + number: 1, + draft: false, + labels: [], + created_at: ago(70), + updated_at: ago(70), + requested_reviewers: [], + requested_teams: [], + assignees: [], + user: { login: 'some-author' }, + head: { sha: 'sha1' }, + base: { ref: 'master', sha: 'basesha' }, + } + server.use( + prowYaml('blunderbuss:\n delay: 1h\n'), + ...utils.noOrgOrRepoConfigExcept(), + utils.defaultBranchTree([], tree), + http.get(`${repo}/pulls`, utils.mockResponse(200, [due])), + http.get(`${repo}/issues/1/timeline`, utils.mockResponse(200, [], timeline)), + http.get(`${repo}/pulls/1/reviews`, utils.mockResponse(200, [], reviews)), + http.post(`${repo}/pulls/1/requested_reviewers`, utils.mockResponse(201, {}, requested)), + ) + + await expect(sweep(context)).resolves.toEqual({ candidates: [], merged: [], enqueued: [], failures: [] }) + await expect(tree.called()).resolves.toBe('called') + await expect(timeline.notCalled()).resolves.toBe('not called') + await expect(reviews.notCalled()).resolves.toBe('not called') + await expect(requested.notCalled()).resolves.toBe('not called') + }) +})