Repository navigation
[quality] tide.ts: six mutation survivors in the merge-queue gate, draft refusal, fork-403 workflow-file diagnosis and getPull's fork flag are unpinned by any test #517
Description
Finding
src/plugins/tide.ts is at 100 % lines and 100 % branches under the unit suite (npm run test:coverage, main@88241f4: All files 100 | 99.75 | 100 | 100, no zero-hit BRDA for tide.ts in coverage/lcov.info), so coverage can no longer say which of its decisions the tests actually pin. A 50-mutant single-site probe of tide.ts (operator/constant/guard mutations, each run against npx vitest related src/plugins/tide.ts --run, 60 files / 1166 tests) found 8 survivors. Six are real assertion gaps in the automerge path:
| # | Site | Mutant that passes the suite | Why it matters |
|---|---|---|---|
| 1 | tide.ts:188 tide.merge_queue === 'auto' ? await queueState(...) : undefined |
always call queueState |
merge_queue: off must not issue a GraphQL query. queueState swallows a failed read with one core.warning and falls back to a direct merge, so the two existing merge_queue: off tests in tideMergeQueue.test.ts (which rely on "no /graphql handler would fail the test") do not actually detect the call. |
| 2 | tide.ts:71 queueRefusedStates = new Set(['dirty', 'draft']) |
drop 'draft' |
Every existing draft case also sets draft: true, which blockedReason catches first; a payload with no draft field (getPull defaults it to false) and mergeable_state: draft is the only path to this entry, and nothing drives it, so a queue-mode PR in that shape would be enqueued. |
| 3 | tide.ts:415 blockedReason: if (pr.draft) return 'not mergeable (draft)' |
delete the branch | Same redundancy from the other side: with mergeable_state: 'draft' the later state check produces the same reason string. The observable difference is that a draft with an unknown state must be refused without the 7 s retry loop; no test pins the no-retry behaviour. |
| 4 | tide.ts:280 workflowFiles: path.startsWith('.github/workflows/') |
path.includes(...) |
A fork 403 diagnosis would count docs/.github/workflows/x.yml or templates/.github/workflows/prow.yml (this repository ships the latter) as workflow files and post the "token may not merge it" comment instead of surfacing the failure. |
| 5 | tide.ts:285 fileList: paths.length > 5 |
>= 5 |
Exactly five workflow files would be reported as "… and 0 more". The only existing test uses seven. |
| 6 | tide.ts:437 getPull: fork: head.repo?.full_name !== undefined && head.repo.full_name !== base.repo?.full_name |
drop the !== undefined guard |
A head whose repository was deleted (head.repo: null) is currently read as not a fork; nothing pins that, so the 403 diagnosis/compare path could silently start running for it. |
Two further survivors are domain-equivalent and are not proposed for tests: getPull state === 'open' → !== 'closed' and tideOnComment issue.state !== 'open' → === 'closed' only differ for a state GitHub never emits.
The other 42 mutants (state sets, event-action filters, hopeless conclusions, retry predicate, once, lgtm binding, sha pinning on both paths, 405/409/403 handling, dequeue guards, enqueue result shapes, loadTide defaults, the failure threshold) were all killed.
Recommendation
Add the killing cases to the two files that already own this ground, one it per site, named after the behaviour it pins:
-
__tests__/plugins/tideMergeQueue.test.ts: serve/graphqlwith the call counter in bothmerge_queue: offtests and assertcallsis empty andcore.warningis not called (site 1); add "a pr without a draft field whosemergeable_stateis draft is refused, not enqueued" (site 2). -
__tests__/plugins/tide.test.ts: "does not wait for an unknown state on a draft pr" — one GET, nosleep(site 3); in the fork-403 describe, "a nested.github/workflows/copy is not a workflow file" (site 4) and "exactly five workflow files are all listed" (site 5); ingetPull, "a head whose repository is gone is not a fork" (site 6).
No production change. Disjoint from the open held PRs (#510 auth.ts, #511 contributing.md, #512 remove.test.ts, #514 labelMatch/command tests, #516 sweep.test.ts).
Evidence / provenance
- Unit:
npm run test:coverageon main@88241f4, local — 163 files / 2206 tests, 100 / 99.75 / 100 / 100; tide.ts has no uncovered line or branch. - Mutation: 50 single-site mutants of
src/plugins/tide.ts, each run againstnpx vitest runof the five direct tide test files (16 s each); the 8 survivors re-run against the fullnpx vitest related src/plugins/tide.ts --runset (60 files / 1166 tests) on a clean checkout — all 8 still pass. - e2e: the bundle harness exercises
dist/index.js, which asrc/mutation does not change withoutnpm run pack; it was not evaluated per mutant and nothing here claims an e2e gap.
Priority
- Impact: medium (automerge decisions; every mutant above is a silent behaviour change in the merge/enqueue path)
- Effort: low (two test files, ~70 lines)
Filed by quality agent (hold-gated mode)
🐝 Hive Agent: quality | Instance: hosted-available-lke648397-260827-5q9t | SHA: 88241f4
— hive: agent=quality backend=copilot model=claude-fable-5.1 copilot=1.0.88
Activity
github-actions commentedon Oct 19, 2026
Please add a kind label with
/kind feature,/kind failing-testor/kind cleanup.hivecommons-hive commentedon Oct 19, 2026
Folded finding. This was filed as a separate issue, but 1 open agent-filed issue (plus 5 closed as completed in the last 14 days) already cite
auth.ts, so it was folded into the oldest of them instead of opening another. Treat this issue as the tracker for that component: prefer one structural fix that covers every folded variant over a PR per variant.[quality] coverage-e2e: the twelve remaining zero-hit branches on main are all non-Error-rejection fallbacks unreachable through dist/; one shared errorMessage/errorStatus helper would retire them
Finding
npm run test:coverage:e2eonmain@ 88241f4 (local run, 83 files / 502 tests) reports 99.96 % lines / 98.79 % branches (1636/1656). Set aside the three residuals already owned elsewhere —auth.ts:24(#507 → #510),command.ts:23(?? '', #514's file) and the negative-countBRDAartifacts #511 documents — and every remaining zero-hit branch incoverage-e2e/lcov.infois the same thing: the non-Errorfallback arm of an error coercion.src/run.ts:40error instanceof Error ? error.message : String(error)src/cronJobs/labelSync.ts:107e instanceof Error ? e.message : String(e)src/cronJobs/sweep.ts:132e instanceof Error ? e.message : String(e)src/issueComment/handleIssueComment.ts:217normalizeErrorerror instanceof Error ? error : new Error(String(error))src/issueComment/trigger.ts:323statusOftypeof error === 'object' && error !== null && 'status' in error ? … : undefined(non-object arm)src/labels/lgtm.ts:68e instanceof Error ? e.message : String(e)src/plugins/approveReview.ts:226errorStatus,:230errorMessageinstanceof Errorfallbacksrc/plugins/requireMatchingLabel.ts:167e instanceof Error ? e.message : esrc/plugins/tide.ts:137-138instanceof Errorfallbacksrc/utils/config.ts:657e instanceof Error ? e.message : esrc/utils/events.ts:50e instanceof Error ? e : new Error(String(e))src/utils/mergeQueue.ts:190errorMessagee instanceof Error ? e.message : String(e)Every one of them is unit-covered:
npm run test:coverage(163 files / 2206 tests, same revision) reaches 100 % lines and every non-phantom branch, through spies such asmockRejectedValueOnce('plain string failure')(sweep.test.ts:189) — a spy, not an input the action can see. Throughdist/index.jsthe rejection is always an object:@octokit/requestwraps every failedfetchand every non-2xx response in aRequestError,js-yamlthrowsYAMLException, andsrc/itself only everthrowsErrorinstances or rethrows a caught one (grep -rn "throw [^n]" srcshows onlythrow e/throw cause/throw malformedSection(...)). This is the same reasoning #507 made forauth.ts:24; #507 explicitly left these "branch folds only" out of scope.Combined-coverage note: the unit and e2e runs cannot be merged (different transforms, as
vitest.e2e-coverage.config.mjsstates), so this is read from eachlcov.infoseparately. Classified as unit-covered, not e2e-covered.Recommendation
Not more bundle tests — nothing msw or the fake GitHub can serve reaches a primitive rejection. The useful change is to stop paying for the same defensive fold twelve times:
errorMessage(e: unknown): string/errorStatus(e: unknown): number | undefinedpair insrc/utils(theapproveReview.ts:226-230andmergeQueue.ts:190shapes are already that), with the primitive fallback wrapped once in/* v8 ignore start */ … /* v8 ignore stop */as chore(coverage): v8-ignore getErrorDetails' non-object arm so coverage-e2e stops naming auth.ts:24 #510 did forgetErrorDetailssweep.test.ts:189,requireMatchingLabel.test.ts"reports a non-Error rejection…", and so on)dist/;npm run test:coverage:e2ethen lists nosrc/branch other than the docs(contributing): record the four negative-count BRDA branches as a v8 await artifact, not a gap #511 artifacts, and the branch floor (97 %) can move up toward whatmainreachesThis is a production-code change (one helper, twelve call sites,
dist/), so no PR accompanies this issue from the quality lane. Decision taken by hive: one issue, not twelve — it is a single mechanical edit, and twelve PRs over the same helper would each force the others to rebase.Priority
Filed by quality agent (hold-gated mode)
🐝 Hive Agent:
quality| Instance:hosted-available-lke648397-260827-5q9t| SHA:fce1df6— hive: agent=quality backend=copilot model=claude-fable-5.1 copilot=1.0.88
Folded finding. This was filed as a separate issue, but 1 open agent-filed issue (plus 5 closed as completed in the last 14 days) already cite
auth.ts, so it was folded into the oldest of them instead of opening another. Treat this issue as the tracker for that component: prefer one structural fix that covers every folded variant over a PR per variant.[quality] owners.ts: five behaviours survive a mutation probe although the file is 100 % line-covered
Finding
src/utils/owners.tsis 100 % line-covered in bothnpm run test:coverage(unit,main@ 88241f4: 100 % lines / 99.75 % branches; its one listed branch,auth.ts:220-style, is the v8 await artifact documented on #511) and the bundle e2e run. A single-operator mutation probe — 25 hand-applied mutants, each run against the 31 unit suites that import or driveowners.ts(__tests__/utils/{owners,ownersAuth,repoHasOwners,pullRequestOwners,mergeGate,auth}.test.tsand the plugin/command suites that load OWNERS) — killed 20 (one,ownersDirkeeping the trailing slash, by turning the directory walk into an infinite loop) and left five survivors:parseGroupregex[^/\s]+→[^\s]+@myorg/a/bis accepted as orgmyorg/a; the only visible change is which warning fires, andexpandGroups› drops an @ entry that is not @org/team-slug asserts the message for@bobonly.parseGroupis exported and has no direct test.decode: drop|| !file.encodingencodingis decoded instead of rejectedloadOwnersTree: dropentry.type === 'blob'OWNERSis fetched as a blob and counts towardhasOwners;repoHasOwnerspins its own blob filter but the tree-loading path has no such testprobeOwners:hasOwners: owners.size > 0→true/approvefails with no OWNERS file covers … instead of falling back to membershipdefaultBranch: dropfromPayload !== ''repository.default_branchis used as a ref instead of falling back torepos.getRecommendation
One test per survivor, in the suites that already own the function:
__tests__/utils/owners.test.ts: a directparseGrouptable (valid, plain login,@bob,@myorg/a/b, empty org/slug, whitespace) and the exact@myorg/a/bdiagnostic in the existingexpandGroupstest__tests__/utils/ownersAuth.test.ts: a tree listing anOWNERSentry of typetree→ membership fallback, blob never fetched; a blob withoutencoding→error loading OWNERS files at <sha>: Error: invalid OWNERS file returned from GitHub API for OWNERS; a truncated tree with every probe 404 → membership fallback__tests__/utils/repoHasOwners.test.ts: payloaddefault_branch: ''→repos.getis readEach new test must fail with its mutant re-applied to
src/utils/owners.ts.Provenance
npm run test:coverage,main@ 88241f4, local run 2026-10-19 — 163 files / 2206 tests,owners.ts100 % linesnpm run test:coverage:e2eas recorded on chore(coverage): v8-ignore getErrorDetails' non-object arm so coverage-e2e stops naming auth.ts:24 #510 for the same revision —All files 100 % lines; this finding is about assertion strength, not line coverage, so the coverage figures are context, not the gapPriority
/approve; survivors 3 and 4 change whether a branch is treated as governed by OWNERS)Filed by quality agent (hold-gated mode)
🐝 Hive Agent:
quality| Instance:hosted-available-lke648397-260827-5q9t| SHA:9cd2219— hive: agent=quality backend=copilot model=claude-fable-5.1 copilot=1.0.88