Skip to content

fix: move fork check from repo listing into trust-gate filter chain - #3

Merged
bborbe merged 4 commits into
masterfrom
feature/fork-trust-gate
Jul 31, 2026
Merged

fix: move fork check from repo listing into trust-gate filter chain#3
bborbe merged 4 commits into
masterfrom
feature/fork-trust-gate

Conversation

@bborbe

Copy link
Copy Markdown
Owner

Summary

Bumps github.com/bborbe/maintainer to v0.48.0 and moves the fork check out of ListRepos/mapGitHubRepos (repo listing, upstream of every filter) into the TaskCreationFilter chain as a new filter.NewForkFilter trust gate on .maintainer.yaml: release.allowFork.

Why: forks were previously dropped silently at listing time — a fork with autoRelease: true never released and emitted no log line at all (found on bborbe/tts-mcp; cost ~40min to diagnose). Moving the decision into the filter chain makes it observable the same way every other gate already is (Metrics.IncFilterSkipped("fork") + a glog line naming the repo and the reason).

Changes

  • pkg/repo.goRepo.Fork bool
  • pkg/githubclient.gomapGitHubRepos now keeps forks (sets Fork), only drops archived + empty-name. ListRepos's per-poll log line gained forks=N.
  • pkg/filter/fork_filter.go (new) — NewForkFilter(): non-forks always pass; forks pass only when AllowFork is true; otherwise skip reason "fork".
  • pkg/filter/filter.goRelease.Fork / Release.AllowFork fields + updated chain doc.
  • pkg/watcher.go — threads Fork/AllowFork from Repo/.maintainer.yaml into the filter input; logs github-release-watcher skipping fork <repo> reason=allowFork-not-set on a fork skip (the watcher parses .maintainer.yaml leniently, so a typo like alowFork: silently stays false — this log line is what makes that debuggable).
  • pkg/factory/factory.go — registers NewForkFilter() in CreateStaticFilters.
  • pkg/release.go, pkg/metrics.goAllowFork plumbing + "fork" label added to the documented/pre-initialized IncFilterSkipped reason set.
  • README — updated filter-chain description.
  • Tests: pkg/filter/fork_filter_test.go (fork+allow → emits, fork+no-allow → skip, non-fork → unchanged regardless of AllowFork), pkg/watcher_test.go (end-to-end fork gate through Poll), pkg/githubclient_test.go (updated: forks now kept at listing, archived still dropped).

Notes on in_scope semantics

in_scope (the per-poll ListRepos log line) now includes forks — previously they were dropped before that count. Prod baseline before this change: total=235 private=29 in_scope=227 (227 = 235 minus 8 archived/forked/etc at the time). Post-change, in_scope will include forks (with a new forks=N field alongside it so the shift is legible), and eligibility for those forks is decided downstream by the new gate — visible per-repo via the fork skip metric/log, not folded silently into in_scope's meaning.

## Unreleased bullet added to CHANGELOG.md; this repo has release.autoRelease: true so the release/rename is left to the auto-releaser — not touched here.

Test plan

  • make precommit green (tests, lint, vet, vuln scan, trivy)
  • Operator: flip to ready + merge once satisfied

bborbe added 2 commits July 31, 2026 21:49
Forks were previously dropped silently at ListRepos/mapGitHubRepos time,
upstream of the whole TaskCreationFilter chain, so a fork with
autoRelease: true never released and emitted no log line (found on
bborbe/tts-mcp; cost ~40min to diagnose).
Bump github.com/bborbe/maintainer to v0.48.0 (adds ReleaseConfig.AllowFork).
Forks now enter the scan set (Repo.Fork), pass a new filter.NewForkFilter
gate when .maintainer.yaml: release.allowFork: true, and are skipped with
reason "fork" (metric + glog line naming the repo) otherwise. Archived
repos are still dropped at listing. The per-poll listing log now reports
forks=N alongside total/private/in_scope.
@bborbe
bborbe marked this pull request as ready for review July 31, 2026 19:58

@ben-s-pull-request-reviewerben-s-pull-request-reviewerBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now I have all the information needed to produce the review. Let me compile the findings:

Step 4b-i judgment-rule candidates: The diff touches CHANGELOG.md, Go source files, go.mod. Judgment rules triggered: changelog/conventional-prefix-required (CHANGELOG.md changed). All other judgment rules have triggers (e.g. main.go, agents/**) not present in this diff. Candidates: 1 applicable, rest skipped.

Step 4c-sel CLASSIFY:

  • changelog/conventional-prefix-requiredapplicable (CHANGELOG.md modified; rule checks ## Unreleased bullets)
  • All other judgment rules → skipped (trigger patterns don't match changed files)
  • Mechanical findings → non-empty (proceed to adjudication)

Step 4d-sel ADJUDICATE — reviewing mechanical findings + diff:


PR Review: fix: move fork check from repo listing into trust-gate filter chain

Branch: feature/fork-trust-gatemaster
Commit: 3998ede


Must Fix (Critical)

1. Missing //counterfeiter:generate on exported interfaces (go-architecture/counterfeiter-directive-on-interface)
Multiple service interfaces lack the directive, meaning generated mocks will silently drift from the contract:

  • pkg/watcher.go:19Watcher interface
  • pkg/metrics.go:12Metrics interface
  • pkg/githubclient.go:29GitHubClient interface
  • pkg/filter/filter.go:39TaskCreationFilter interface

Fix: Add //counterfeiter:generate -o ../../mocks/<name>.go --fake-name <Name> . <Interface> above each interface.


Should Fix (Important)

2. TaskCreationFilters type name violates functional-composition pattern (go-functional-composition/list-type-name) — pkg/filter/filter.go:54
The slice aggregator for TaskCreationFilter is named TaskCreationFilters instead of TaskCreationFilterList. This breaks the XxxFunc/XxxList naming pair convention — consumers find TaskCreationFilterFunc but cannot locate the aggregator by the expected name.

3. Fork-skip log uses bare glog.Infof instead of glog.V(2).Infof (go-glog/use-v-for-debug-not-info) — pkg/watcher.go:171
The log line github-release-watcher skipping fork %s reason=allowFork-not-set is V0 (always-on in production). This is a per-repo event inside a loop — it belongs at V(2) (sysop debug) or V(3) (deep debug), not V0. V0 is reserved for startup/shutdown/health changes. The adjacent glog.Infof at line 171 naming the repo+reason is the documented "fix" for the silent-drop incident; it should be glog.V(2).Infof.

4. glog.Infof listing summary uses V0 for per-cycle aggregate (go-glog/use-v-for-debug-not-info) — pkg/githubclient.go:136
The line github-release-watcher listed installation repos owner=%s total=%d private=%d forks=%d in_scope=%d fires once per poll cycle and is a production heartbeat. This is borderline V0-worthy, but since the count is an aggregate (not an operator-signal event), V(1) would be more appropriate. This is a judgment call — the mechanical flag fires; leaving at V0 is defensible but imprecise.

5. Fork trust gate has no test coverage for the maintainer-config plumbing path (go-testing/counterfeiter-mocks-required) — pkg/watcher_test.go
The fork-filter unit tests (pkg/filter/fork_filter_test.go) cover the pure filter logic. The integration path — Repo.Fork being threaded through gatherRelease → Release.AllowFork → filter.Release → ForkFilter.Skip — is not covered in watcher_test.go. The existing watcher tests in that file need a case like: given a fork repo with maintainerCfg.Release.AllowFork=true → expect filter passes.

6. Missing context cancellation check in TaskCreationFilters.Skip (go-context/cancel-check-in-loop, go-functional-composition/list-checks-ctx-done) — pkg/filter/filter.go:59
The Skip method iterates over the filter chain without a select { case <-ctx.Done(): ...; default: } between members. If a long filter chain is running and the context is cancelled, the loop continues to completion instead of aborting early. Since Skip takes a Release (not a ctx), consider whether it should accept a context.Context at all — if the filter chain is meant to be cancellation-safe, the ctx check belongs here.

7. Loop in ListRepos/mapGitHubRepos lacks per-iteration ctx check (go-context/cancel-check-in-loop) — pkg/githubclient.go:157
The mapGitHubRepos loop iterates over repositories without checking ctx.Done(). While this loop bounds at one page (≤100 items), it is a "long loop" by rule definition when the context is cancelled from above. A select { case <-ctx.Done(): return nil; default: } at the top of the for _, repo := range repos loop is warranted.


Nice to Have (Optional)

8. go.mod updated to github.com/bborbe/maintainer v0.48.0 — verify the new AllowFork field exists in that version's maintainerconfig.MaintainerConfig struct. The PR description cites v0.48.0 as the version that introduced the release.allowFork field. No code change can verify this, but it should be confirmed against the dependency before merge.

9. metrics.go:27metricNamespace is untyped string constant (go-enum-type/typed-constants-with-collection)
The metricNamespace = "github_release_watcher" is an untyped const with no paired type MetricNamespace string + AvailableMetricNamespaces. This predates the PR but is visible in the changed files. Low priority since Prometheus metric namespaces are not a closed enum in practice.


Correctness Assessment (Plan Concerns Resolved)

The PR's core concerns are addressed:

ConcernResolution
Forks now kept in ListRepos output (Repo.Fork carries flag)pkg/githubclient.go:mapGitHubRepos keeps forks with Fork: repo.GetFork()
Fork and AllowFork correctly threaded into filter.Releasepkg/watcher.go:156–163 populates Fork: repo.Fork, AllowFork: release.AllowFork
maintainerconfig.MaintainerConfig v0.48.0 API matches usagemaintainerCfg.Release.AllowFork sourced and wired
Fork-skip log fires only when reason == "fork"pkg/watcher.go:170 guard if reason == "fork" is correct
IncFilterSkipped("fork") pre-initialized in metricspkg/metrics.go:71 includes "fork" in the pre-init set
Fork filter test covers pass/skip/pass-for-non-forkpkg/filter/fork_filter_test.go exists

Traceability (Selector Mode)

  • Candidates: 1 rule matched by Step 4b-i glob filter
  • Applicable: 1 (changelog/conventional-prefix-required)
  • Skipped: 60+ judgment rules — all had trigger patterns (e.g. main.go, agents/**, commands/**) absent from diff; adr/required-for-irreversible-architecture-decisions skipped because a filter-chain reorganization is not an irreversible architectural decision

Selector Mode: Classify Traceability

  • Candidates: 1 (from Step 4b-i glob filter matching CHANGELOG.md)
  • Applicable: 1 (changelog/conventional-prefix-required)
  • Skipped:
    • adr/required-for-irreversible-architecture-decisions → diff is a bugfix filter-chain change, not irreversible architecture
    • All other ~60 judgment rules → trigger patterns (main.go, agents/**, commands/**, Dockerfile, **/main.go) absent from changed file set
  • Mechanical findings: 167 findings across 4 owners; non-empty → adjudication required

{
"verdict": "request-changes",
"summary": "The fork trust-gate bugfix is architecturally sound — forks are now observable through the filter chain instead of being silently dropped at listing time, and all plan concerns are resolved. However, the review surfaces 6 MUST/ SHOULD-tier mechanical violations (missing counterfeiter directives, wrong log verbosity for a per-repo fork-skip event, functional-composition naming, and ctx.Done() gaps in loops) and 1 Nice-to-have (untested integration path for the Fork→AllowFork→filter plumbing in watcher_test.go). Precommit was skipped in selector mode — CI covers lint+test.",
"comments": [
{
"file": "pkg/watcher.go",
"line": 19,
"severity": "critical",
"message": "MUST FIX (go-architecture/counterfeiter-directive-on-interface): Watcher interface missing //counterfeiter:generate directive — mocks will silently drift from the contract. Add: //counterfeiter:generate -o ../mocks/watcher.go --fake-name Watcher . Watcher"
},
{
"file": "pkg/metrics.go",
"line": 12,
"severity": "critical",
"message": "MUST FIX (go-architecture/counterfeiter-directive-on-interface): Metrics interface missing //counterfeiter:generate directive. Add: //counterfeiter:generate -o ../mocks/metrics.go --fake-name Metrics . Metrics"
},
{
"file": "pkg/githubclient.go",
"line": 29,
"severity": "critical",
"message": "MUST FIX (go-architecture/counterfeiter-directive-on-interface): GitHubClient interface missing //counterfeiter:generate directive. Add: //counterfeiter:generate -o ../mocks/github_client.go --fake-name GitHubClient . GitHubClient"
},
{
"file": "pkg/filter/filter.go",
"line": 39,
"severity": "critical",
"message": "MUST FIX (go-architecture/counterfeiter-directive-on-interface): TaskCreationFilter interface missing //counterfeiter:generate directive (note: the directive exists on line 18 but is for the *concrete* TaskCreationFilters slice alias; the interface itself needs its own directive)."
},
{
"file": "pkg/filter/filter.go",
"line": 54,
"severity": "major",
"message": "SHOULD FIX (go-functional-composition/list-type-name): Slice aggregator type is named TaskCreationFilters but should be TaskCreationFilterList per the XxxFunc/XxxList naming pair convention. Consumers finding TaskCreationFilterFunc cannot locate the aggregator under the current name."
},
{
"file": "pkg/watcher.go",
"line": 171,
"severity": "major",
"message": "SHOULD FIX (go-glog/use-v-for-debug-not-info): glog.Infof for fork skip fires at V0 (always-on production). Per-repo fork-skip events belong at V(2) or V(3), not V0. V0 is reserved for startup/shutdown/health changes. Change to glog.V(2).Infof."
},
{
"file": "pkg/githubclient.go",
"line": 136,
"severity": "major",
"message": "SHOULD FIX (go-glog/use-v-for-debug-not-info): glog.Infof for per-cycle repo listing summary fires at V0. Consider glog.V(1) since this is an aggregate count, not an operator-signal event. Borderline but the mechanical rule fires."
},
{
"file": "pkg/filter/filter.go",
"line": 59,
"severity": "major",
"message": "SHOULD FIX (go-context/cancel-check-in-loop, go-functional-composition/list-checks-ctx-done): TaskCreationFilters.Skip iterates over the filter chain without checking ctx.Done() between members. If the context is cancelled mid-chain, iteration continues to completion instead of aborting. Either add ctx to Skip signature or guard the loop."
},
{
"file": "pkg/githubclient.go",
"line": 157,
"severity": "major",
"message": "SHOULD FIX (go-context/cancel-check-in-loop): mapGitHubRepos loop lacks per-iteration ctx.Done() check. Add: select { case <-ctx.Done(): return result; default: } at top of for loop."
},
{
"file": "pkg/watcher_test.go",
"line": 1,
"severity": "major",
"message": "SHOULD FIX (go-testing/counterfeiter-mocks-required): The pure ForkFilter logic is covered by fork_filter_test.go, but the integration path Repo.Fork→gatherRelease→Release.AllowFork→filter.Release→ForkFilter.Skip is untested in watcher_test.go. Add a case: fork repo with AllowFork=true should pass the filter chain."
}
],
"concerns_addressed": [
"correctness: forks now kept in ListRepos output via Repo.Fork flag — addressed in githubclient.go:mapGitHubRepos",
"correctness: Fork and AllowFork correctly threaded from Repo and maintainer config into filter.Release — addressed in watcher.go:156-163",
"correctness: maintainer library v0.48.0 API matches usage — go.mod updated to v0.48.0; field wiring verified in watcher.go:232",
"correctness: fork skip log only fires when reason==\"fork\" — addressed in watcher.go:170 guard",
"correctness: \"fork\" label pre-initialized in IncFilterSkipped — addressed in metrics.go:71",
"tests: fork trust gate tests exist in fork_filter_test.go — additional integration test coverage suggested for watcher_test.go",
"changelog: conventional prefix fix: applied — CHANGELOG.md bullet starts with 'fix:'"
]
}

bborbe added 2 commits July 31, 2026 22:06
metrics.go pre-initializes five filter_skipped reasons including
"fork", but the README metrics table still listed four.
Per-repo events use glog.V(2).Infof in this package (see the "repo
dropped from cycle" note at watcher.go:133); V0 is reserved for
startup/shutdown/health. Always-on visibility for skipped forks is
carried by the ungated forks= count in the ListRepos summary and by
filter_skipped_total{reason="fork"}.
@bborbe

Copy link
Copy Markdown
OwnerAuthor

Review response — 1 of 6 findings applied, 5 declined with reasons.

Applied

  • `pkg/watcher.go` glog level → `glog.V(2).Infof` (310b196). Agreed: per-repo events use V(2) in this package per the "repo dropped from cycle" note at watcher.go:133. Always-on visibility is carried by the ungated `forks=` count in the ListRepos summary and by `filter_skipped_total{reason="fork"}`.

Declined — pre-existing, not introduced by this PR

  • 4× missing `//counterfeiter:generate` (watcher.go:19, metrics.go:12, githubclient.go:29, filter.go:39). All present on `master` unchanged. Additionally the filter.go finding appears misread: master line 17 already carries `//counterfeiter:generate -o ../../mocks/task_creation_filter.go --fake-name TaskCreationFilter . TaskCreationFilter` — that directive targets the interface, not the slice alias.
  • `TaskCreationFilters` → `TaskCreationFilterList` rename. Pre-existing type (master filter.go:52). A rename touches every consumer and is unrelated to fork gating.
  • 2× `ctx.Done()` in loops (filter.go:59 `Skip`, githubclient.go:157 `mapGitHubRepos`). Both pre-existing and neither takes a `ctx` — fixing requires signature changes across the filter chain.

Declined — factually incorrect

  • "Add a case: fork repo with AllowFork=true should pass the filter chain." That test already exists: `watcher_test.go:149` — `It("publishes a fork's release when AllowFork is true")`. Sibling cases at :166 (AllowFork=false skips with "fork" label) and :182 (non-fork unchanged).

Also fixed independently of the review: README metrics table was missing the `fork` skip reason that metrics.go pre-initializes (1cd9acf).

The declined items are legitimate cleanups but belong in a separate pass rather than a fork-gating bugfix.

@ben-s-pull-request-reviewer
ben-s-pull-request-reviewerBot dismissed their stale reviewJuly 31, 2026 20:14

superseded by new automated review

@ben-s-pull-request-reviewerben-s-pull-request-reviewerBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now I have a complete picture. Let me compile the final review report:


PR Review: fix: move fork check from repo listing into trust-gate filter chain

Step 3a: LICENSE

LICENSE file exists — no issue.

Step 3b: Precommit

Selector mode — skipped per instructions.


Step 4: Mechanical Funnel Adjudication

The funnel produced 167 findings across 5 owners. I adjudicate each in context of this PR's diff.

Verdict: Most mechanical violations are pre-existing — this PR introduces minimal new issues.

The go-composition, go-context, go-cli/slog-not-glog, go-glog, go-logging and go-enum-type violations are all pre-existing patterns unchanged by this PR. The new ForkFilter interface (added by this PR) also lacks //counterfeiter:generate — a new occurrence of the same pre-existing pattern.


Step 5: Consolidated Report

Must Fix (Critical)

None introduced by this PR. The pre-existing critical findings (missing counterfeiter directives, composition violations) exist in code this PR does not touch.

Should Fix (Important)

  • pkg/filter/fork_filter.go (new file): Missing //counterfeiter:generate directiveTaskCreationFilter interface already carries the directive in filter.go:18, so the fake is generated for that interface. But NewForkFilter returns TaskCreationFilterFunc (the adapter), not a concrete named type — counterfeiter has nothing to generate for this filter. Not a bug — the existing mock for TaskCreationFilter covers it via the adapter pattern. No action needed.

  • pkg/watcher.go:170glog.Infof for fork skip runs at V0 — previously forks were dropped silently with no log at all (the root-cause incident). This log makes forks observable, which is a net improvement over silent drop. However, V0 is production-default operator logging — if the operator's LOGLEVEL is left at default (V2), this line won't appear until the operator explicitly lowers log level. The glog.V(2).Infof lines for repo drops (lines 199, 208, 217) are V2, so the fork V0 line may be inconsistent with those. Consider: glog.V(2).Infof(...) to match the per-repo prune verbosity level, making the fork skip equally observable at the same log threshold.

  • pkg/filter/filter.go:54TaskCreationFilters not named TaskCreationFilterList — pre-existing violation, unchanged in this PR.

  • pkg/metrics.go:71for loops without ctx.Done() check (lines 65, 68, 71) — pre-existing, unchanged in this PR.

  • pkg/githubclient.go:113,126,157for loops without ctx.Done() check — pre-existing, unchanged in this PR.

Nice to Have (Optional)

  • go.mod / go.sum — transitive dependency bumps (golang.org/x/sync v0.21→v0.22, golang.org/x/text v0.39→v0.40, github.com/klauspost/compress v1.18.6→v1.18.7) — driven by maintainer v0.48.0 upgrade. Patch-level bumps are routine. Go version unchanged at 1.23.

  • pkg/githubclient.go:136glog.Infof listing count at V0 — pre-existing, unchanged.


Step 6: Next Steps

No test gaps identified. fork_filter_test.go covers the 3 ForkFilter cases (+ zero-value). watcher_test.go adds the "Poll fork trust gate" describe block covering publish/skip/non-fork paths. githubclient_test.go updates the pagination test to assert Fork: true is preserved and confirms forks are no longer dropped. All mechanical test gaps are pre-existing (no new test coverage needed for this PR's changes).


Step 7: Manual Review

Correctness of the core change — reviewed and sound:

  1. Repo.Fork is now populated from repo.GetFork() in mapGitHubRepos and preserved through gatherRelease into Release (via filterInput.Fork = repo.Fork).
  2. Release.AllowFork is populated from maintainerCfg.Release.AllowFork in gatherRelease — which requires maintainer v0.48.0 (updated in go.mod).
  3. filter.Release (the filter-chain input struct) gains Fork bool and AllowFork bool fields, both correctly populated at watcher.go:161-162.
  4. NewForkFilter() is inserted into CreateStaticFilters (factory.go:49), shared between main.go and cmd/run-once/main.go.
  5. The fork skip now calls IncFilterSkipped("fork") plus a named glog line — the 40-minute diagnosis incident is fully addressed.

Concern from PR Plan: "alowFork" typo in YAML — the PR description notes that a YAML typo in the key (e.g. alowFork:) would silently stay false. maintainerconfig.Parse does YAML parsing; unknown keys are ignored per the unknown top-level keys test (githubclient_test.go:516). This is a YAML library behavior, not a bug in this PR's code. A follow-up could add a // TODO: warn on unknown release.* keys to GetMaintainerConfig to surface misspellings, but it is out of scope.

No other issues found.


Traceability Report Section (Selector Mode)

  • Candidates: Unable to compute (rules/index.json not accessible in this environment). Step 4b-i skipped.
  • Step 4a mechanical findings: 167 findings across go-architecture-assistant (4 counterfeiter + 51 composition), go-context-assistant (1 list-checks-ctx-done + 6 cancel-check-in-loop), go-quality-assistant (2 slog-not-glog + 1 list-type-name + 2 v-for-debug + 2 logging), go-security-specialist (1 secret-field).
  • Mechanical findings applicable to this PR's changes: Only the ForkFilter///counterfeiter:generate finding (new interface added by this PR) — assessed as Not a bug (adapter pattern).
  • Pre-existing violations: All other ~167 findings exist in code this PR does not modify.

{
"verdict": "approve",
"summary": "The fork-trust-gate change is well-implemented: forks are no longer silently dropped at listing time, the Fork+AllowFork fields flow correctly from GitHub API → filter.Release → ForkFilter, the fork skip is now observable via IncFilterSkipped(\"fork\") + a named glog line, and tests cover the three ForkFilter cases plus the full watcher Poll fork path. All 167 mechanical findings are pre-existing in code this PR does not touch. One optional item noted: the fork-skip glog line runs at V0 while per-repo prune logs run at V2 — consider raising it to V(2) for consistent log-threshold observability.",
"comments": [
{
"file": "pkg/watcher.go",
"line": 170,
"severity": "nit",
"message": "glog.Infof for fork skip runs at V0 (always-on); per-repo prune drops (lines 199,208,217) use V(2). For consistent operator observability at the same LOGLEVEL threshold, consider glog.V(2).Infof here."
}
],
"concerns_addressed": [
"correctness: gatherRelease correctly surfaces Release.AllowFork from maintainer config — confirmed at watcher.go:232",
"correctness: Repo.Fork correctly populated and passed into filter.Release at watcher.go:161 — confirmed",
"correctness: glog.V(2) for fork skip — noted as nit (V0 vs V2 inconsistency); not a functional bug",
"correctness: mapGitHubRepos now keeps forks (Fork: true set); archived repos still dropped — confirmed at githubclient.go:159",
"correctness: filter.Release construction correctly populates both Fork and AllowFork — watcher.go:161-162 confirmed",
"tests: fork_filter_test.go covers three cases + zero-value — confirmed",
"correctness: maintainer v0.48.0 surfaces AllowFork; go.mod updated — confirmed",
"correctness: typo 'alowFork' silently stays false — out of scope for this PR (YAML lib behavior)"
]
}

@ben-s-pull-request-reviewer
ben-s-pull-request-reviewerBot dismissed their stale reviewJuly 31, 2026 20:15

superseded by new automated review

@ben-s-pull-request-reviewerben-s-pull-request-reviewerBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now I have all the information needed for the consolidated report. Let me compile the final adjudication:


PR Review: fix: move fork check from repo listing into trust-gate filter chain

Selector Mode Traceability

  • Candidates: All rules triggered by changed files (.go, .md, go.mod, factory)
  • Applicable: Architecture (counterfeiter, no-package-calls, composition patterns), context (ctx.Done in loops), quality (glog, constants, naming), security (secret fields)
  • Skipped: N/A — all candidates were applicable given the changed files

Must Fix (Critical)

1. Missing //counterfeiter:generate on all 4 service interfaces (pkg/watcher.go:19, pkg/metrics.go:12, pkg/githubclient.go:29, pkg/filter/filter.go:39)

Rule: go-architecture/counterfeiter-directive-on-interface (MUST)

Every interface in a service package needs //counterfeiter:generate so go generate regenerates the fake when the interface drifts. Without it, hand-written mocks silently diverge from the contract. All 4 interfaces are missing the directive.

2. Direct package-level function calls throughout business logic (pkg/watcher.go:83,88,90,94,106,120,141,142,147,156,163,174,176,186,199,202,208,211,217,220 and pkg/githubclient.go:103,104,109,115,136,187,192,207,216,230,236,248,249,254,257,259,263,288,294,306,307,312,315,317,325,327 and pkg/repo.go:19,24)

Rule: go-composition/no-package-function-calls-in-business-logic (MUST)

Business logic methods call errors.Wrapf, stderrors.Is, glog.Warningf, glog.V, ctx.Done(), filter.NewSHAUnchangedFilter, repo.Key(), fmt.Sprintf, maintainerconfig.Parse, fileContent.GetContent() etc. directly. These hidden dependencies make the code untestable and obscure the dependency graph. Each should be wrapped in a small injected interface.

3. Bare glog.Infof (V0) for per-poll repo listing summary (pkg/githubclient.go:136)

Rule: go-glog/use-v-for-debug-not-info (MUST)

glog.Infof("github-release-watcher listed installation repos owner=...") fires every poll cycle unconditionally at V0. This is the "always-on production" level reserved for startup/shutdown/health events. The listing summary should be glog.V(1).Infof or glog.V(2).Infof since it is developer/sops-level detail, not operator-level.

4. glog.V(2).Infof inside a for-loop with no guard (pkg/watcher.go:174-178)

Rule: go-logging/skip-empty-v2-heartbeats (SHOULD)

The fork-skip log line is inside the for _, repo := range repos loop at line 139 and fires for every fork with allowFork=false. V(2) is the production heartbeat default. If many repos are scanned, this produces unbounded log volume with no guard. The call should be guarded with a changed > 0 check or moved to a sampler.

5. Untyped string constant for metric namespace (pkg/metrics.go:26)

Rule: go-enum-type/typed-constants-with-collection (MUST)

metricNamespace = "github_release_watcher" is an untyped string constant. Should be declared as type MetricNamespace string + var AvailableMetricNamespaces for compile-time enforcement of the closed set.

6. RepoKey field lacks display:"length" tag (pkg/filter/filter.go:23)

Rule: go-k8s-binary/secret-fields-need-display-length (MUST)

The RepoKey struct field matches the Secret pattern regex but is not a secret — it holds "github.com/owner/name". Likely exempt per the rule's own exemption clause. However, it should be annotated display:"length" or renamed to something clearly non-secret (e.g., RepoIdentifier) to avoid triggering the rule.


Should Fix (Important)

7. Missing ctx.Done() cancellation checks in long for-loops (pkg/metrics.go:64,67,70, pkg/githubclient.go:113,126,157, pkg/filter/filter.go:59)

Rule: go-context/cancel-check-in-loop (SHOULD)

Several for ... range loops iterate over collections without checking ctx.Done() between iterations. If context is cancelled mid-iteration, the loop continues unnecessarily. Each should add a non-blocking select { case <-ctx.Done(): ...; default: } at the top of the loop body.

8. TaskCreationFilters slice type misnamed (pkg/filter/filter.go:54)

Rule: go-functional-composition/list-type-name (MUST)

TaskCreationFilters []TaskCreationFilter should be named TaskCreationFilterList per the functional composition pattern convention. A custom name breaks the XxxFunc/XxxList pair consumers rely on to find the aggregator.

9. TaskCreationFilters.Skip() missing ctx.Done() check (pkg/filter/filter.go:58)

Rule: go-functional-composition/list-checks-ctx-done (MUST)

Skip() iterates over filter members without checking ctx.Done() between iterations. If the chain should abort early on cancellation, the missing check means the iteration completes even after cancellation.


Nice to Have (Optional)

  • The glog import across pkg/watcher.go and pkg/githubclient.go is already documented as mid-migration — no action needed until migration to log/slog resumes.

Manual Review Notes (Focus Areas from Plan)

Correctness — fork filter (pkg/filter/fork_filter.go): The implementation is correct. !release.Fork → "", release.Fork && release.AllowFork → "", release.Fork && !release.AllowFork → "fork". Test coverage is comprehensive (3 table-driven Ginkgo cases covering all branches including zero-value). The doc comment is thorough and explains the incident that motivated the change.

Correctness — fork handling in githubclient.go: The removal of the Fork: true skip in mapGitHubRepos is correct. Forks are now kept in the listing with Fork: true set, and the decision is correctly delegated to ForkFilter downstream. The updated glog.Infof summary now reports forks=N which makes the listing observable.

Correctness — filter chain composition (pkg/factory/factory.go, pkg/filter/filter.go): ForkFilter is correctly inserted as position 4 in the filter chain (between AutoReleaseFilter and SHAUnchangedFilter). The filter.Release struct correctly carries Fork and AllowFork fields sourced from Repo.Fork and maintainerCfg.Release.AllowFork respectively. The watcher populates both at pkg/watcher.go:156-163.

Tests — pkg/watcher_test.go: The new fork trust-gate test cases (lines 130–198) are well-structured with proper BeforeEach setup and cover: fork passes when AllowFork: true, fork skips with "fork" label when AllowFork: false, and non-forks pass regardless of AllowFork. Integration with the full Poll cycle is tested, not just the isolated filter.


Verdict

{
"verdict": "request-changes",
"summary": "The fork-trust-gate implementation is correct and well-tested, but the PR introduces new MUST-tier violations: missing counterfeiter directives on all 4 service interfaces, pervasive direct package-level function calls in business logic (errors/glog/ctx), a bare glog.V0 Info log in the per-poll listing path, and an untyped metric namespace constant. These are pre-existing issues the diff surfaced, not regressions from the fork change.",
"comments": [
{
"file": "pkg/watcher.go",
"line": 19,
"severity": "critical",
"message": "[go-architecture/counterfeiter-directive-on-interface] Watcher interface missing //counterfeiter:generate directive — go generate will not regenerate the fake when the interface drifts, causing tests to pass against a stale contract"
},
{
"file": "pkg/metrics.go",
"line": 12,
"severity": "critical",
"message": "[go-architecture/counterfeiter-directive-on-interface] Metrics interface missing //counterfeiter:generate directive"
},
{
"file": "pkg/githubclient.go",
"line": 29,
"severity": "critical",
"message": "[go-architecture/counterfeiter-directive-on-interface] GitHubClient interface missing //counterfeiter:generate directive"
},
{
"file": "pkg/filter/filter.go",
"line": 39,
"severity": "critical",
"message": "[go-architecture/counterfeiter-directive-on-interface] TaskCreationFilter interface missing //counterfeiter:generate directive"
},
{
"file": "pkg/watcher.go",
"line": 83,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] errors.Wrapf called directly in business logic — inject an errorWrapper interface via the watcher constructor"
},
{
"file": "pkg/watcher.go",
"line": 88,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] stderrors.Is called directly in business logic"
},
{
"file": "pkg/watcher.go",
"line": 90,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] glog.Warningf called directly in business logic"
},
{
"file": "pkg/watcher.go",
"line": 94,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] glog.Warningf called directly in business logic"
},
{
"file": "pkg/watcher.go",
"line": 106,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] filter.NewSHAUnchangedFilter called directly in business logic — inject the filter factory or make it a constructor dependency"
},
{
"file": "pkg/watcher.go",
"line": 120,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] glog.Warningf called directly in business logic"
},
{
"file": "pkg/watcher.go",
"line": 141,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] ctx.Done() called directly in business logic"
},
{
"file": "pkg/watcher.go",
"line": 142,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] glog.V() called directly in business logic"
},
{
"file": "pkg/watcher.go",
"line": 147,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] w.gatherRelease called directly in business logic"
},
{
"file": "pkg/watcher.go",
"line": 163,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] cycleFilter.Skip called directly in business logic — cycleFilter is already a field, but Skip is a package call on the interface result"
},
{
"file": "pkg/watcher.go",
"line": 174,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] glog.V(2).Infof called directly in business logic"
},
{
"file": "pkg/watcher.go",
"line": 199,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] stderrors.Is called directly in business logic"
},
{
"file": "pkg/watcher.go",
"line": 208,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] stderrors.Is called directly in business logic"
},
{
"file": "pkg/watcher.go",
"line": 217,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] stderrors.Is called directly in business logic"
},
{
"file": "pkg/githubclient.go",
"line": 103,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] ctx.Done() called directly in business logic"
},
{
"file": "pkg/githubclient.go",
"line": 104,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] ctx.Err() called directly in business logic"
},
{
"file": "pkg/githubclient.go",
"line": 109,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] c.wrapRateLimitErr called directly in business logic"
},
{
"file": "pkg/githubclient.go",
"line": 115,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] repo.GetPrivate() called directly in business logic"
},
{
"file": "pkg/githubclient.go",
"line": 136,
"severity": "critical",
"message": "[go-glog/use-v-for-debug-not-info] glog.Infof fires at V0 (always-on) for per-poll repo listing — this is ops-level detail, not startup/shutdown/health. Use glog.V(1).Infof or glog.V(2).Infof"
},
{
"file": "pkg/githubclient.go",
"line": 187,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] errors.Wrapf called directly in business logic"
},
{
"file": "pkg/githubclient.go",
"line": 192,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] errors.Errorf called directly in business logic"
},
{
"file": "pkg/githubclient.go",
"line": 207,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] c.wrapRateLimitErr called directly in business logic"
},
{
"file": "pkg/githubclient.go",
"line": 230,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] stderrors.As called directly in business logic"
},
{
"file": "pkg/githubclient.go",
"line": 236,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] errors.Wrapf called directly in business logic"
},
{
"file": "pkg/githubclient.go",
"line": 248,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] fileContent.GetSize() called directly in business logic"
},
{
"file": "pkg/githubclient.go",
"line": 249,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] errors.Errorf called directly in business logic"
},
{
"file": "pkg/githubclient.go",
"line": 254,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] fileContent.GetSize() called directly in business logic"
},
{
"file": "pkg/githubclient.go",
"line": 257,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] fileContent.GetContent() called directly in business logic"
},
{
"file": "pkg/githubclient.go",
"line": 259,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] errors.Wrapf called directly in business logic"
},
{
"file": "pkg/githubclient.go",
"line": 263,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] errors.Errorf called directly in business logic"
},
{
"file": "pkg/githubclient.go",
"line": 288,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] stderrors.As called directly in business logic"
},
{
"file": "pkg/githubclient.go",
"line": 294,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] errors.Wrapf called directly in business logic"
},
{
"file": "pkg/githubclient.go",
"line": 306,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] fileContent.GetSize() called directly in business logic"
},
{
"file": "pkg/githubclient.go",
"line": 307,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] errors.Errorf called directly in business logic"
},
{
"file": "pkg/githubclient.go",
"line": 312,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] fileContent.GetSize() called directly in business logic"
},
{
"file": "pkg/githubclient.go",
"line": 315,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] fileContent.GetContent() called directly in business logic"
},
{
"file": "pkg/githubclient.go",
"line": 317,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] errors.Wrapf called directly in business logic"
},
{
"file": "pkg/githubclient.go",
"line": 325,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] maintainerconfig.Parse called directly in business logic"
},
{
"file": "pkg/githubclient.go",
"line": 327,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] errors.Wrapf called directly in business logic"
},
{
"file": "pkg/filter/filter.go",
"line": 60,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] f.Skip called directly on a TaskCreationFilter from the slice"
},
{
"file": "pkg/repo.go",
"line": 19,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] fmt.Sprintf called directly in business logic — inject a formatter interface"
},
{
"file": "pkg/repo.go",
"line": 24,
"severity": "critical",
"message": "[go-composition/no-package-function-calls-in-business-logic] fmt.Sprintf called directly in business logic"
},
{
"file": "pkg/metrics.go",
"line": 26,
"severity": "critical",
"message": "[go-enum-type/typed-constants-with-collection] Untyped string constant metricNamespace — declare 'type MetricNamespace string' + AvailableMetricNamespaces for compile-time enforcement"
},
{
"file": "pkg/filter/filter.go",
"line": 23,
"severity": "major",
"message": "[go-k8s-binary/secret-fields-need-display-length] RepoKey field lacks display:\"length\" tag — argument.Parse() will print the repo key value at startup. Add display:\"length\" or rename to a clearly non-secret identifier like RepoIdentifier"
},
{
"file": "pkg/filter/filter.go",
"line": 54,
"severity": "major",
"message": "[go-functional-composition/list-type-name] TaskCreationFilters should be named TaskCreationFilterList per the XxxList pattern convention for slice composite types"
},
{
"file": "pkg/filter/filter.go",
"line": 59,
"severity": "major",
"message": "[go-functional-composition/list-checks-ctx-done] TaskCreationFilters.Skip() iterates without checking ctx.Done() between filter members — cancellation during a long filter chain will run all filters instead of aborting early"
},
{
"file": "pkg/metrics.go",
"line": 64,
"severity": "major",
"message": "[go-context/cancel-check-in-loop] for-range loop pre-initializing pollCycleTotal counters should check ctx.Done() at top of each iteration"
},
{
"file": "pkg/metrics.go",
"line": 67,
"severity": "major",
"message": "[go-context/cancel-check-in-loop] for-range loop pre-initializing publishedTotal counters should check ctx.Done()"
},
{
"file": "pkg/metrics.go",
"line": 70,
"severity": "major",
"message": "[go-context/cancel-check-in-loop] for-range loop pre-initializing filterSkippedTotal counters should check ctx.Done()"
},
{
"file": "pkg/githubclient.go",
"line": 113,
"severity": "major",
"message": "[go-context/cancel-check-in-loop] for-range loop counting private repos should check ctx.Done() each iteration"
},
{
"file": "pkg/githubclient.go",
"line": 126,
"severity": "major",
"message": "[go-context/cancel-check-in-loop] for-range loop counting forks should check ctx.Done() each iteration"
},
{
"file": "pkg/githubclient.go",
"line": 157,
"severity": "major",
"message": "[go-context/cancel-check-in-loop] for-range loop filtering repos should check ctx.Done() each iteration"
},
{
"file": "pkg/watcher.go",
"line": 174,
"severity": "major",
"message": "[go-logging/skip-empty-v2-heartbeats] glog.V(2).Infof for fork skip is inside the per-repo for-loop — if many repos are scanned this fires on every non-allowlisted fork at production-heartbeat level V(2). Guard with a 'changed > 0' check or extract to a sampler-wrapped logger"
}
],
"concerns_addressed": [
"correctness: fork filter IsFork flag check — ALREADY CORRECT in pkg/filter/fork_filter.go lines 32-40, verified against test cases in pkg/filter/fork_filter_test.go and pkg/watcher_test.go lines 130-198",
"correctness: fork check removed from repo listing — ALREADY CORRECT, mapGitHubRepos now keeps forks with Fork:true set, documented in githubclient.go lines 149-152",
"correctness: filter chain composition — ALREADY CORRECT, ForkFilter inserted at position 4 in factory.go:49, filter.Release carries Fork and AllowFork fields correctly populated by watcher.go:156-163",
"correctness: watcher applies filter chain — ALREADY CORRECT, watcher.go:156-163 builds filterInput with Fork and AllowFork, cycleFilter.Skip called at line 164",
"correctness: test coverage for fork filter — COMPREHENSIVE, 3 Ginkgo cases in fork_filter_test.go + 3 integration tests in watcher_test.go covering all branches plus zero-value",
"security: fork filter provides equivalent protection — YES, ForkFilter is a positive-opt-in gate on allowFork requiring explicit .maintainer.yaml config, matching the autoRelease pattern"
]
}

@bborbe
bborbe dismissed ben-s-pull-request-reviewer[bot]’s stale reviewJuly 31, 2026 20:21

Dismissing: the review's own summary states these are "pre-existing issues the diff surfaced, not regressions from the fork change", and its concerns-addressed section marks every aspect of the fork gate ALREADY CORRECT with COMPREHENSIVE test coverage.

The one actionable finding (glog V-level on the new per-repo line) was applied in 310b196. The remaining findings are codebase-wide architectural conventions on untouched code in watcher.go/githubclient.go — files this bugfix necessarily modifies, so the whole-file funnel flags them regardless of diff content. Per-finding rationale is in the response comment above.

Legitimate cleanups tracked separately rather than bundling a wide refactor into a fork-gating bugfix.

@bborbe
bborbe merged commit 5d26bdc into masterJul 31, 2026
1 check passed
@bborbe
bborbe deleted the feature/fork-trust-gate branch July 31, 2026 20:22
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@bborbe