Conversation
…ato#914) `GithubPoller.timeline()` validated the whole `/timeline` response with `timelineEventSchema`, which admits only `labeled` and `unlabeled` entries. The timeline is a heterogeneous, additive GitHub surface, so the first `commented` (or `cross-referenced`, `assigned`, `renamed`, …) entry rejected the array parse — and the throw propagated out of `poll()`, so no candidate on the page was evaluated, not even from issues whose own history is all label events. Since any issue anyone has commented on carries such an entry, `issue.labeled` and `issue.unlabeled` were unusable in practice. `pull_request.opened` and `issue.opened` never reach `timeline()` and were unaffected. Fixed as the issue suggests: filter before validating, via `safeParse` per entry. `timelineEventSchema` stays strict for the entries that matter — a `labeled` entry with a malformed `label` is still dropped rather than crashing the poll — and `reconstructLabelEvents` is untouched. Silently discarding unparseable entries is the right default for an open-ended surface: a new GitHub event type must never be able to stop an automation. Two regression tests, both of which fail on the parent commit: one mixes `commented` and `cross-referenced` entries between two `labeled` ones and asserts the poll still yields the label candidate; one asserts a `labeled` entry with a missing `label` is dropped rather than failing the poll. Reproduced against a real repository before and after: 12 issues in the window, ZodError of 3124 characters before, candidates returned after. Worth a separate look, observed while diagnosing this: that ZodError is handed to `appendLog({ reason })`, whose schema caps `reason` at 2000 characters, so the log-write failure replaces the original error. What surfaces in the preview pane is `Too big: expected string to have <=2000 characters` on path `reason` — a message with nothing to do with `/timeline`, which sends the diagnosis the wrong way. This fix makes that path unreachable for label polls, but the masking itself is still there for any other long failure reason. Closes open-mercato#914
01f4895 to
865a9a7
Compare
|
🤖 |
pat-lewczuk
left a comment
There was a problem hiding this comment.
🔍 Code Review: fix(automations): filter the timeline before validating it (#914)
🎯 Summary
This PR fixes the bug reported in #914: GithubPoller.timeline() validated the entire /timeline response with timelineEventSchema, a schema that only admits labeled and unlabeled entries. Because /timeline is a heterogeneous, additive GitHub surface, the first commented or cross-referenced entry rejected the whole array parse, and the resulting ZodError propagated out of poll() — so no candidate on the page was evaluated, not even from issues whose own history is nothing but label events. Since any issue anyone has ever commented on carries such an entry, issue.labeled and issue.unlabeled automations were unusable in practice.
The fix (packages/cezar/src/automations/github-poller.ts:200-217) is four lines: parse the response as z.array(z.unknown()), then flatMap each entry through timelineEventSchema.safeParse, keeping the successes and dropping the rest. I reviewed both changed files — github-poller.ts and github-poller.test.ts — against the repository's CODE_REVIEW.md and BACKWARD_COMPATIBILITY.md.
This is a good change, and I want to be specific about why rather than just waving it through. It is the minimal correct fix rather than the tempting one: widening event to z.string() and filtering afterwards would also have worked, but it would have cost timelineEventSchema its role as the definition of "an entry we can reconstruct from," and it would have silently accepted a labeled entry with a broken label object. Filtering with safeParse keeps the schema strict exactly where strictness earns its keep. It also matches the house convention already stated in CODE_REVIEW.md — "parse failure degrades to a sane default, never a crash," and "readers skip unparseable lines" — so the poller now behaves like the rest of the codebase's boundary parsing rather than as an exception to it. reconstructLabelEvents is untouched, the change is confined to one private method, and the explanatory comment cites the issue as the repo asks.
I independently verified the regression claim rather than taking the PR description's word for it: with github-poller.ts reverted to its parent state (185c68a7) and the new test file kept, the suite reports 2 failed | 6 passed; with the fix restored, 8 passed. Both new tests are genuine regression guards, not decoration.
I also re-ran the full gate and reached a slightly better result than the PR description reports — see the Validation Gate section: with TMPDIR pointed outside a git work tree, all 6160 tests pass, including the two the description characterises as load-sensitive.
Verdict
✅ approve — the change is correct, minimal, faithful to the diagnosis in #914, covered by two regression tests that I confirmed fail without it, and green across the entire validation gate. It touches no contract surface and cannot affect the pull_request.opened / issue.opened paths, which never reach timeline(). The findings below are two minors and a nit; none of them blocks the merge, and none of them needs to be addressed in this PR.
🧪 Validation Gate
| Command | Status | Notes |
|---|---|---|
npm run typecheck |
✅ PASS | Clean across contract, client, server and web projects. |
npm test |
✅ PASS | 326 files, 6160 passed / 0 failed, when TMPDIR points outside a git work tree. Run with the default TMPDIR of this environment — which happens to live inside the cezar checkout — six tests fail (git-changes.test.ts ×2, directional-usage.test.tsx, agents-section.test.tsx, health-forge.test.ts, projects-api.test.ts). Every one of those failures is the mkdtempSync(join(tmpdir(), …)) artifact the PR description calls out: the "temp" directory lands inside a repository, so assertions like expect(await getRepoInfo(bare)).toBeNull() correctly see the enclosing repo. This is an environment artifact, not a property of the branch. Vitest also emitted 4 EnvironmentTeardownError unhandled rejections from runs/store.test.ts teardown; they are worker-shutdown noise, unrelated to this diff, and did not fail any test. |
npm run test:unit |
✅ PASS | node:test core-module suite, clean. |
npm run build |
✅ PASS | build:server, build:web and the check:pack tarball gate all succeeded. |
npm run test:package |
✅ PASS | Packaged CLI E2E, 15 tests, 0 failures. |
The gate is fully green on this branch. Worth recording for the author: the two tests you flagged as load-sensitive (automations-gate.test.ts, run-isolation.test.ts) also pass in a full-suite run once TMPDIR is clean, so the TMPDIR explanation appears to cover all six failures rather than four of them.
Findings
🔹 Minor
packages/cezar/src/automations/github-poller.ts:157 — the timeline() call site is still unguarded, so the same blast radius remains for transport failures. This fix closes the schema half of #914, but the structural problem the issue's root-cause section also names is untouched: const timeline = await this.timeline(owner, repo, item.number) sits inside the per-item loop of poll() with no try/catch, so any non-schema failure from that one gh api call — a 404 on a transferred or deleted issue, a secondary rate limit, the 30-second GITHUB_COMMAND_TIMEOUT_MS expiring, a malformed non-array body — still throws straight out of poll() and discards every candidate on the page, exactly the failure mode #914 was filed about. scheduler.ts:99 then records the error and recordFailure escalates the backoff toward its six-hour cap, so a single unlucky issue can silence the whole automation. The repository's own CODE_REVIEW.md puts this under "Graceful degradation": GitHub reads are supposed to degrade rather than throw. The fix is small and local — wrap the call so a failed timeline fetch skips that item instead of the page:
let timeline;
try {
timeline = await this.timeline(owner, repo, item.number);
} catch {
continue; // one issue's timeline is unreachable; the rest of the page is still valid (#914)
}I am flagging this as minor rather than major deliberately: it is pre-existing behaviour that this diff does not introduce or worsen, and the PR is right to keep its scope to what #914 asked for. It is a good candidate for a follow-up issue, and I am happy to file one.
packages/cezar/src/automations/github-poller.ts:213-216 — the filter cannot distinguish "an entry we do not care about" from "an entry we should have understood but could not," and silence is only correct for the first. Dropping a commented or cross-referenced entry is expected and uninteresting; dropping a labeled entry whose label object is malformed is a signal that something is wrong, and it currently vanishes without a trace. That second case is not merely invisible — it makes reconstructLabelEvents produce wrong data rather than incomplete data, because its backward walk at github-poller.ts:270-277 starts from the issue's current label set and undoes each transition in turn, which assumes it has been handed every transition. A dropped labeled entry therefore corrupts the reconstructed labels array on every older event in that issue's timeline, and nothing anywhere reports it. The issue itself anticipated this ("If observability matters, a debug counter of dropped entries would be preferable to a throw"). A cheap version that captures the distinction: count only the entries whose raw event field is labeled or unlabeled yet still failed safeParse, and surface that count through the automation log. In practice GitHub always sends label on a labeled event, so this is a robustness and diagnosability point rather than a live bug — hence minor.
💅 Nit
packages/cezar/src/automations/github-poller.ts:205-212 — the comment is eight lines explaining four. It is accurate, well written, and correctly cites #914 as CODE_REVIEW.md requires, so this is purely the author's call. But most of it restates the commit message, and the repository explicitly values modules that read in one sitting. Something like "Filter before validating: /timeline is heterogeneous and additive, so parsing the whole array against timelineEventSchema let the first commented entry kill the poll (#914). The schema stays strict for the entries we do reconstruct from." carries the same information in a third of the space, with the full story remaining one git blame away.
💥 Breaking Changes
- No exported/public symbol removed or renamed without a deprecation path.
timeline()is aprivatemethod; the exportedGithubPoller,buildSearchQuery,reconstructLabelEventsandmatchesFiltersare untouched. - No function signature changed in a breaking way.
timeline()still returnsz.infer<typeof timelineEventSchema>[], which is exactly whatreconstructLabelEvents(…, timeline)atgithub-poller.ts:267expects, so its signature did not need to move either. - No required type field removed or narrowed.
timelineEventSchemais byte-for-byte unchanged; only where it is applied changed. - No HTTP route URL removed or renamed; no method changed for an existing operation. No route in
server.tsis touched. - No field removed or retyped in an existing response shape.
GithubCandidateandGithubPollResultare unchanged, so/api/v1/automations/:id/checkandGET /api/v1/automation-checks/:checkIdkeep their exact shapes. - No event or message name renamed or removed; no payload field removed. The
AutomationEventvocabulary is untouched —issue.labeledandissue.unlabeledsimply start working. - No CLI command or flag renamed or removed; no machine-parsed output format changed.
- No database table or column renamed or removed; no column type narrowed. Not applicable — no schema in this project.
- No config key renamed and no default changed silently.
CEZ_AUTOMATIONS=1gating is untouched. - Where a contract had to change: not applicable, no contract changed.
Checked against BACKWARD_COMPATIBILITY.md section 2 and the "GitHub automations — opt-in gating (#801)" section, which requires the automations routes to "behave exactly as before once the flag is on." This change strictly widens what parses successfully — every input that produced candidates before still produces the same candidates — so it moves toward that guarantee rather than away from it. Persisted formats (automations.json, receipts, the NDJSON execution log, frozen high-watermarks) are all untouched. No protected surface is affected and there is nothing here to warn about.
🧪 Test Coverage
Coverage for this fix is genuinely good, and I verified it rather than inferring it. github-poller.test.ts:90-120 stubs the injected run so /timeline returns a labeled, a commented, a cross-referenced and a second labeled entry, then polls an issue.labeled definition filtered to changedLabels: ['triage'] and asserts one candidate comes back with changedLabel: 'triage' at the right timestamp. That test does double duty: it proves the non-label entries no longer abort the poll, and — because the first labeled entry carries the label other and is correctly filtered out rather than dropped — it proves the surviving entries still flow through reconstructLabelEvents and matchesFilters intact. github-poller.test.ts:122-143 covers the half a widened z.enum would have lost: a labeled entry with no label object is dropped while its well-formed sibling still yields its candidate.
I confirmed both are real regression guards by reverting github-poller.ts to its parent state and re-running the file: 2 failed | 6 passed, with the failures landing on exactly these two tests at github-poller.ts:157. Restoring the fix gives 8 passed. The existing test at github-poller.test.ts:34-88 continues to pass unchanged, so the ordering, cursor and tie-breaker behaviour did not regress.
One optional gap, worth a sentence rather than a finding: nothing asserts what happens when /timeline returns a non-array — a {"message": "Not Found"} error body, say. The outer z.array(z.unknown()).parse will still throw there, which is defensible behaviour, but it is currently untested and therefore unpinned. If you address the first minor above, that try/catch would become the natural place for such a test (expect(result.candidates).toEqual([]) when one item's timeline returns an error body), and I would suggest adding it there rather than in this PR.
Reviewed by om-auto-review-pr. Autofix: skipped (not my PR — re-run with --autofix to fix it here). No autofix was needed regardless: the verdict is approve.
|
🤖
|
|
🤖 The full The whole validation gate is green on this branch: The review leaves two minors and a nit, none of which needs to be addressed here: the Labels: Autofix: skipped (not my PR — re-run with |
…2e (open-mercato#911) Every re-run of the CI workflow failed, on any branch and regardless of the diff, since the nightly-publish work landed in open-mercato#876. The red step was "Run packaged CLI E2E tests", failing one assertion: the nightly version came out as 0.9.9-nightly.20260813.12.2 where the test expected ...12. That trailing .2 is the workflow's run attempt, and computeSnapshot appends it by design whenever the attempt is > 1, so a re-cut nightly cannot collide with a version npm already has. The bug was in the test's environment, not in that behavior: runScript spreads process.env into the child, and under Actions that carries the workflow's own run identity. The suite already neutralizes NODE_AUTH_TOKEN and GITHUB_ACTIONS for exactly this reason, but not GITHUB_RUN_ATTEMPT, so on attempt 2 the ambient value leaked through and every version the orchestrator stamped silently grew a suffix. Locally the variable is unset, which is why it never reproduced on a developer machine. Pin the default in runScript's base env rather than in the one failing test, which also covers the three other call sites that were latently exposed; a test wanting re-run behavior overrides it through extraEnv. Adds an e2e case that passes attempt 2 explicitly and asserts the suffix end-to-end, pinning both the collision-avoidance contract and the fact that the default is a default rather than a hard override. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit feb8566)
🔍 Code Review: PR #916 — CI follow-up🎯 SummaryThe existing review approves the timeline-filter fix and explicitly says its two minor suggestions and comment-length nit do not need to be addressed in this PR. There are no inline review threads and the PR is mergeable without conflicts. The current failed CI run fails in the nightly snapshot package test, not the timeline parser. The failure reproduces on the PR head with Upstream already fixed this in #911, commit VerdictThe identified CI defect is fixed and pushed to the existing PR as 🧪 Validation Gate
Validation used Findings⛔ Blocker
🔍 Inherited reviewer feedbackThe existing review approves the change. Its transport-failure isolation and malformed-label observability suggestions remain follow-up work, as that review explicitly recommends. Adding transport skipping requires a separate cursor/retry analysis; it is outside this CI repair. The optional comment shortening is declined because the current explanation is accurate and does not block approval. No required review ask is outstanding. 💥 Breaking Changes
🧪 Test CoverageThe original nightly test fails without this patch under HandoffThe existing The author approved the push. 🤖 The new CI run targets 🤖 |
Fixes #914 — automations on
issue.labeled/issue.unlabeledfailed on every poll against a real repository.The bug
GithubPoller.timeline()validated the whole/timelineresponse withtimelineEventSchema, which admits onlylabeledandunlabeledentries. The timeline is a heterogeneous, additive GitHub surface, so the firstcommented(orcross-referenced,assigned,renamed, …) entry rejected the array parse — and the throw propagated out ofpoll(), so no candidate on the page was evaluated, not even from issues whose own history is nothing but label events.Since any issue anyone has commented on carries such an entry, the two label events were unusable in practice.
pull_request.openedandissue.openednever reachtimeline()and were unaffected.The fix
Exactly what the issue suggests — filter before validating, via
safeParseper entry:timelineEventSchemastays strict for the entries that matter — alabeledentry with a malformedlabelis still dropped rather than crashing the poll — andreconstructLabelEventsis untouched. Widening thez.enumtoz.string()and filtering afterwards would also work, but it would cost the schema its role as the definition of "an entry we can reconstruct from".Silently discarding unparseable entries is the right default here: the timeline is open-ended, and a new GitHub event type must never be able to stop an automation.
Tests
Two regression tests in
github-poller.test.ts, both of which fail on the parent commit:keeps polling when the timeline carries entries other than labeled/unlabeled— mixescommentedandcross-referencedentries between twolabeledones, asserts the poll still yields the label candidate.drops a labeled entry whose label is malformed instead of failing the poll— guards the half that a widened schema would have lost.Also verified end to end against a live repository: the poll raised a
ZodErrorbefore the change and returned candidates after it.The full validation gate is clean on this branch:
npm run typecheck,npm run test:unit,npm run build(includingcheck:pack), andnpm run test:packageall pass.npm testreports 6154 passed, 6 failed (6160), none of them in this code path: four (git-changes.test.ts×2,directional-usage.test.tsx,agents-section.test.tsx) fail identically on the parent commit, and the other two (automations-gate.test.ts,run-isolation.test.ts) are load-sensitive — they pass on parent and on this branch alike when run on their own.One note on running the suite outside a scratch directory
Several server tests fail when
TMPDIRpoints inside a git work tree —mkdtempSync(join(tmpdir(), …))then lands in a repository, so assertions likeexpect(await getRepoInfo(bare)).toBeNull()see the enclosing repo.TMPDIR=/tmpclears most of them. Not part of this PR, just what tripped me up first.