feat(tracker): ApplyTrackerFacts reducer + shared observer skeleton (#112)#116
Conversation
Greptile SummaryThis PR lands two scaffolding pieces ahead of the Tracker observer (#35): the
Confidence Score: 5/5Safe to merge — all changed paths are either new scaffolding with no live callers yet, or refactors of the SCM observer that preserve identical runtime behaviour. The two bugs surfaced in earlier review rounds were both fixed and covered by regression tests before this review. The SCM refactor is behaviourally neutral. No new issues were found. No files require special attention. Important Files Changed
Reviews (3): Last reviewed commit: "test(observe): capture deadline once in ..." | Re-trigger Greptile |
…ent filter Two P1 review findings on #116: 1. observe.CheckCredentialsOnce was returning (true, nil) on every call after the gate ran, even when the probe had marked the observer disabled, because the *checked short-circuit ignored *disabled. The SCM observer didn't surface this in practice — its Poll method has an independent `if o.disabled { return nil }` guard that runs first — but a future Tracker observer that relies on the helper's documented contract ("Observer stays disabled") would silently flip back to "credentials available" after the first poll. Change the short-circuit to `return !*disabled, nil` and lock the behavior with a regression test that issues repeat calls after the probe reported unavailable. 2. lifecycle.newBotCommentContent's "skip uninteresting comments" filter used && where it needed ||. A bot comment with an empty ID but a non-empty body slipped through and appended "" to the ids slice. If every bot comment in the observation had an empty ID, strings.Join produced "" — which matches the zero value of the in-memory dedup map, so sendOnce treated the nudge as already-sent and silently suppressed it forever. Switch to || so any comment missing either an ID or a body is dropped, and add a regression test that an empty-ID bot comment never nudges (and does not pollute the dedup state for a follow-up comment that has a real ID). 586 tests pass with -race. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Move the observer-pattern-general pieces of the SCM observer into a new backend/internal/observe package so the tracker observer (issue #35) can build on the same primitives: - StartPollLoop: goroutine supervisor with immediate-first-poll + ticker + ctx-done exit. SCM Observer.Start now delegates to it. - CheckCredentialsOnce: lazy first-poll credential gate driven by a CredentialProbe closure. SCM observer keeps credentialsChecked/disabled as Observer fields; the shared helper mutates them via pointer so state ownership stays single-source. - CacheSet[V any] / CacheDelete[V any]: one generic bounded-FIFO helper replaces the three near-identical cacheSet{String,Time,Bool} bodies and the standalone evictStrings. The SCM-side methods are now one-line wrappers that thread o.Cache.max into the shared helper, so existing call sites and tests are untouched. SCM behavior is unchanged. The full 21-test SCM suite (including the end-to-end test added in PR #115) plus 577 backend tests stay green under `go test -race`. Part of #112. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Land the contract that the future Tracker observer (issue #35) and its provider adapters must satisfy. No observer is wired in this PR — the DTO + reducer are the deliverable, and locking the shape now lets the observer + adapter work happen in small follow-up PRs. DTO (backend/internal/ports/tracker_observations.go): - TrackerObservation mirrors ports.SCMObservation: Fetched bool, ObservedAt time.Time, Provider/Host/Repo, normalized Issue facts, Comments, and a Changed{State, Assignee, Comments} discriminator. - TrackerIssueObservation carries the minimal facts lifecycle needs today (state, assignee, title, body, timestamps); richer per-provider metadata stays inside each adapter. - TrackerCommentObservation carries the comment fields needed for the bot-mention nudge (Author, Body, IsBot, ID for dedup). Reducer (backend/internal/lifecycle/reactions.go): - ApplyTrackerFacts(ctx, sessionID, ports.TrackerObservation) error, mirroring ApplySCMObservation's "Fetched gate → terminal-state → per-bucket reactions" shape. - Three initial reactions: * Issue state == done | cancelled → MarkTerminated (idempotent). * Changed.Assignee → log only via slog.Default(). The "assignee changed away from AO" policy is reserved for #40. * Changed.Comments with bot comments → one-time nudge with strings.Join'd bot bodies, deduped by comment IDs. - The nudge path reuses sendOnce with an empty prURL so the in-memory dedup applies but the PR-row persistence path is skipped. Tracker signature persistence will land with #35 alongside issue-row storage. Tests in backend/internal/lifecycle/manager_test.go cover each branch: terminate (done + cancelled), log-only assignee, nudge fires on new bot comment, nudge suppressed on repeat, new bot comment id refires, not-fetched is no-op, terminated session ignores observations. Part of #112. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
golangci-lint revive flagged the CacheSet generic helper's max parameter as shadowing the built-in max() function. Rename to maxEntries; signature change is internal to the observe package and the SCM observer's one-line wrappers pass the value positionally, so no call sites need updating. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ent filter Two P1 review findings on #116: 1. observe.CheckCredentialsOnce was returning (true, nil) on every call after the gate ran, even when the probe had marked the observer disabled, because the *checked short-circuit ignored *disabled. The SCM observer didn't surface this in practice — its Poll method has an independent `if o.disabled { return nil }` guard that runs first — but a future Tracker observer that relies on the helper's documented contract ("Observer stays disabled") would silently flip back to "credentials available" after the first poll. Change the short-circuit to `return !*disabled, nil` and lock the behavior with a regression test that issues repeat calls after the probe reported unavailable. 2. lifecycle.newBotCommentContent's "skip uninteresting comments" filter used && where it needed ||. A bot comment with an empty ID but a non-empty body slipped through and appended "" to the ids slice. If every bot comment in the observation had an empty ID, strings.Join produced "" — which matches the zero value of the in-memory dedup map, so sendOnce treated the nudge as already-sent and silently suppressed it forever. Switch to || so any comment missing either an ID or a body is dropped, and add a regression test that an empty-ID bot comment never nudges (and does not pollute the dedup state for a follow-up comment that has a real ID). 586 tests pass with -race. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The `TestStartPollLoop_LogsPollErrorWithoutPanic` spin-wait was computing the loop bound as `time.Now().Before(time.Now().Add(200ms))` on every iteration, which is permanently true — the loop could only exit via the `break`. Under a scheduler delay (heavy CI load or `GOMAXPROCS=1`) where two polls never land in time, the test would hang until the wall-clock kill rather than failing fast. Capture the deadline once before the loop, and tighten the assertion to actually require two polls + done-channel closure within a bounded window, matching `TestStartPollLoop_FirstPollImmediateThenTicks`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
4713587 to
8ec9b46
Compare
There was a problem hiding this comment.
harshitsinghbhandari has reached the 50-review limit for trial accounts. To continue receiving code reviews, upgrade your plan.
Closes #112.
Lands the two scaffolding pieces from the issue so the Tracker observer (issue #35) and its provider adapters can be built in small, reviewable follow-ups instead of one monolithic PR.
Part 1 —
ports.TrackerObservationDTO +lifecycle.Manager.ApplyTrackerFactsbackend/internal/ports/tracker_observations.gomirrorsscm_observations.go's field-level documentation style:Fetched bool,ObservedAt time.Time,Provider/Host/Repo, normalizedTrackerIssueObservation,Comments, and theChanged{State, Assignee, Comments}discriminator.lifecycle.Manager.ApplyTrackerFactsinbackend/internal/lifecycle/reactions.gomirrorsApplySCMObservationshape: Fetched gate → terminal-state shortcut → per-bucket reactions. Three reactions:doneorcancelled→MarkTerminated(idempotent).Changed.Assignee→ log only viaslog.Default(). Per-policy reaction is reserved for Tracker: mirror agent lifecycle to issue via Comment + Transition #40.Changed.Commentscarrying bot comments → one-time nudge with bodies concatenated; deduped by comment IDs viasendOncewith emptyprURL(in-memory only — issue-row persistence ships with Tracker observer loop: poll + ApplyTrackerFacts into LCM #35).backend/internal/lifecycle/manager_test.gocover terminate (done + cancelled), log-only assignee, nudge fires, nudge suppressed on repeat, new bot id refires, not-fetched no-op, terminated-session no-op.No observer is wired in this PR — that's intentional. The contract is the deliverable.
Part 2 — Shared observer skeleton extraction
backend/internal/observepackage (one-paragraph package doc on top ofobserver.go) carries the observer-pattern-general pieces:StartPollLoop(ctx, tick, poll, logger, name)— immediate first poll + ticker + ctx-done exit. SCMObserver.Startnow delegates to it.CredentialProbe+CheckCredentialsOnce(ctx, probe, checked, disabled, logger, name)— lazy first-poll credential gate. The SCM observer keepscredentialsChecked/disabledas Observer fields; the shared helper mutates them via pointer so state ownership stays single-source.CacheSet[V any](m, order, max, key, value)+CacheDelete[V any](m, order, key)— one generic bounded-FIFO helper collapses the three near-identicalcacheSet{String,Time,Bool}bodies and the standaloneevictStrings. The SCM-side methods are kept as one-line wrappers that threado.Cache.maxthrough, so call sites and tests are untouched.backend/internal/observe/observer_test.goadds 11 small unit tests for the shared package, including aCacheSet-over-time.Timecase to exercise the generic.SCM behavior is unchanged. Both the existing 21-test SCM suite and the end-to-end test added in PR #115 stay green without modification.
Acceptance checklist (from issue body)
ports.TrackerObservation+TrackerChangedexist with field-level documentation matching the SCM DTO style.lifecycle.Manager.ApplyTrackerFactsexists as a working reducer with the three initial reactions covered by unit tests.go build ./... && go test -race ./...clean (585 tests, 39 packages).Validation
→ 585 tests pass across 39 packages.
🤖 Generated with Claude Code