Skip to content

Fix: Resolve open cache-isolation and config-provenance issues - #393

Open
ModeSevenIndustrialSolutions wants to merge 4 commits into
lfreleng-actions:mainfrom
modeseven-lfreleng-actions:fix/open-issues
Open

Fix: Resolve open cache-isolation and config-provenance issues#393
ModeSevenIndustrialSolutions wants to merge 4 commits into
lfreleng-actions:mainfrom
modeseven-lfreleng-actions:fix/open-issues

Conversation

@ModeSevenIndustrialSolutions

@ModeSevenIndustrialSolutions ModeSevenIndustrialSolutions commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Closes #385
Closes #386

Note

#392 has merged, so this no longer stacks — the diff is now just the two commits below. Still marked draft; say the word and I'll mark it ready.

Fix: Reset process-wide caches between tests#385

The autouse fixture clearing gerrit_urls._BASE_PATH_CACHE landed in #384; the sibling caches that issue named were left to a follow-up. This is that follow-up.

ssh_config_parser memoises four values, each derived from state tests routinely monkeypatch:

Cache Key Value derived from
_get_respect_user_ssh_setting none — zero-arg lru_cache(maxsize=1) G2G_RESPECT_USER_SSH
_get_cached_git_user_email none — zero-arg lru_cache(maxsize=1) git config user.email
derive_gerrit_credentials (host, organization, port) the two above, plus ~/.ssh/config and env — none of which are in the key
_ssh_config_cache config path, never its contents the parsed SSH config

The first two are the worst: with no arguments there is a single cache slot, so any two tests collide and the second receives the first one's environment — including the git identity that isolate_git_environment works to control.

The module already exposed clear_credential_cache(), which resets all four, but only two test modules called it and only from setup_method — guarding those files on the way in while leaving the caches populated for whatever runs next. Calling it from an autouse fixture covers every test, and clearing on both sides stops a dirty cache reaching a test as well as leaving one behind. No src/ change was needed.

Honest scoping: no leak between existing tests is demonstrable. Every test reaching derive_gerrit_credentials either patches it wholesale or clears in setup_method, which is why this has held. The exposure is latent, exactly like the base-path bug — it bites when someone adds a test that derives credentials for real, and surfaces far from its cause. No leak is claimed that could not be shown.

The explicit clear in test_gerrit_urls_more.py at module scope is now redundant and goes. The one mid-test survives, because it is not a test boundary: without it the second half of that test never probes at all and its assertions pass off the value cached by the first half. Its comment claimed the fixture covered it, which was wrong; the comment is now accurate.

Fix: Separate derived Gerrit config from explicit#386

Duplicate detection short-circuited on GERRIT_SERVER / GERRIT_PROJECT from the environment, but by then those are usually derived fallbacks, not operator intent — _load_effective_inputs derives them and writes them to the environment first.

Two consequences: the .gitreview resolution below that early return was effectively dead on the normal path, including the base-ref pinning added in #384 for fork PRs; and the derived project is a guess taken from the repository name, so OpenDaylight's integration-distribution yielded that same string where Gerrit holds integration/distribution. Detection queried a project that does not exist and reported no duplicates, silently.

Config values now carry provenance. mark_derived_keys records which keys hold a derived fallback and is_derived_key reports it, backed by G2G_DERIVED_KEYS.

On why an environment variable is acceptable here: this is process-scoped configuration, settled once before the bulk ThreadPoolExecutor starts — deliberately unlike the per-PR state that cli.py:289 already documents as unsafe to hold globally.

Marking applies only to keys whose environment value is empty, matching the test apply_config_to_env applies moments later, so a key derivation supplies but the environment already defines explicitly stays explicit. The cli.py:767 call site opts out via mark_derived=False: it persists to the org config file after successful submission and never touches the environment.

Detection now short-circuits only when both host and project are explicit, field by field, letting the per-PR .gitreview fill only what is not explicitly configured — and only where .gitreview actually carries a value. Where it yields nothing the derived pair still applies, because returning None would disable detection entirely wherever it previously ran against a derived project. The fork-trust handling from #384 is untouched.

Review round

Copilot found five issues across this PR, all genuine, all fixed. Three were bugs I introduced; two were pre-existing problems the provenance work exposed. Worth recording, since two of them were worse than the bug this PR set out to fix:

  1. Partial-pair precedence. Collapsing both keys into one derived flag meant one derived key made the whole pair non-explicit, so .gitreview replaced an explicitly configured value too. Now tracked per field.
  2. Host-only .gitreview. parse_gitreview requires only host= and returns project = "". Taking that empty project dropped the project: qualifier from the query — _build_duplicate_query_path logs it as project=(any) — so detection searched every project on the server, letting an unrelated same-title change block a legitimate submission. Now falls back to the environment value per field.
  3. Per-repository value in per-organization config. save_derived_parameters_to_config writes into the org section, so one repository's GERRIT_PROJECT was handed to every sibling repository. Now filtered out before writing.
  4. Legacy config entries. Values auto-saved by earlier releases still loaded as explicit. Rather than a migration, a GERRIT_PROJECT from the config file is now treated as derived outright — the file is sectioned per-organization while a project is per-repository, so such a value is misscoped whoever wrote it. It stays usable as a last resort, so nothing breaks and no user file is rewritten.
  5. A substring check in my own test helper. "raw.githubusercontent.com" in url accepted lookalike hosts, path segments, query strings and plain HTTP. Now parsed and compared as an origin.

One deliberate behaviour change from (2): when .gitreview is host-only and no project is configured anywhere, the resolver returns None and the check is skipped, where it previously issued the unscoped query. A missed duplicate is recoverable; a false positive blocking a legitimate submission is not.

Validation

Gate Result
pytest 1549 passed, 0 failed, 0 errors, 0 skipped (from 1518)
basedpyright (124 files) 0 errors, 0 warnings
ruff check / format clean
aislop ci 100 / 100, 0 findings
prek run --all-files every hook passed
CI 24 success, 3 skipped, 0 failures

Every fix was verified by deleting it and watching the tests fail, not by observing them pass. Representative:

# detector reverted
FAILED test_duplicate_query_uses_gitreview_path_not_derived_name
  AssertionError: Gerrit was not queried for the .gitreview project:
  ['...changes/?q=project:integration-distribution status:open...']

# host-only .gitreview fix reverted
AssertionError: Gerrit query was not scoped to a project:
  ['...changes/?q=status:open after:2026-08-19&n=50&...']

# autouse fixtures disabled
FAILED test_base_path_cache_does_not_leak_into_later_test
FAILED test_respect_user_ssh_setting_does_not_leak_into_later_test
FAILED test_git_user_email_cache_does_not_leak_into_later_test
FAILED test_credential_cache_does_not_leak_into_later_test
FAILED test_ssh_config_cache_does_not_leak_into_later_test

Two of the provenance tests pass with and without their fix — with the block absent nothing marks the key, so "not marked" is trivially true. They guard against a plausible mis-fix rather than proving the fix, and are flagged as such rather than counted as regression tests.

A note on the aislop gate

The first cut of #386 added 47 lines to duplicate_detection.py and tripped complexity/file-too-large. Rather than add an ignore directive, I measured the real budget — the gate warns at 991 lines, a 10% grace band over the configured max: 900, and the file sat at 978 — then expressed the same logic, twice over as review findings landed, by moving rationale into the docstring and condensing comments. Final: 989 lines, gate at 100/100. No threshold lowered, no exclusion added.

Deliberately not done

  • core._resolve_gerrit_info still returns .gitreview unconditionally, ahead of an explicitly configured GERRIT_SERVER — the "arguably backwards" precedence Duplicate detection cannot distinguish derived config #386 calls out. is_derived_key is now the tool to fix it, but it touches the push path and deserves its own change.
  • config._read_gitreview_host still reads the local working-directory file — the residual exposure from Duplicate detection cannot distinguish derived config #386's comment thread, needing PR provenance at derivation time.
  • derive_gerrit_credentials' memo is unsound in production, not merely in tests. Its real inputs — an env var, git config, ~/.ssh/config — are absent from its key, and cli.py:2329 mutates G2G_RESPECT_USER_SSH at runtime, so whether that mutation is visible depends on call ordering.
  • Verify pull_request_review runs are privileged #391 remains blocked on deciding whether to probe pull_request_review privileges or skip to the two-stage workflow_run redesign.

basedpyright reported 104 errors across tests/ that src/ was already
free of, because both the pre-commit hook and the [tool.pyright]
include list covered src/ alone.

Most were genuine. Tests passed a str where Inputs.gerrit_server_port
is declared int, passed "" where a Path | None was expected, and set
Orchestrator.workspace to a str immediately after the constructor had
already stored the Path. Thirteen loops searched action.yaml for a
named step and then used the result without checking that the search
had found anything, so a renamed step surfaced as an opaque
AttributeError on None rather than a message naming what was missing.

Test stand-ins for GerritInfo and GitHubContext now build the real
dataclasses, which checks their field names too. Inputs keeps its
SimpleNamespace stand-in behind a cast, because reconstructing ~30
required fields to set four reconciliation switches would bury what
each test actually varies.

Also drops a click compatibility shim from _get_combined_output. It
guarded against click below 8.2, where CliRunner mixes stderr into
stdout and result.stderr raises ValueError. Three other CliRunner
call sites already read result.stderr unguarded, so the suite has
required click 8.2 for some time; one helper compensating on its own
bought nothing.

No assertion was removed or weakened, and no suppression comment was
added. The suite collects and passes the same 1518 tests as before.

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Matthew Watkins <mwatkins@linuxfoundation.org>
basedpyright and ruff covered src/ alone, so tests/, scripts/ and
sitecustomize.py accumulated errors that no gate would catch. An
editor language server reads [tool.pyright] directly and checks
whatever file is open, so those errors surfaced during development
while CI stayed green.

The include list and the hook's files pattern now name every Python
path in the repository, and each cross-references the other so the
two stay together.

Tests keep the annotation latitude they already had under mypy,
through an executionEnvironment that disables
reportMissingParameterType. Annotating several hundred monkeypatch
and tmp_path parameters would add no checking beyond what pytest's
name-based fixture resolution performs at collection time.
Correctness rules still apply to tests in full.

reportUntypedFunctionDecorator is off for tests as well, because it
fires on every @pytest.fixture whenever pytest is not importable, and
the hook runs basedpyright without a project virtualenv. The
repository already tolerates that condition through
reportMissingImports.

The dev extra now pins click>=8.2. Several tests read CliRunner's
result.stderr, which click captures separately only from 8.2 onward
and otherwise raises ValueError, so this records a requirement the
suite already had. The runtime floor stays at 8.1.7, which remains
accurate for the package itself.

Finally, the dev extra is referenced from the default dependency
group. uv installs that group for a bare `uv sync`, so `uv run
pytest` picks up the project's pinned pytest; previously the extra
went uninstalled and the call fell through to whichever pytest sat on
PATH. The extra stays an extra because CI installs it by name and
cannot see dependency groups.

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Matthew Watkins <mwatkins@linuxfoundation.org>
Completes issue lfreleng-actions#385. An autouse fixture already cleared
gerrit_urls._BASE_PATH_CACHE; the sibling caches it named were left to
a follow-up.

ssh_config_parser memoises four values, and each derives from state
tests routinely monkeypatch. Two are lru_cache(maxsize=1) over
zero-argument functions reading G2G_RESPECT_USER_SSH and git config
user.email, so with one slot and no key *any* two tests collide and
the second sees the first one's environment. derive_gerrit_credentials
keys on (host, organization, port) while its answer also depends on
those two caches and on ~/.ssh/config, none of which appear in the
key. _ssh_config_cache keys on the config path, never its contents, so
rewriting the file under an unchanged HOME serves the previous parse.

The module already exposed clear_credential_cache(), which resets all
four, but only two test modules called it and only from setup_method:
that guards those files on the way in while leaving the caches
populated for whatever runs next. Calling it from an autouse fixture
covers every test, and clearing on both sides stops a dirty cache
reaching a test as well as leaving one behind. No src/ change was
needed.

No leak between existing tests was demonstrable, so this is latent
rather than live. Every test reaching derive_gerrit_credentials either
patches it wholesale or clears in setup_method, which is why it has
held so far. It would bite whenever someone adds a test that derives
credentials for real, and would surface far from its cause.

The new tests pair a polluter with a victim for each cache. Verified
non-vacuous: with the autouse fixtures disabled all five victims fail,
with stale environment, stale git identity and a stale SSH config
parse respectively.

The explicit clear in test_gerrit_urls_more.py at module scope is now
redundant and goes. The one mid-test survives, because it is not a
boundary: without it the second half of that test never probes at all
and its assertions pass off the value cached by the first half. Its
comment claimed the fixture covered it, which was wrong.

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Matthew Watkins <mwatkins@linuxfoundation.org>
Copilot AI balanced review requested due to automatic review settings August 26, 2026 08:22
@github-actions github-actions Bot added the bug Something isn't working label Aug 26, 2026

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

Resets process-wide caches between tests and tracks derived Gerrit configuration so duplicate detection can prefer .gitreview. The diff also contains stacked #392 static-analysis changes.

Changes:

  • Adds cache/provenance isolation fixtures and regression tests.
  • Tracks derived configuration and adjusts duplicate-detection precedence.
  • Updates stacked test typing and static-analysis configuration.

Reviewed changes

Copilot reviewed 28 out of 29 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/github2gerrit/config.py Adds derived-key provenance tracking.
src/github2gerrit/duplicate_detection.py Prefers .gitreview over derived values.
src/github2gerrit/cli.py Controls provenance during config persistence.
tests/conftest.py Resets global caches and provenance.
tests/test_cache_isolation.py Tests cache isolation regressions.
tests/test_config_helpers.py Tests provenance behavior.
tests/test_duplicate_detection.py Tests duplicate resolution precedence.
tests/test_gerrit_urls_more.py Removes redundant cache fixture.
tests/test_ssh_artifact_prevention.py Adds type-narrowing assertions.
tests/test_reconciliation_plan_and_orphans.py Uses typed test models.
tests/test_reconciliation_extracted_module.py Uses typed test models.
tests/test_pr_content_filter_integration.py Corrects port type.
tests/test_pr_commands.py Adds type annotations.
tests/test_orphan_rest_side_effects.py Uses typed Gerrit information.
tests/test_mapping_comment_digest_and_backref.py Corrects model argument types.
tests/test_mapping_comment_additional.py Uses typed test models.
tests/test_gerrit_pr_closer.py Narrows parsed result type.
tests/test_gerrit_change_id_footer.py Corrects port type.
tests/test_core_ssh_setup.py Corrects port types.
tests/test_core_prepare_commits.py Corrects port type.
tests/test_core_integration_fixture_repo.py Corrects port type.
tests/test_composite_action_coverage.py Adds missing-step assertions.
tests/test_cli.py Targets Click 8.2 output behavior.
tests/test_action_outputs.py Adds missing-step assertion.
tests/test_action_environment_mapping.py Adds missing-step assertions.
tests/fixtures/make_repo.py Simplifies byte-output decoding.
pyproject.toml Extends static analysis and dev dependencies.
.pre-commit-config.yaml Broadens Python hook coverage.
uv.lock Locks updated development dependencies.

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

Comment thread src/github2gerrit/cli.py

This comment was marked as resolved.

This comment was marked as resolved.

This comment was marked as resolved.

This comment was marked as resolved.

Closes issue lfreleng-actions#386. Duplicate detection short-circuited on GERRIT_SERVER
and GERRIT_PROJECT from the environment, but by the time it runs those
are usually derived fallbacks rather than operator intent:
_load_effective_inputs derives them and writes them to the environment
first.

Two consequences. The .gitreview resolution below that early return was
effectively dead on the normal path, including the base-ref pinning
added for fork pull requests. And the derived project is a guess taken
from the GitHub repository name, so the OpenDaylight repository
integration-distribution yielded that same string where Gerrit holds
integration/distribution. Detection queried a project that does not
exist and reported no duplicates, silently.

Config values now carry provenance. mark_derived_keys records which
keys hold a derived fallback and is_derived_key reports it, backed by
G2G_DERIVED_KEYS. That is process-scoped configuration, settled once
before the bulk thread pool starts, so unlike per-pull-request state it
is safe to hold in the environment.

apply_parameter_derivation marks only those keys whose environment
value is empty, matching the test apply_config_to_env applies moments
later; a key derivation supplies but the environment already defines
explicitly stays explicit. The save-after-success call site opts out,
since it persists to the config file without ever touching the
environment.

Duplicate detection now short-circuits only when both host and project
are explicit, letting the per-pull-request .gitreview resolve otherwise.
Where .gitreview yields nothing the derived pair still applies, because
returning nothing would disable detection entirely wherever it
previously ran against a derived project; a query against a possibly
wrong project is no worse than no query. The fork-trust handling is
untouched.

Provenance is tracked per field, so a partially configured target keeps
operator intent: where only one of the pair is derived, .gitreview
supplies that field alone and the explicit one survives, and only
where it carries a value. A .gitreview naming a host but no project
parses successfully, and taking its empty project would drop the
project qualifier from the query and search every project on the
server, letting an unrelated change of the same title block a
legitimate submission.

A GERRIT_PROJECT arriving from the configuration file counts as
derived, whichever release or hand wrote it. That file is sectioned
per-organization while a project is per-repository, so a value there is
wrong for every repository in the org but one; treating it as a
fallback keeps it usable while letting .gitreview outrank it, and
covers configurations written before such saves stopped.

Derived parameters persisted to the configuration file now omit
GERRIT_PROJECT. That value is per-repository while the file section is
per-organization, so saving it handed every other repository in the org
one repository's project name, and it returned on the next run as
configuration indistinguishable from operator intent.

Verified against the unmodified detector: the new test for a project
containing a path separator fails there, showing the hyphenated name
in the live query URL.

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Matthew Watkins <mwatkins@linuxfoundation.org>

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

Copilot reviewed 28 out of 29 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/github2gerrit/config.py:828

  • This provenance block is skipped when G2G_ENABLE_DERIVATION=false, because apply_parameter_derivation returns at lines 770–775 first. A legacy org-level GERRIT_PROJECT is then exported without a derived mark, so duplicate detection treats it as explicit and again short-circuits before .gitreview, reproducing #386 for a supported configuration. Record provenance for config-sourced projects independently of whether new derivation is enabled, and add the disabled-derivation regression case.
    if (
        mark_derived
        and cfg.get("GERRIT_PROJECT", "").strip()
        and (os.getenv("GERRIT_PROJECT") or "").strip() == ""
    ):
        mark_derived_keys(["GERRIT_PROJECT"])

@ModeSevenIndustrialSolutions
ModeSevenIndustrialSolutions marked this pull request as ready for review August 27, 2026 12:16
@ModeSevenIndustrialSolutions
ModeSevenIndustrialSolutions requested a review from a team August 27, 2026 12:16
askb
askb previously approved these changes Aug 29, 2026
@ModeSevenIndustrialSolutions
ModeSevenIndustrialSolutions dismissed askb’s stale review August 29, 2026 10:24

The merge-base changed after approval.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Duplicate detection cannot distinguish derived config Global base-path cache leaks between tests

3 participants