refactor(gates): migrate retry-client carriers onto _gitapex_github_http.py - #1472
Conversation
…ent migration Refs #729
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1472 +/- ##
==========================================
+ Coverage 99.59% 99.62% +0.03%
==========================================
Files 133 133
Lines 23507 23315 -192
Branches 2840 2816 -24
==========================================
- Hits 23411 23227 -184
+ Misses 96 88 -8 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Status on the two failing required checks, both expected mid-execution states, not regressions:
Both will resolve together once execution reaches Step 8/9. No fix needed for either right now. Generated by Claude Code |
…apex_github_http.py Refs #729
…empts rationale graphql_call arrived from gitapex_sync_pr_publish.py with its own four hand-written add_header calls, re-declaring inside the shared module the exact header set build_headers (one function above it) already builds for the REST path. Route it through build_headers instead. call_json's docstring gains the max_attempts/idempotency rationale that was deleted along with the per-carrier _call docstrings it used to live in -- gitapex_post_merge_retro.py and gitapex_stale_retro_stub_autoclose.py both cite "call_json's own docstring" at their max_attempts=1 call sites, a reference that resolved to nothing until now. Refs #729
_format_code and _HTTP_TIMEOUT_SECONDS both belonged to the inline retry loop and _default_opener this script deleted when it moved onto _gitapex_github_http.request_with_retry. Neither has a production caller left; _format_code survived only because a unit test asserted on it directly, and the timeout constant only because a test compared the real (shared-module) timeout against this module's now-unrelated copy of the same number. Delete both, delete the private-helper unit test with no remaining subject, and repoint the timeout assertion at the constant that actually drives it. The module docstring gains the _gitapex_github_http delegation paragraph the other four migrated carriers already carry, and apply_call's own docstring now records the disclosed network-failure message-text difference that until now was documented only in a test comment. Refs #729
The migration gave the shared module its first cross-module consumer of this helper: gitapex_gate_retro_title_convention_citation.py raises the GitHubApiError itself (request_with_retry hands it a raw status code and never sees it again), so it has to render the code the same way the shared module does. It did that by importing _format_code -- the only place in .github/scripts/ importing an underscore-prefixed name from another module; every other cross-module import there names public symbols only. Promote the helper rather than let the exception stand, or re-inline the two-line convention this PR exists to stop copying. No behavior change: the function body, every message it feeds, and every caller's output are identical. Refs #729
… it checks The test asserted only the signature default for `opener` while its name claimed it also covered the `sleeper=None -> time.sleep` default, which nothing in the file exercises. A name that overstates coverage is how a gap stays invisible; state the real boundary in the name and the comment. Refs #729
…r step 8 refactor pass) Refs #729
tests/test_gitapex_gate_acm_issue_disclosure.py's migration comment says the retry mechanics it stopped testing locally -- "5xx retry, IncompleteRead retry, repeated-network-failure raise" -- are "covered there by tests/test_gitapex_github_http.py". Two of those three were not. Only the 5xx path had a direct test in the shared module's own test file; request_with_retry's `except (OSError, http.client.IncompleteRead)` arm and call_json's raise-after-exhausted-network-retries path had none. Established by mutation, not by reading: collapsing that except clause to a bare `except OSError` -- which reintroduces the crash-instead-of-retry bug, since http.client.IncompleteRead is an HTTPException and not an OSError subclass -- left all 29 tests in tests/test_gitapex_github_http.py green. Three carrier test files did catch it transitively, so the branch was never uncovered repo-wide; but the shared primitive's most subtle line had no test of its own, and the comment pointing readers at this file for it was wrong. Three tests added, no production change: - request_with_retry retries an IncompleteRead body read (this kills the mutant above); - it treats a URLError as the sentinel code 0 carrying `str(error)` as the body, retrying like a 5xx instead of early-breaking; - call_json still surfaces GitHubApiError, rendered through format_code as "HTTP network error", once those attempts are exhausted -- the direct replacement for the test_call_raises_after_repeated_network_failure case deleted from the acm gate's own test file during the migration. Refs #729
…dant tests Refs #729 Step 8 adversarial review (reviewing-an-artifact, reuse-and-simplification axis) found graphql_call's exception handling narrower than request_with_retry's own: `except urllib.error.URLError` alone misses http.client.IncompleteRead (not a URLError/OSError subclass), letting a stalled/truncated GraphQL body read escape uncaught instead of retrying. Widened to `except (OSError, http.client.IncompleteRead)` to match, with a regression test, and corrected the docstring's "moved verbatim ... already correct" claim now that it names two real edits. Also closes out the two remaining Step 8 findings: a direct format_code unit test (previously only exercised indirectly through error messages), and trimming test_gitapex_sync_pr_publish.py's graphql_call block down to an identity-wiring assertion -- that module re-exports the shared module's graphql_call unwrapped, so the retry/backoff/transient-marker behavior it re-tested was already covered, more precisely, in tests/test_gitapex_github_http.py.
Refs #729 drafting-a-pr-to-merge's Step 8 inner layer (reviewing-an-artifact, 5 fresh isolated subagents against the redacted diff) surfaced 4 genuine, independently-verified fixes beyond what the earlier refactor/adversarial pass already caught: - blast-radius axis: .github/workflows/post-merge-retro.yml's own header comment still cited the deleted local `_call()` retry ceiling; updated to name the actual `_gitapex_github_http.call_json` calls it now describes. - convention axis: gitapex_sync_pr_publish.py's module docstring claimed graphql_call was "moved wholesale ... was the correct one", directly contradicting the function's own docstring (which discloses the IncompleteRead exception-handling fix made on arrival). Corrected, and apply_call's own docstring now enumerates all three disclosed behavioral differences from its former local loop (body-text wrapping, JSON separator spacing, network-error display text) instead of only the first. - convention axis: format_code's docstring claimed "every other cross-module import ... names public symbols only", falsified by gitapex_apply_rulesets.py's pre-existing private `_HTTP_TIMEOUT_SECONDS` import. Corrected to name that exception instead of denying it exists. - convention axis: tests/test_gitapex_post_merge_retro.py and tests/test_gitapex_stale_retro_stub_autoclose.py still carried the full set of retry-mechanic tests (5xx retry, persistent-4xx raise, network-failure raise, IncompleteRead retry) that duplicate coverage now centralized in tests/test_gitapex_github_http.py -- the same inconsistency already fixed for test_gitapex_gate_acm_issue_disclosure.py and test_gitapex_sync_pr_publish.py earlier in this PR. Trimmed both to one integration-style test each confirming GitHubApiError still surfaces correctly, matching the established precedent; kept the close_stub_issue PATCH-close wiring test that proves this call site does NOT inherit the comment-POST's max_attempts=1, dropping only its redundant IncompleteRead variant. Security, correctness, and reuse-and-simplification axes found no new defects requiring a fix: their candidate findings were independently verified to already be disclosed in this PR's own Risk/Non-goals sections (the 404 stderr log line, JSON separator change, and network-error display text change), or are pre-existing gaps confirmed unchanged since before this migration (graphql_call's own duplicated retry-loop shape vs. request_with_retry, and its lack of a max_attempts parameter). Full suite: 6923 passed. mypy/ruff clean.
Summary
Migrates 5 hand-copied GitHub API retry-client implementations onto the
shared
.github/scripts/_gitapex_github_http.pymodule, closing criterion1 of issue #729 (up to 3 separate PRs total per that issue's own
Constraints; this is PR 1 of 3).
Facts
across 7 files. 2 were already migrated (issue feat(merge-retrospective): compute Gate-Preventable Repair Rate (GPRR) from existing retrospective issues #726/PR feat(merge-retrospective): compute Gate-Preventable Repair Rate (GPRR) from retrospective issues #731). This PR
migrates the remaining 5
.github/scripts/*.pycarriers named in theissue's own revised scope (2026-08-07 and 2026-08-29 comments):
gitapex_gate_acm_issue_disclosure.py,gitapex_post_merge_retro.py,gitapex_stale_retro_stub_autoclose.py(2 internal copies),gitapex_gate_retro_title_convention_citation.py,gitapex_sync_pr_publish.py(REST
apply_call+ GraphQLgraphql_call).hooks/gitapex_check_pr_issue_acm_disclosure.pyis deliberately excludedper the issue's 2026-08-29 comment (redistribution-boundary conflict --
hooks/must work standalone in a distributed plugin, perdocs/repository-layout.md, the same reasoning already established forhooks/gitapex_check_skill_audit_disclosure_or_waiver.py).Assumptions
Criteria 2 (stdin/file decode) and 3 (JSON shape guard) are separate,
later PRs per the issue's own Constraints -- not addressed here.
Risk / blast radius
Touches 5 CI gate/report scripts' own GitHub-API call paths (retry/error
handling only -- no endpoint URLs, payloads, or business logic changed).
Public function signatures and behavior are preserved as a pure extraction
refactor, with the following disclosed, individually-verified exceptions
found by this PR's own mandatory Step 8 refactor + independent adversarial
review passes (each verified live with a scripted repro, not by reading):
gitapex_stale_retro_stub_autoclose.py: an HTTP 200 response with anunparseable or empty body now raises
GitHubApiErrorinstead ofsilently returning an empty list, since
_fetch_issues_pagewasreplaced by the shared
fetch_json_page. Verified unreachable indocumented GitHub behavior (the List repository issues and List
issue comments REST endpoints document only 200/301/404/422 and
200/404/410 respectively, with an empty result set always
[], never azero-byte body) -- reachable only via a flaky-proxy/CDN truncation, the
same case
_gitapex_github_http.py's own docstring already documentsas an intentional raise. In that one reachable case the OLD behavior
was actively worse: a truncated comments-list response made
_has_close_commentreturnFalse, soclose_stub_issuewould post aduplicate close comment -- defeating that function's own documented
duplicate-comment guard. The new loud-failure behavior is a net
improvement, not a regression; no fix applied.
gitapex_sync_pr_publish.py: outgoing JSON request-body whitespacechanged from compact (
separators=(",", ":")) to default spacing forapply_call's REST bodies (graphql_callkeeps compact separators,moved verbatim). Confirmed harmless: JSON insignificant whitespace is
non-semantic (RFC 8259 section 2.7),
Content-Lengthis recomputed from theencoded bytes, and this file's only REST bodies are plain field maps
(file content goes through GraphQL, not a byte-exact REST body). Not
normalized -- the new spacing matches the other 4 migrated carriers.
gitapex_sync_pr_publish.py:apply_call's caught-exception surfacewidened from
HTTPError/URLErrorto the shared(OSError, http.client.IncompleteRead), matching the other 4 carriers'already-established shape. Confirmed a net improvement: a body-read
IncompleteRead/TimeoutError/ConnectionResetErrorpreviouslyescaped as an uncaught exception and now retries;
apply_callhas noexternal callers besides this file's own internals, and its 2 REST
POSTs are name-keyed creates GitHub itself dedupes (documented 201/409
or 201/403 outcomes, no observed second-success path), so the wider
retry surface introduces no duplicate-creation hazard.
zero consumers via a repo-wide grep:
gitapex_gate_retro_title_convention_citation.py'sis_resolvable_issuenow emits one extrastderrdiagnostic line on a404 (return value/attempt-count/backoff unchanged);
gitapex_sync_pr_publish.py'snetwork-error log text changed from
HTTP 000toHTTP network error(the shared module's
format_codeconvention, now appliedconsistently across all 5 carriers instead of only 4).
Every carrier's own existing unit test suite plus new tests on the shared
module (including a mutation-tested regression test added during the
adversarial-review pass, confirmed to kill a mutant that the pre-fix suite
missed) are the primary defense against further behavioral drift.
Rollback
Revert this PR's commits. No schema, data, or external-state change --
a plain code revert restores the prior per-file copies exactly.
Verification
Acceptance Criteria Map (this PR's scope):
_gitapex_github_http.pyrequest_with_retry/call_json/graphql_callto the shared module; each carrier delegates its retry-loop body while keeping its own public signature/behavior_gitapex_github_http.py; migrate 5 carrier filessync_pr_publish.py'sapply_callis dependency-injected deep into its own internal functions -- verified byte-identical wiring by the Step 8 adversarial review's owninspect.signatureidentity check, not assumedgrep -rn "def _call(" .github/scripts/<5 carriers>returns zero matcheshooks/gitapex_check_pr_issue_acm_disclosure.pystays excluded; gets a parity/sync test insteadtests/test_gitapex_check_skill_audit_disclosure_hook_sync.py's patterngit diffon the hooks file is emptyjson.loads-on-2xx bug is NOT fixed here (criterion 3's own separate PR)call_jsonfetch_json_pagepath used bygitapex_stale_retro_stub_autoclose.pywas already raise-on-unparseable before this PR and stays that way -- see Risk item 1 above)Commands run (final head, after the mandatory Step 8 refactor + adversarial
review passes):
Non-goals
shape-guard helper) are separate, later PRs per issue refactor(gates): extract shared stdin-decode / GitHub-API-retry / JSON-shape modules out of .github/scripts/*.py #729's own
Constraints section (up to 3 PRs total) -- not in this PR's scope.
json.loadson a 2xx response body" bug incall_json(used by 3 of the 5 carriers plushooks/gitapex_check_pr_issue_acm_disclosure.py,which stays out of scope) is deliberately NOT fixed in this PR -- that
is explicitly criterion 3's own scope. Nuance found during review:
gitapex_stale_retro_stub_autoclose.py's list-fetch call sites gothrough the shared
fetch_json_page(notcall_json), which alreadyraised on an unparseable 2xx body before this PR too -- so criterion 3's
remaining scope for that one file is narrower than "6 unguarded sites"
originally estimated; see Risk item 1 above for the detail.
NOT introduced by this PR (reproduced identically against the
pre-migration base commit), and left out of this PR's own scope as
genuinely separate concerns:
gitapex_stale_retro_stub_autoclose.py'slist_open_retro_issues/_has_close_commentdon't shape-check
fetch_json_page's return value against its owndocumented "every caller must shape-check it before use" contract,
unlike the sibling
gitapex_gate_acm_issue_disclosure.py, whichalready does this correctly. Natural fit for criterion 3's own PR.
gitapex_sync_pr_publish.py's_create_branch_ref/_create_prarenon-idempotent creates that retry 3x instead of the
max_attempts=1pattern this repository's own RFC 9110 conventionalready establishes elsewhere in this same file family -- likely
low-severity in practice (both endpoints appear name-keyed/GitHub-deduped)
but inconsistent once fixed everywhere else.
tooling timed out; flagging here instead so they aren't lost.)
drafting-a-pr-to-merge's ownStep 8 independent review (reuse-and-simplification axis), confirmed
NOT introduced by this PR, and left out of this PR's own scope the same
way as the two items above:
_gitapex_github_http.py's owngraphql_callstill duplicatesrequest_with_retry's request/retry-loop shape internally instead ofdelegating to it (found by this PR's own Step 8 independent review,
reuse-and-simplification axis) -- confirmed real, but not fully
fixable without giving
request_with_retrya pluggableis-this-retryable hook, since GraphQL retries on a 200 response
carrying an error-marker body while
request_with_retryunconditionallybreaks on any 2xx. Left as a known design gap rather than widening
this PR into that refactor; a natural fit for a follow-up PR.
graphql_callhas nomax_attemptsparameter, unlike its twosiblings (
request_with_retry,call_json), despitegitapex_sync_pr_publish.pyusing it for a non-idempotent
createCommitOnBranchwrite (found bythe same review). Confirmed pre-existing (the pre-migration local
copy already hardcoded the same 3-attempt loop) and low severity in
practice (the mutation's own
expectedHeadOidprecondition makes aretried-after-lost-success attempt fail loudly rather than duplicate
the commit) -- left unfixed here for the same narrow-scope reason as
the item above.
Checklist
skills/*/SKILL.md, design doc, security-relevant skill, or new deterministic checker script added by this PR (existing.github/scripts/*.pyfiles are modified, not newly added)evals/*/split.mdKept-edit-log entry in this PRSkill audit evidence
This PR modifies deterministic checker/gate scripts under
.github/scripts/*.py(2 of the 5 migrated carriers,
gitapex_gate_acm_issue_disclosure.pyandgitapex_gate_retro_title_convention_citation.py, are themselvesgate_*-named/.gitapex/ssot.json-registered gates;gitapex_stale_retro_stub_autoclose.pyis also ssot-registered), triggering
skill-audit-disclosure'sprocess-disclosure checks:
checker-script-adversarial-review: RAN --executing-a-branch-plan'sStep 8 sub-step 2 (an independent, fresh-subagent adversarial code
review over the full accumulated diff) reviewed all 5 carriers plus the
shared module for correctness, with live/scripted repros rather than
inspection alone -- see Risk section above for its findings.
deterministic-gate-quality: RAN -- the same Step 8 review explicitlychecked argument-order/value preservation at every migrated call site
(a 50-scenario differential harness comparing old vs. new behavior),
the non-idempotent-POST
max_attempts=1safety pattern at all 4applicable call sites, and message-format byte-identity on raised
errors -- the class of defect
evaluating-deterministic-gate-quality'sdimension 15 (fail-closed default) and this repository's own PR fix(plugin): brace ${CLAUDE_PLUGIN_ROOT} so apm-deployed hooks resolve #651
precedent (three fail-open defects that passed happy-path tests alone)
exist to catch.
defeat-test-disclosure: RAN -- Step 8 sub-step 1 (refactor/simplify)found and fixed a genuine detection-logic gap in the shared module's own
test suite:
request_with_retry'sexcept (OSError, http.client.IncompleteRead)branch had no direct test of its own (only indirectly covered through 3
carrier test files). A mutation test confirmed the gap (collapsing the
tuple to a bare
except OSErrorleft the shared module's own 29-testfile fully green) and a new regression test was added and confirmed to
kill that mutant (
1 failed, 31 passedwith the mutant present,32 passedwithout) -- committed to the test suite per this gate's own"commit the case ... one only constructed and run once ... can be
silently reintroduced" requirement. No detection logic was narrowed by
this PR (a pure extraction refactor), so no additional narrowing-edit
defeat case was required.
Merge gate: independent review
This PR is also subject to the
independent-review-pendingrequiredstatus check (see
.github/workflows/independent-review-pending.yml/.github/scripts/gitapex_gate_independent_review_pending.py). It stayspending/failing until a
## Independent review verdictsection namingthis PR's current head commit is recorded in this body --
drafting-a-pr-to-merge's own Step 8 records it once its independentreview completes. There is nothing for you to do here now: do not
pre-fill this section yourself, and do not remove this note.
Execution log
PlanApproved-- Branch Plan/ACM committed asdocs/superpowers/plans/2026-08-29-claude-gitapex-pr-729-1yu06c.md;1 wave-1 task (shared module) + 6 wave-2 tasks (5 carrier migrations +
1 hooks parity test) decomposed, no file-ownership conflicts.
BranchPublished--claude/gitapex-pr-729-1yu06cpushed with thetask-list commit.
TaskCompleted(T1)--request_with_retry/call_json/graphql_calladded to
_gitapex_github_http.py(commita77a56a5); tests/mypy/ruffclean. Governance-path pre-filter: no-match; full diff review: clean.
BranchDriftMerged(x3) -- shared branch mergedorigin/mainafterwave 1, after wave 2, and after the Step 8 refactor pass (12, 31, and 8
commits behind respectively, all clean merges, tests re-confirmed green
each time).
CoverageGapClosed-- Codecov flagged 7 patch-coverage-missing lines inwave 1's own new functions; closed with 15 new tests before wave 2
dispatched (18 -> 29 tests in
tests/test_gitapex_github_http.py).TaskCompleted(T2-T7)-- all 6 wave-2 tasks completed(
gitapex_gate_acm_issue_disclosure.py3e37f673,gitapex_post_merge_retro.py7bd7a1e4,gitapex_stale_retro_stub_autoclose.pyb020aad9,gitapex_gate_retro_title_convention_citation.py62a98992,gitapex_sync_pr_publish.py9dc6fa78, hooks parity test1e725250);merged onto the shared branch with no conflicts, diffs reviewed
(governance-path pre-filter + full model review, clean).
GateFixed--stdlib-only-claim-driftcorrectly flagged a stale barepython3usage example ingitapex_sync_pr_publish.py's docstring(pre-existing staleness surfaced by this PR's new import); fixed to
uv run --frozen python3 ...matching the established convention.Step8RefactorPass-- fresh-subagent refactor/simplify pass over thefull accumulated diff: 8 findings fixed (dead code, doc drift, header
duplication in
graphql_call, a private cross-module import promotedto public, a test that had stopped catching drift, a test name
overstating its own coverage), 3 genuine behavioral differences
surfaced and handed to the adversarial-review pass rather than fixed
outside that pass's own scope (commits
39a1f3a3,58caf28a,e33a5ac0,4d0c47a3).Step8AdversarialReview-- independent fresh-subagent adversarialreview verified all 3 handed-off findings live (see Risk section
above), found and fixed one test-coverage gap via mutation testing
(commit
94c9f3ae), and confirmed clean on a full independentcorrectness pass (call-site argument audit, non-idempotent-POST audit,
dependency-injection audit, import-graph audit) plus two pre-existing,
out-of-scope findings noted in Non-goals.
ReadyForReview-- all tasks complete, Step 8 clean, remote stateconfirmed to match local;
branch-plan-executinglabel removed, PRmarked ready for review. Ownership passes to
drafting-a-pr-to-merge.Step8IndependentReviewFixes-- a second round of Step 8 fixes(commit
1424f889) applying findings fromdrafting-a-pr-to-merge'sown inner-layer review (
reviewing-an-artifact, 5 fresh isolatedsubagents against the PR-narrative-redacted diff): fixed a stale
workflow-comment reference to the deleted
_call()retry loop(
.github/workflows/post-merge-retro.yml), 2 contradictory/falsedocstring claims (
gitapex_sync_pr_publish.py's module docstring andformat_code's own docstring), and trimmedtests/test_gitapex_post_merge_retro.py/tests/test_gitapex_stale_retro_stub_autoclose.py'sown leftover redundant retry-mechanic tests to match this PR's own
established precedent. 2 additional pre-existing, out-of-scope
findings recorded in Non-goals above. Full suite (6923 passed),
mypy, and ruff re-confirmed clean.
Independent review verdict
Outer layer: this repository does not have Anthropic's Claude Code
Review GitHub App configured (absent from this PR's own check-run list).
GitHub Copilot's
copilot-pull-request-reviewer[bot]was requested viarequest_copilot_reviewand, as of this recording, has not posted areview after roughly 20 minutes -- longer than its typical turnaround,
and consistent with this repository's other third-party review bots
(Devin, CodeRabbit) both currently running in a degraded/non-functional
mode on this repo (trial/credit and star-count gating, per their own
posted comments). Disclosed per this gate's own weaker-signal rule:
treat this layer as did-not-run rather than a pass.
Inner layer:
reviewing-an-artifact(low effort, 5-axis fan-out --correctness, blast-radius, reuse-and-simplification, convention,
security -- each a fresh, isolated subagent against this PR's own diff
with PR/commit narrative redacted) ran twice this session: once during
executing-a-branch-plan's own Step 8 (see Execution log above), andagain here as
drafting-a-pr-to-merge's own mandatory inner layer.4 confirmed findings from this second pass were fixed and pushed
(commit
1424f889; see Execution log entryStep8IndependentReviewFixesabove for the full list). 2 additional confirmed-but-pre-existing
findings and 1 minor cosmetic nit were disclosed rather than fixed,
staying within this PR's own narrow scope (see Non-goals above for the
2 pre-existing findings; the cosmetic nit -- a test helper defined after
its first inline-equivalent use in
tests/test_gitapex_github_http.py-- has zero functional impact and was judged not worth its own Non-goals
entry). Zero security-tier findings, confirmed or unconfirmed-concern,
on any pass.
Related Issue
Refs #729