From a3e6037f984b85c7004c63f8ea6b3b1a3a229725 Mon Sep 17 00:00:00 2001 From: quality <280983584+hivecommons-hive[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:40:53 -0400 Subject: [PATCH] test(tide): pin the merge_queue:off no-GraphQL rule, the draft-without-field refusal, and the fork-403 workflow-file boundaries tide.ts sits at 100 % lines and branches, so a 50-mutant single-site probe is what shows which decisions the suite actually pins. Eight mutants passed the full `vitest related src/plugins/tide.ts` set; six are real and each gets one case here, named for the behaviour it pins: - tideMergeQueue.test.ts: the two `merge_queue: off` tests assumed an unhandled /graphql request would fail them, but queueState swallows a failed read with one warning and falls back to a direct merge, so always-querying survived. They now serve /graphql with the call counter and assert zero calls (and no warning). A pull without a `draft` field whose mergeable_state is `draft` is refused rather than enqueued: the only path to queueRefusedStates' `draft` entry, since blockedReason catches `draft: true` first. - tide.test.ts: a draft with an `unknown` state is refused on the first read with no retry sleep (blockedReason's draft check, not the later state check). In the fork-403 diagnosis, exactly five workflow files are listed in full with no "and 0 more", and a nested `docs/.github/workflows/...` path is not a workflow file (startsWith, not includes). getPull reads a head whose repository is gone (`head.repo: null`) as not a fork. The two remaining survivors (`state === 'open'` vs `!== 'closed'` in getPull and tideOnComment) only differ for a state GitHub never emits and are left alone. Closes #517 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: quality <280983584+hivecommons-hive[bot]@users.noreply.github.com> --- __tests__/plugins/tide.test.ts | 47 ++++++++++++++++++++++++ __tests__/plugins/tideMergeQueue.test.ts | 22 ++++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/__tests__/plugins/tide.test.ts b/__tests__/plugins/tide.test.ts index 316c5ef4..8d640399 100644 --- a/__tests__/plugins/tide.test.ts +++ b/__tests__/plugins/tide.test.ts @@ -146,6 +146,15 @@ describe('fetchMergeability', () => { await expect(getPull(octokit, context, 1)).resolves.toMatchObject({ draft: false }) }) + + it('reads a head whose repository is gone (head.repo null) as not a fork', async () => { + servePull(pull([], { + head: { sha: 'headsha', repo: null }, + base: { ref: 'master', sha: 'basesha', repo: { full_name: 'Codertocat/Hello-World' } }, + })) + + await expect(getPull(octokit, context, 1)).resolves.toMatchObject({ fork: false }) + }) }) describe('mergeOnce', () => { @@ -214,6 +223,19 @@ describe('tryMergePullRequest', () => { expect(sleep).not.toHaveBeenCalled() }) + // the draft flag is read before mergeable_state: a draft is refused without waiting for GitHub to compute the state + it('does not wait for an unknown state on a draft pr', async () => { + const gets = servePull(pull(['lgtm'], { ...unknown, draft: true })) + const merge = observeMerge() + const info = vi.spyOn(core, 'info') + + await expect(tryMergePullRequest(octokit, context, 1, tide)).resolves.toBe('skipped') + await expect(merge.notCalled()).resolves.toBe('not called') + expect(gets).toHaveLength(1) + expect(sleep).not.toHaveBeenCalled() + expect(info).toHaveBeenCalledWith('skipping pr #1: not mergeable (draft)') + }) + it('waits for an unknown state when the gate passes, then merges', async () => { const gets = servePull(pull(['lgtm'], unknown), pull(['lgtm'])) const merge = observeMerge() @@ -466,6 +488,31 @@ describe('tryMergePullRequest', () => { expect(body).toContain('workflow files (`.github/workflows/a.yml`, `.github/workflows/b.yml`, `.github/workflows/c.yml`, `.github/workflows/d.yml`, `.github/workflows/e.yml` and 2 more) that it changes.') }) + it('exactly five workflow files are all listed, with no "and 0 more"', async () => { + servePull(forkPull()) + observeMerge(403, forbidden) + const own = ['a', 'b', 'c', 'd', 'e'].map(name => `.github/workflows/${name}.yml`) + const diff = serveDiff([], own) + 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/a.yml`, `.github/workflows/b.yml`, `.github/workflows/c.yml`, `.github/workflows/d.yml`, `.github/workflows/e.yml`) that it changes.') + expect(body).not.toContain('more') + }) + + it('only paths under the top-level .github/workflows/ count: a nested copy is not a workflow file', async () => { + servePull(forkPull()) + observeMerge(403, forbidden) + const diff = serveDiff(['docs/.github/workflows/example.yml'], ['templates/.github/workflows/prow.yml']) + const error = vi.spyOn(core, 'error').mockImplementation(() => {}) + + await expect(tryMergePullRequest(octokit, context, 1, tide)).resolves.toBe('failed') + await expect(diff.compare.called()).resolves.toBe('called') + await expect(diff.comment.notCalled()).resolves.toBe('not called') + expect(error).toHaveBeenCalledWith(expect.stringContaining('Resource not accessible by integration')) + }) + it('a same-repository pull request keeps the raw failure and never compares', async () => { servePull(pull(['lgtm'], { head: { sha: 'headsha', repo: { full_name: 'Codertocat/Hello-World' } }, base: { ref: 'master', sha: 'basesha', repo: { full_name: 'Codertocat/Hello-World' } } })) observeMerge(403, forbidden) diff --git a/__tests__/plugins/tideMergeQueue.test.ts b/__tests__/plugins/tideMergeQueue.test.ts index 1e6b5d89..b1d1870a 100644 --- a/__tests__/plugins/tideMergeQueue.test.ts +++ b/__tests__/plugins/tideMergeQueue.test.ts @@ -146,6 +146,19 @@ describe('evaluateMerge on a branch that requires a merge queue', () => { expect(mutations(calls, 'enqueuePullRequest')).toHaveLength(0) }) + // without a draft field the gate cannot see the draft; mergeable_state: draft is still ours to refuse, not the queue's + it('a pr without a draft field whose mergeable_state is draft is refused, not enqueued', async () => { + const { draft, ...noDraft } = pull(['lgtm'], { mergeable_state: 'draft' }) + expect(draft).toBe(false) + servePull(noDraft) + const merge = observeMerge() + const calls = serveGraphql({ enabled: true }) + + await expect(evaluateMerge(octokit, context, 1, tide)).resolves.toEqual({ result: 'skipped', reason: 'not mergeable (draft)' }) + await expect(merge.notCalled()).resolves.toBe('not called') + expect(mutations(calls, 'enqueuePullRequest')).toHaveLength(0) + }) + it('an unknown state is retried, then enqueued', async () => { const gets = servePull(pull(['lgtm'], { mergeable: null, mergeable_state: 'unknown' }), pull(['lgtm'])) const calls = serveGraphql() @@ -282,7 +295,10 @@ describe('evaluateMerge on a branch that requires a merge queue', () => { servePull(pull(['lgtm'])) const merge = observeMerge(405, { message: 'Changes must be made through the merge queue.' }) vi.spyOn(core, 'error').mockImplementation(() => {}) - // no /graphql handler: an unhandled request would fail the test + // queueState swallows a failed read (one warning, then a direct merge), so an unhandled + // request would not fail the test: count the calls instead + const calls = serveGraphql({ enabled: true }) + const warning = vi.spyOn(core, 'warning') await expect(evaluateMerge(octokit, context, 1, resolveTide({ merge_queue: 'off' }))).resolves.toEqual({ result: 'failed', @@ -290,6 +306,8 @@ describe('evaluateMerge on a branch that requires a merge queue', () => { status: 405, }) await expect(merge.called()).resolves.toBe('called') + expect(calls).toHaveLength(0) + expect(warning).not.toHaveBeenCalled() }) it('debugs once that the queue\'s merge method wins over tide.merge_method', async () => { @@ -374,8 +392,10 @@ describe('dequeue when the gate breaks', () => { it('merge_queue: off: no GraphQL call when the gate fails', async () => { servePull(pull([])) + const calls = serveGraphql({ enabled: true }) await expect(evaluateMerge(octokit, context, 1, resolveTide({ merge_queue: 'off' }))).resolves.toEqual({ result: 'skipped', reason: 'missing lgtm' }) + expect(calls).toHaveLength(0) }) it('a stale lgtm on an in-queue bot-enqueued pr is stripped and dequeued', async () => {