Skip to content

Suppress unactionable PRs, and fix the cache bug that made the trigger filter inert - #11

Merged
fredclausen merged 3 commits into
mainfrom
feat/ignore-pulls-and-cache-versioning
Aug 10, 2026
Merged

Suppress unactionable PRs, and fix the cache bug that made the trigger filter inert#11
fredclausen merged 3 commits into
mainfrom
feat/ignore-pulls-and-cache-versioning

Conversation

@fredclausen

@fredclausen fredclausen commented Aug 10, 2026

Copy link
Copy Markdown
Member

Two changes, one commit each. They are related only in that the second is
why one of the three reported CI failures was bogus.

fix: the ETag cache could silently disable a filter

The trigger filter added in #9 was never active in production. The cache
stores projections rather than raw responses, so when WorkflowDefinition
changed from a bare Vec<String> of crons to a struct, every persisted entry
kept the old shape while its ETag stayed valid. GitHub answered 304, the 304
path failed to decode the cached body, and the caller treats a failed
definition lookup as "triggers unknown" -- which means accept any event.

So frext and bike-fitter-1000 kept reporting months-old push failures for
a ci.yml that declares only pull_request, which is exactly what #9 set out
to fix. Confirmed on sdrhub: every cached contents entry was still
pre-upgrade ([] / ["0 0 1 * *"] instead of the struct).

Two independent mechanisms, because one of them already failed once:

  • the cache file carries a format version, and a mismatch discards it whole
  • an undecodable entry is a cache miss, not an error -- this is what
    recovers from a forgotten version bump, so the fix does not depend on anyone
    remembering to bump it

feat: ignore_pulls

For a PR that is genuinely stuck and genuinely not ours: #32 on
sdr-enthusiasts/docker-vesselalert is conflicting, open against a repo we do
not own, and the maintainer has not engaged. Not ours to close or draft, so it
alerts forever.

ignore_pulls = ["sdr-enthusiasts/docker-vesselalert#32"]

Suppression is scoped to actionability, not visibility. An ignored PR
leaves the per-PR series so nothing can alert on it, but still counts in
repo_pulls_open -- the repo really does have it open -- and the count
suppressed is published as repo_pulls_ignored, including a zero, so the
dashboard can assert "nothing is hidden here" instead of assuming it.

Per-PR rather than reusing denylist, which would also blind the exporter to
the repo's CI and to every future PR on it. Entries are parsed at startup, so a
typo is a startup error rather than a filter that silently never matches.

Verification

cargo xtask ci green (118 tests, clippy, deny, machete). Both changes were
also run against the live API:

  • with a pre-versioning cache file seeded on disk, it is rejected and frext /
    bike-fitter-1000 no longer report their superseded push failures
  • #32 is absent from every per-PR series while repo_pulls_open still reports
    1 and repo_pulls_ignored reports 1; the other 47 monitored repos are
    unaffected. The configured entry was deliberately mixed-case, so this also
    exercises case-insensitive matching.

Summary by CodeRabbit

  • New Features

    • Added support for excluding specific pull requests using owner/name#number identifiers.
    • Added a per-repository metric reporting the number of ignored open pull requests.
    • Added configuration support for ignored pull requests in NixOS deployments.
    • Ignored pull requests are excluded from per-request metrics while aggregate open pull-request counts remain accurate.
  • Bug Fixes

    • Improved cache recovery when cached data is missing, malformed, or incompatible.
    • Automatically refetches data when cached projections cannot be decoded.
  • Documentation

    • Documented cache versioning, recovery behavior, ignored pull requests, validation, and the new metric.

The ETag cache stores projections, not raw responses, so its contents are
only meaningful to the code that wrote them. Nothing enforced that.

Adding the workflow trigger list changed the cached workflow definition
from a bare `Vec<String>` of crons to a struct. Every persisted entry
kept the old shape across the upgrade while its ETag stayed valid, so
GitHub answered 304 and the 304 path returned a decode error. The caller
treats a failed definition lookup as "triggers unknown", and an empty
trigger list means "accept any event" -- so the trigger filter was inert
in production for as long as the cache file survived.

That is not a cosmetic bug: it is the exact frext / bike-fitter-1000
false positive the filter was written to remove, still being reported
weeks after the fix shipped. Confirmed on sdrhub, where every cached
contents entry was still in the pre-upgrade format.

Two independent mechanisms, because one of them already failed:

* The cache file carries a format version. A mismatch discards the file
  wholesale, since a projection change invalidates all of it, at a cost
  of one cold sweep.
* An undecodable entry is a cache miss rather than an error. The stale
  ETag is dropped and the resource refetched unconditionally. This is
  what recovers from a forgotten version bump -- the failure mode that
  actually happened -- and it is why the fix does not depend on
  remembering to bump anything.

Verified against the live API with a pre-versioning cache file seeded on
disk: it is rejected, and frext and bike-fitter-1000 no longer report
their superseded push failures.
Some pull requests are stuck and will stay stuck. #32 on
sdr-enthusiasts/docker-vesselalert is open against a repository we do not
own, conflicting, and the maintainer has not engaged with it. It is not
ours to close or convert to a draft, so it alerts forever.

Nothing the exporter can measure separates that from a pull request worth
chasing, so it has to be declared:

    ignore_pulls = ["sdr-enthusiasts/docker-vesselalert#32"]

Suppression is scoped to actionability, not visibility. An ignored PR is
dropped from the per-PR series -- needs_attention, ready_to_merge, and
created_timestamp -- so no alert can fire on it and it leaves the
dashboard's PR tables. It still counts towards repo_pulls_open, because
the repository really does have it open and that count must keep matching
GitHub's own. The number suppressed is published per repository as
repo_pulls_ignored, including as a zero, so "nothing is hidden here" is
an assertion the dashboard can make rather than an absence it has to
assume.

Deliberately per-PR rather than reusing `denylist`: denylisting the
repository would also blind the exporter to its CI state and to every
future pull request on it.

Entries are parsed into a PullRef at startup, so a typo is a startup
error rather than a filter that silently never matches -- the worst
outcome being an operator who believes a PR is suppressed while its alert
keeps firing. Matching is case-insensitive, since the entry is
hand-written and the runtime value comes from the API.

Verified against the live API: #32 is absent from every per-PR series
while repo_pulls_open still reports 1 and repo_pulls_ignored reports 1,
and the other 47 monitored repositories are unaffected.
Copilot AI lite review requested due to automatic review settings August 10, 2026 23:17
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9a0f20bb-8075-4b7e-8bfd-c1b4e4f0a65a

📥 Commits

Reviewing files that changed from the base of the PR and between 1780013 and 3230058.

📒 Files selected for processing (4)
  • README.md
  • github-ci-exporter/src/collector.rs
  • github-ci-exporter/src/github/client.rs
  • github-ci-exporter/src/model.rs

📝 Walkthrough

Walkthrough

The exporter adds per-pull-request suppression through configuration and NixOS wiring. It reports ignored pull counts while preserving aggregate open-pull totals. The GitHub client now uses versioned cache files and refetches when cached projections cannot be decoded.

Changes

Pull-request suppression

Layer / File(s) Summary
Pull reference and configuration contracts
github-ci-exporter/src/model.rs, github-ci-exporter/src/config.rs
PullRef parses normalized owner/name#number references. Config validates and exposes ignored pull references.
Collection and ignored-pull metrics
github-ci-exporter/src/collector.rs, github-ci-exporter/src/metrics.rs
Matching open pull requests no longer produce per-pull-request metrics. Aggregate open-pull counts remain unchanged. A repository-level ignored count is published, including zero values.
NixOS wiring and operator documentation
nix/nixos-module.nix, README.md
The NixOS module exposes ignorePulls and writes ignore_pulls to TOML. Documentation covers suppression, validation, and the new metric.

Cache versioning and recovery

Layer / File(s) Summary
Versioned cache persistence
github-ci-exporter/src/github/client.rs
Cache files now contain a format version and entries. Incompatible and pre-versioning files are discarded.
Conditional-response projection recovery
github-ci-exporter/src/github/client.rs, README.md
Undecodable cached projections on 304 Not Modified responses cause stale ETags to be removed and data to be refetched. Documentation describes the recovery behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Config
  participant Collector
  participant Metrics
  Config->>Collector: provide ignored PullRef set
  Collector->>Metrics: omit matching per-pull metrics
  Collector->>Metrics: publish repository ignored count
Loading
sequenceDiagram
  participant GithubClient
  participant Cache
  participant GitHubAPI
  GithubClient->>GitHubAPI: send conditional request with ETag
  GitHubAPI-->>GithubClient: return 304 response
  GithubClient->>Cache: decode cached projection
  Cache-->>GithubClient: return decode failure
  GithubClient->>GitHubAPI: refetch without stale ETag
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes both main changes: pull request suppression and the cache fix that restores trigger filtering.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ignore-pulls-and-cache-versioning

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@github-ci-exporter/src/github/client.rs`:
- Around line 255-267: Update with_cache_file to handle Err from
std::fs::read_to_string explicitly: log non-NotFound read failures with warn!,
while continuing silently for a missing cache file and preserving the existing
decode and cache-restore behavior.
- Around line 492-510: Update the stale-projection recovery flow around the
cached response match to remove the stale cache entry, clear cached_etag, and
continue through the existing request loop rather than issuing a direct
unconditional request. Preserve classify_retry handling so recovery responses
such as 429 and 5xx receive the normal bounded retries.

In `@github-ci-exporter/src/model.rs`:
- Around line 220-222: Update the is_full_name validation in PullRef::new to
reject repository strings containing whitespace, including owner/name `#32`,
before retaining the value. Extend the malformed-input tests with
whitespace-containing repository values and verify they are rejected.

In `@README.md`:
- Around line 81-83: Update the README guidance to reference
CACHE_FORMAT_VERSION using the repository-relative path
github-ci-exporter/src/github/client.rs, or explicitly state that
github/client.rs is relative to github-ci-exporter/src.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 78252087-a1d4-42e0-a98a-ab58fa5a3012

📥 Commits

Reviewing files that changed from the base of the PR and between 765031b and 1780013.

📒 Files selected for processing (7)
  • README.md
  • github-ci-exporter/src/collector.rs
  • github-ci-exporter/src/config.rs
  • github-ci-exporter/src/github/client.rs
  • github-ci-exporter/src/metrics.rs
  • github-ci-exporter/src/model.rs
  • nix/nixos-module.nix

Comment thread github-ci-exporter/src/github/client.rs
Comment thread github-ci-exporter/src/github/client.rs Outdated
Comment thread github-ci-exporter/src/model.rs
Comment thread README.md Outdated

Copilot AI 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.

Pull request overview

This PR (1) hardens the GitHub API ETag cache so cached projection shape changes cannot silently disable workflow-trigger filtering, and (2) adds an operator-configurable ignore_pulls mechanism to suppress unactionable PRs from per-PR alerting series while keeping aggregate counts honest.

Changes:

  • Add a versioned on-disk envelope for the ETag cache and treat undecodable cached projections as cache misses (refetch) instead of hard errors.
  • Introduce ignore_pulls configuration (including NixOS module support) and a new github_repo_pulls_ignored metric; suppressed PRs are omitted from per-PR series but still counted in github_repo_pulls_open.
  • Extend documentation and tests to cover the new cache behavior and PR suppression logic.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
README.md Documents cache-format versioning/behavior and the new ignore_pulls suppression semantics and metric.
nix/nixos-module.nix Adds NixOS option services.github-ci-exporter.ignorePulls and maps it to ignore_pulls.
github-ci-exporter/src/model.rs Introduces PullRef (validated owner/name#number) with parsing + tests for case-insensitive matching.
github-ci-exporter/src/metrics.rs Adds and registers repo_pulls_ignored metric family, and clears it between cycles.
github-ci-exporter/src/github/client.rs Adds cache file versioning + decode logic, refetch-on-decode-failure for 304s, and tests for both behaviors.
github-ci-exporter/src/config.rs Adds ignore_pulls config field, validates entries at load, and exposes parsed HashSet<PullRef>.
github-ci-exporter/src/collector.rs Applies ignore list during activity recording and publishes github_repo_pulls_ignored per repo; adds tests for suppression behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread github-ci-exporter/src/github/client.rs Outdated
Comment on lines +300 to +303
let encoded = serde_json::to_string(&CacheFile {
version: CACHE_FORMAT_VERSION,
entries: cache.clone(),
})?;
Comment thread github-ci-exporter/src/collector.rs Outdated
Comment on lines +164 to +167
// Validated at startup, so a parse failure here cannot happen; an empty
// set (ignore nothing) is the safe reading if it somehow does.
let ignored_pulls = config.ignored_pulls().unwrap_or_default();

Six findings from the PR review, two of which were real bugs:

`PullRef` accepted internal whitespace. Only the outer edges were
trimmed, so `owner/name #32` parsed successfully and then never matched
an API-supplied repository name -- recreating the exact silent no-op the
validation exists to prevent. Whitespace anywhere in the repository
portion is now rejected, with the cases added to the malformed-input
test.

The stale-projection recovery request bypassed the retry loop. It was
issued inline, outside `classify_retry`, so a 429 or 5xx on the refetch
failed the cycle instead of backing off. It now clears the validator and
re-enters the existing loop, which also deletes the duplicated
request/ETag/status handling. That introduces one new way to spin -- a
server answering 304 to a request carrying no If-None-Match -- so that
is now an explicit fast failure rather than another cache miss.

The rest are hygiene:

* `ignore_pulls` was re-parsed every cycle despite being validated once
  at startup. It is now resolved on first use and memoised in
  WorkflowCache, and passed by reference rather than cloned.
* A non-NotFound error reading the cache file was silently swallowed. A
  permission or I/O failure otherwise shows up only as a persistently
  high request count.
* `persist_cache` cloned the whole cache to serialise it. A borrowed
  write-side envelope produces identical JSON without the copy.
* The README pointed at `github/client.rs`, which does not resolve from
  the repository root.

Re-verified against the live API, including a warm restart: the
versioned cache is accepted without a cold-start warning, and #32 stays
suppressed while repo_pulls_open still reports it.
@fredclausen
fredclausen merged commit cbaeda6 into main Aug 10, 2026
8 checks passed
@fredclausen
fredclausen deleted the feat/ignore-pulls-and-cache-versioning branch August 10, 2026 23:40
Sign up for free to 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.

2 participants