From ef7297bd364da63878ac2e614184eba576f270a2 Mon Sep 17 00:00:00 2001 From: quality <280983584+hivecommons-hive[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:38:12 -0400 Subject: [PATCH] test(sweep): pin the reviewer-key fallbacks of delayed blunderbuss on a bare pull listing (sweep.ts:208) requestReviewersIfDue reads requested_reviewers ?? [] and requested_teams ?? [] off the pull list entry, but no unit or bundle test listed a delayed-only pull request without those keys, leaving both fallback arms unreached in coverage/ and coverage-e2e/. Serve a due pull with the keys removed on both harnesses and assert reviewers are still requested. Signed-off-by: quality <280983584+hivecommons-hive[bot]@users.noreply.github.com> --- .../blunderbussDelayBareListing.test.ts | 73 +++++++++++++++ .../cronJobTest/sweepDelayBareListing.test.ts | 89 +++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 __tests__/bundle/blunderbussDelayBareListing.test.ts create mode 100644 __tests__/cronJobTest/sweepDelayBareListing.test.ts diff --git a/__tests__/bundle/blunderbussDelayBareListing.test.ts b/__tests__/bundle/blunderbussDelayBareListing.test.ts new file mode 100644 index 0000000..b7db25a --- /dev/null +++ b/__tests__/bundle/blunderbussDelayBareListing.test.ts @@ -0,0 +1,73 @@ +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 { blobSha } from '../utils/ownersData' +import { start } from './fakeGithub' +import { helpersFor, repo, token } from './helpers' +import { runBundle } from './runBundle' + +vi.setConfig({ testTimeout: 30_000 }) + +// The delayed-reviewers loop of the sweep (src/cronJobs/sweep.ts, +// `requestReviewersIfDue`) reads `requested_reviewers ?? []` and +// `requested_teams ?? []` off the pull list entry: a due pull request listed +// without either key counts as having no requested reviewers and gets them. +describe('dist/index.js delayed blunderbuss on a pull request listed without reviewer keys', () => { + let gh: FakeGithub + const { routeOwners } = helpersFor(() => gh) + const owners = { OWNERS: 'reviewers:\n- bob\n' } + + 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('requests reviewers on a due pull request whose listing omits requested_reviewers and requested_teams', 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 }) + const { requested_reviewers: _reviewers, requested_teams: _teams, ...bare } = { + ...structuredClone(pullReqListPulls[0]), + number: 1, + created_at: ago(70), + updated_at: ago(70), + labels: [], + assignees: [], + draft: false, + user: { login: 'Codertocat' }, + base: { ref: 'master', sha: 'basesha' }, + head: { sha: 'sha1', repo: { full_name: 'Codertocat/Hello-World' } }, + } + expect(bare).not.toHaveProperty('requested_reviewers') + expect(bare).not.toHaveProperty('requested_teams') + gh.route('GET', new RegExp(`^${repo}/pulls\\?`), { status: 200, body: [bare] }) + gh.route('GET', `${repo}/git/trees/master`, { + status: 200, + body: { sha: 'master', truncated: false, tree: [{ path: 'OWNERS', type: 'blob', sha: blobSha('OWNERS') }] }, + }) + routeOwners(owners, ['x.go'], bare) + gh.route('GET', `${repo}/issues/1/timeline`, { status: 200, body: [] }) + gh.route('GET', `${repo}/pulls/1/reviews`, { status: 200, body: [] }) + gh.route('POST', `${repo}/pulls/1/requested_reviewers`, { status: 201, body: {} }) + + 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(result.stdout).not.toContain('already has requested reviewers') + expect(gh.requestsMatching('GET', /\/timeline\?per_page=100$/)).toHaveLength(1) + expect(gh.requestsMatching('GET', /\/reviews\?per_page=1$/)).toHaveLength(1) + expect(gh.requestsMatching('POST', /\/requested_reviewers$/).map(r => r.body)).toEqual([{ reviewers: ['bob'] }]) + expect(gh.requests.filter(r => r.method !== 'GET').map(r => r.path)).toEqual([`${repo}/pulls/1/requested_reviewers`]) + }) +}) diff --git a/__tests__/cronJobTest/sweepDelayBareListing.test.ts b/__tests__/cronJobTest/sweepDelayBareListing.test.ts new file mode 100644 index 0000000..e5c82f7 --- /dev/null +++ b/__tests__/cronJobTest/sweepDelayBareListing.test.ts @@ -0,0 +1,89 @@ +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' +import { blobSha } from '../utils/ownersFixtures' + +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 `requested_reviewers ?? []` and `requested_teams ?? []` fallbacks +// of `requestReviewersIfDue` (src/cronJobs/sweep.ts): a delayed-only pull +// request whose list entry carries neither key is treated as having no +// requested reviewers, so a due one still gets blunderbuss' reviewers. +describe('sweep with blunderbuss.delay on a pull request listed without reviewer keys', () => { + const owners = { OWNERS: 'approvers:\n- alice\nreviewers:\n- carol\n' } + let context: utils.MockContext + + beforeEach(() => { + utils.setupJobsEnv('sweep') + vi.useFakeTimers({ now, toFake: ['Date'] }) + resetOwnersCaches() + context = new utils.MockContext(pullReqOpenedEvent) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('requests reviewers on a due pull request whose listing omits requested_reviewers and requested_teams', async () => { + const tree = Object.keys(owners).map(path => ({ path, type: 'blob', sha: blobSha(path) })) + const timeline = new utils.ObserveRequest() + const reviews = new utils.ObserveRequest() + const requested = new utils.ObserveRequest() + const { requested_reviewers: _reviewers, requested_teams: _teams, ...bare } = { + ...structuredClone(pullReqListPulls[0]), + number: 1, + draft: false, + labels: [], + created_at: ago(70), + updated_at: ago(70), + assignees: [], + user: { login: 'some-author' }, + head: { sha: 'sha1' }, + base: { ref: 'master', sha: 'basesha' }, + } + expect(bare).not.toHaveProperty('requested_reviewers') + expect(bare).not.toHaveProperty('requested_teams') + server.use( + prowYaml('blunderbuss:\n delay: 1h\n'), + ...utils.noOrgOrRepoConfigExcept(), + utils.defaultBranchTree(Object.keys(owners)), + http.get(`${repo}/git/trees/basesha`, utils.mockResponse(200, { sha: 'basesha', truncated: false, tree })), + ...Object.entries(owners).map(([path, contents]) => http.get(`${repo}/git/blobs/${blobSha(path)}`, utils.mockResponse(200, { encoding: 'base64', content: Buffer.from(contents).toString('base64') }))), + http.get(`${repo}/pulls`, utils.mockResponse(200, [bare])), + http.get(`${repo}/pulls/1`, utils.mockResponse(200, bare)), + http.get(`${repo}/pulls/1/files`, utils.mockResponse(200, [{ filename: 'x.go', status: 'modified' }])), + 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(timeline.called()).resolves.toBe('called') + await expect(reviews.called()).resolves.toBe('called') + await expect(requested.called()).resolves.toBe('called') + }) +})