From a577c89de9d068dc2bb2583c5d04f7db1d45cad6 Mon Sep 17 00:00:00 2001 From: quality Date: Sat, 17 Oct 2026 00:28:35 -0400 Subject: [PATCH] test(bundle): drive tide's draft/files folds and sweep's labels fold through dist/index.js tide.ts:258 (compare without files), tide.ts:426 (pull without draft) and sweep.ts:141 (listing without labels) were only reached by the unit suite; one bundle file now serves each optional field absent and asserts the fold. Signed-off-by: quality --- __tests__/bundle/tideOptionalFields.test.ts | 145 ++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 __tests__/bundle/tideOptionalFields.test.ts diff --git a/__tests__/bundle/tideOptionalFields.test.ts b/__tests__/bundle/tideOptionalFields.test.ts new file mode 100644 index 0000000..e22adb7 --- /dev/null +++ b/__tests__/bundle/tideOptionalFields.test.ts @@ -0,0 +1,145 @@ +import type { FakeGithub } from './fakeGithub' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import pullReqListPulls from '../fixtures/pullReq/pullReqListPulls.json' +import pullReqOpenedEvent from '../fixtures/pullReq/pullReqOpenedEvent.json' +import { blobSha } from '../utils/ownersData' +import { start } from './fakeGithub' +import { configReads, helpersFor, ownersProbe, queueRead, repo, token } from './helpers' +import { runBundle } from './runBundle' + +vi.setConfig({ testTimeout: 30_000 }) + +// GitHub's pull request schema leaves `draft` optional and its compare response leaves `files` optional, and a +// listed pull request may omit `labels`; tide (src/plugins/tide.ts:258,426) and the sweep cron +// (src/cronJobs/sweep.ts:141) each fold the missing field to its empty value. Every other bundle case serves +// those fields populated, so the folds only ever ran under the unit suite +describe('dist/index.js optional pull request fields folded by tide and sweep', () => { + let gh: FakeGithub + const { expectRequests, routeOwners } = helpersFor(() => gh) + + beforeAll(async () => { + gh = await start() + }) + beforeEach(() => gh.mergeQueueFallback({ pullRequestId: 'PR_none', headOid: pullReqOpenedEvent.pull_request.head.sha, enabled: false })) + afterEach(() => gh.reset()) + afterAll(() => gh.close()) + + it('pull_request labeled lgtm: a pull request answered without a `draft` field is treated as ready and merged', async () => { + const head = pullReqOpenedEvent.pull_request.head.sha + const { draft: _draft, ...withoutDraft } = structuredClone(pullReqListPulls[0]) + gh.commitStatuses(repo, head, [{ context: 'prow/lgtm', state: 'success' }]) + gh.route('GET', `${repo}/pulls/1`, { + status: 200, + body: { ...withoutDraft, number: 1, labels: [{ name: 'lgtm' }], mergeable: true, mergeable_state: 'clean', head: { sha: head } }, + }) + gh.route('PUT', `${repo}/pulls/1/merge`, { status: 200, body: { merged: true } }) + + const result = await runBundle({ + eventName: 'pull_request', + payload: { ...pullReqOpenedEvent, action: 'labeled', label: { name: 'lgtm' } }, + inputs: { ...token, 'merge-method': 'squash' }, + apiUrl: gh.url, + }) + + expect(result.status, result.stdout).toBe(0) + expect(result.errors).toEqual([]) + expect(result.stdout).not.toContain('not mergeable (draft)') + expect(result.stdout).toContain('merged pr #1') + expect(gh.requestsMatching('PUT', /\/pulls\/1\/merge$/)).toHaveLength(1) + }) + + it('schedule lgtm job, fork 403: a compare answered without `files` counts no workflow files behind, so only the pull request\'s own are named', async () => { + const head = pullReqListPulls[0].head.sha + const pr = { + ...structuredClone(pullReqListPulls[0]), + labels: [{ name: 'lgtm' }], + head: { sha: head, repo: { full_name: 'dave/Hello-World' } }, + } + gh.route('GET', repo, { status: 200, body: { default_branch: 'master' } }) + gh.route('GET', new RegExp(`^${repo}/pulls\\?`), (req) => { + const page = new URL(req.path, gh.url).searchParams.get('page') + return { status: 200, body: page === '1' ? [pr] : [] } + }) + gh.route('GET', `${repo}/pulls/2`, { status: 200, body: { ...pr, mergeable: true, mergeable_state: 'clean' } }) + gh.commitStatuses(repo, head, [{ context: 'prow/lgtm', state: 'success' }]) + gh.route('PUT', `${repo}/pulls/2/merge`, { status: 403, body: { message: 'Resource not accessible by integration' } }) + gh.route('GET', `${repo}/compare/${head}...master`, { status: 200, body: { status: 'ahead', ahead_by: 1, behind_by: 0 } }) + gh.route('GET', `${repo}/pulls/2/files`, { status: 200, body: [{ filename: '.github/workflows/ci.yml', status: 'modified' }] }) + gh.route('GET', `${repo}/issues/2/comments`, { status: 200, body: [] }) + gh.route('POST', `${repo}/issues/2/comments`, { status: 201, body: {} }) + + const result = await runBundle({ + eventName: 'schedule', + payload: {}, + inputs: { ...token, 'jobs': 'lgtm', 'merge-method': 'squash' }, + apiUrl: gh.url, + }) + + expect(result.status, result.stdout).toBe(0) + expect(result.errors).toEqual([]) + expect(result.stdout).toContain('::warning::pr #2 is a fork pull request whose merge involves workflow files (`.github/workflows/ci.yml`) that it changes; the token may not merge it') + expect(result.stdout).not.toContain('that the branch does not contain') + expect(result.stdout).toContain('skipping pr #2: fork pull request with workflow changes: the token may not merge it') + expect(gh.requestsMatching('POST', /\/issues\/2\/comments$/)).toHaveLength(1) + expectRequests(configReads(), [ + `GET ${repo}/pulls?state=open&page=1`, + ownersProbe, + `GET ${repo}/pulls/2`, + `GET ${repo}/commits/${head}/status?per_page=100`, + queueRead, + `PUT ${repo}/pulls/2/merge`, + `GET ${repo}/pulls/2`, + `GET ${repo}/compare/${head}...master`, + `GET ${repo}/pulls/2/files?per_page=100`, + `GET ${repo}/issues/2/comments?per_page=100`, + `POST ${repo}/issues/2/comments`, + `GET ${repo}/pulls?state=open&page=2`, + ]) + }) + + it('schedule sweep job: a listed pull request without `labels` does not carry ok-to-test, so no pending run is read or approved', async () => { + const ownersFiles: Record = { 'OWNERS': 'approvers:\n- alice\n', 'sdk/OWNERS': 'reviewers:\n- bob\n' } + const stamp = new Date().toISOString() + const { labels: _labels, ...listed } = { + ...structuredClone(pullReqListPulls[0]), + number: 1, + created_at: stamp, + updated_at: stamp, + requested_reviewers: [], + assignees: [], + draft: false, + mergeable: true, + mergeable_state: 'clean', + user: { login: 'dave' }, + head: { sha: 'sha1', repo: { full_name: 'dave/Hello-World' } }, + base: { ref: 'master', sha: 'basesha' }, + } + gh.route('GET', repo, { status: 200, body: { default_branch: 'master' } }) + gh.route('GET', new RegExp(`^${repo}/pulls\\?`), (req) => { + const page = new URL(req.path, gh.url).searchParams.get('page') + return { status: 200, body: page === '1' ? [listed] : [] } + }) + gh.route('GET', `${repo}/git/trees/master`, { status: 200, body: { sha: 'master', truncated: false, tree: Object.keys(ownersFiles).map(path => ({ path, type: 'blob', sha: blobSha(path) })) } }) + // the full pull request read still carries its (empty) labels; only the listing omitted them + routeOwners(ownersFiles, ['sdk/x.go'], { ...listed, labels: [] }) + gh.route('GET', `${repo}/issues/1`, { status: 200, body: { labels: [] } }) + gh.route('GET', `${repo}/labels`, { status: 200, body: ['approved', 'lgtm'].map(name => ({ name })) }) + gh.route('GET', `${repo}/pulls/1/reviews`, { status: 200, body: [] }) + gh.route('POST', `${repo}/pulls/1/requested_reviewers`, { status: 201, body: {} }) + gh.route('GET', `${repo}/issues/1/comments`, { status: 200, body: [] }) + gh.route('POST', `${repo}/issues/1/comments`, { status: 201, body: {} }) + gh.route('GET', '/repos/Codertocat/.project/contents/prow.yaml', { status: 404, body: { message: 'Not Found' } }) + + 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: #1 evaluated') + expect(result.stdout).toContain('::debug::sweep: #1 does not carry ok-to-test') + expect(gh.requestsMatching('GET', /\/actions\/runs/)).toEqual([]) + expect(gh.requestsMatching('POST', /\/actions\/runs\/\d+\/approve$/)).toEqual([]) + // the rest of the sweep still ran: blunderbuss requested reviews on the fresh pull request + expect(gh.requestsMatching('POST', /\/pulls\/1\/requested_reviewers$/)).toHaveLength(1) + }) +})