gitea-saas-core#29: transactional + retryable review outcome application #32
No reviewers
Labels
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: pbwraith/gitea-saas-core#32
Loading…
Reference in New Issue
Block a user
No description provided.
Delete Branch "issue-29"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Transactional and retryable review outcome application (#29)
ReviewPolicy.poll_and_review()previously recorded(repo, PR, head SHA)asreviewed before the policy action succeeded, so a transient failure at any
boundary stranded the PR forever.
This PR makes outcome application transactional + retryable in
gitea_saas/review_policy.py:reviewstable) only after itsrequired policy action fully succeeded (criterion 1).
review_statetable:attempt count, last error, and
merged_step/closed_stepidempotencycheckpoints (criterion 2).
outcome=failedresult (error+attempts); the same unchanged headstays eligible for retry on the next poll (criterion 3).
normally (criterion 4).
double-merge a PR or double-close an issue; the delivery debit keeps its
billing-core idempotency key
<repo>#<pr>@<head_sha>(criterion 5).tests/test_review_policy.pyinjects one-shotfailures after verdict creation and at label, comment, merge, close and
debit boundaries, proving a later poll completes the same head exactly
once (criterion 6). Legacy
reviewsrows are preserved untouched(criterion 7).
python -m pytest -qpasses (169 passed, 1 skipped) andgit diff --checkis clean (criterion 8).Out of scope as filed:
gitea_client.pyHTTP method implementation, liveGitea mutation tests, auth/dashboard PRs.
Closes #29
[lane2] gitea-saas-core#29 implemented — commit
6cff159on branchissue-29, PR #32.Evidence:
python3 -m pytest -q: full suite 169 passed, 1 skipped (11.1s);tests/test_review_policy.py: 26 passed including the new transactional/retry coverage.git diff --check: clean.<repo>#<pr>@<head_sha>preserved).reviewsrows are preserved untouched; retry metadata lives in the newreview_statetable (attempts,last_error,merged_step/closed_stepcheckpoints).Ready for manual review.
Blocking: retry recovery begins by calling
list_open_prs(). Once a real merge succeeds, that PR is no longer open. If debit, label removal, issue closure, or a comment fails after the merge, the persisted retry row can never be reached on a later poll. The tests miss this because the fake client continues returning the merged PR fromlist_open_prs().Please persist the remaining post-merge work before merging and drive incomplete recovery independently of the open-PR scan (or use an equivalent durable delivery/retry queue). Add a test where the merged PR disappears from
list_open_prs()before the next retry. Also cover a crash/failure after a comment or label side effect succeeds so replay does not duplicate externally visible actions. Re-run the full suite on the updated branch.[worker-bounce-fix attempt 1]
Review finding — fixed on the EXACT existing head branch
issue-29(no new branch, no duplicate PR). New commitc3915d2(fast-forward6cff159..c3915d2).Finding: retry recovery began with
list_open_prs(). Once a real merge succeeds the PR is no longer open, so the persistedreview_staterow could never be reached on a later poll — debit, label removal, issue close, or comment failures after the merge were stranded forever. The tests missed it because the fake kept returning the merged PR fromlist_open_prs(). Additionally, post-merge label removal and the success comment had no checkpoints, so replay after a crashed side effect could duplicate visible actions.Fix (finding-by-finding):
_apply_policynow calls_ensure_plan()BEFORE_safe_merge(), writing verdict/tier/linked-issue intoreview_stateso the row alone is sufficient to finish the job (gitea_saas/review_policy.py:594-600; method:233-257).recover_incomplete()walksreview_staterows directly, re-fetches each PR viaget_pr()(works for merged/closed PRs), and resumes the remaining checkpointed steps debit → label → close → comment, with crash-window adoption when the live PR already reports merged/closed (:385-470).poll_and_review()now runs recovery FIRST, before the open scan (:348-352).debit_step,label_step,comment_stepadded alongsidemerged_step/closed_step; new columns ALTER-added idempotently for pre-existing DBs via_ensure_review_state_columns,:170-180). A replay skips already-applied steps: one merge, one debit, one label removal, one close, one success comment.test_merged_pr_gone_from_open_scan_recovered_after_post_merge_failureremoves the merged PR fromlist_open_prs()exactly as real Gitea does; the next poll still completes the outcome exactly once via recovery.test_crash_after_label_removed_no_duplicate_visible_actions(comment fails after label removal succeeded) andtest_crash_after_success_comment_before_completion_record(process dies after the success comment, before the completion row) both prove replay performs no duplicate visible action.Evidence:
python3 -m pytest -q: 172 passed, 1 skipped (was 169 passed, 1 skipped; +3 new recovery tests).tests/test_review_policy.py: 29 passed.git diff --check: clean.gitea_saas/review_policy.py+tests/test_review_policy.pytouched (the issue's owned files).PR left open for manual review; not merged by worker.
[governor-review] BOUNCED — production recovery still cannot safely identify the reviewed merge
Reviewed head
c3915d25331f223083048432fcb20b03065537b4against baseccccfbf879bbd07512e86757abcf0c494f5b49f2in a fresh synthetic merge. Full suite: 172 passed, 1 skipped; diff check clean. The new retry table and post-merge checkpoints address most of the prior failure path, but three production blockers remain:_safe_merge()still callsself.gitea.merge_pr(repo, pr_number)without the reviewedpr["head_sha"]. The repaired production client in PR #31 only closes the review-to-merge TOCTOU gate whenexpected_head_shais supplied. Pass the exact reviewed head and add an integration regression proving SHA A reviewed/current SHA B causes no merge.recover_incomplete()relies onget_pr()returningmergedorstate, but the productionGiteaClient.get_pr()shape from PR #31 returns neither. A real merge that succeeds just before themerged_stepcheckpoint will disappear from the open scan and recovery will continue to skip it forever. Coordinate the production client shape (include the rawmergedboolean andstate, or add an equivalent explicit operation) and test with the production-shaped fake, not a richer test-only PR dict.state == "closed"is not proof of a merge. A PR can be closed without merging; the current recovery path would adopt that asmerged_step, debit the client, remove labels, and close the linked issue. Require positivemerged is True. Add a closed-but-unmerged negative regression proving no billing or issue close occurs.Keep the durable recovery independent of
list_open_prs()and the current idempotency/checkpoint coverage. Repair this same branch and PR.[worker-bounce-fix attempt 2]
Governor review (2026-08-29) findings — fixed on the EXACT existing head branch
issue-29(no new branch, no duplicate PR). New commits:d522ea2(merge of currentmain, bringing in the merged PR #31 client contract) +30e71d4(bounce-fix). Head now30e71d4; PR re-verified: state=open, merged=false, headissue-29, mergeable=true, 3 changed files.Finding 1 — merge not pinned to the reviewed head.
_safe_merge()calledmerge_pr(repo, n)with no expected head, so the production client's review-to-merge TOCTOU gate (PR #31) was never armed from the policy call site.Fix:
_safe_mergenow passesexpected_head_sha=pr["head_sha"]— the exact reviewed SHA — through togitea.merge_pr(...)(review_policy.py:_safe_merge). The client (already on main via the merge) compares the pre-merge live head to it and fails closed BEFORE any POST;head_commit_idis the reviewed SHA.Regression:
test_reviewed_head_pinned_so_moved_branch_is_never_merged— SHA A reviewed, branch moves to SHA B before the merge attempt: no merge POST, no debit, no close, no success comment; next poll re-reviews SHA B and merges exactly once.Finding 2 — recovery relied on
merged/statekeys the production client shape does not return.recover_incomplete()readlive["merged"]/live["state"], but the normalizedGiteaClient.get_pr()shape (PR #31) carried neither, so a real merge succeeding just before themerged_stepcheckpoint would vanish from the open scan and stay unrecovered forever.Fix (coordinated client shape, as suggested):
GiteaClient._normalize_prnow includes the rawmergedboolean andstatein every normalized PR (list_open_prsandget_pr), so recovery positively identifies merges from the production shape (gitea_client.py:_normalize_pr).Tests use the production-shaped fake —
merged/statefields on every PR,list_open_prsreturns open-unmerged only,merge_prmutates the live record to merged/closed exactly like the API would — not a richer test-only dict (tests/test_review_policy.py:FakeGitea).Regression:
test_crash_window_adopts_merged_pr_from_production_shape— plan persisted, checkpoint write missed, live record reportsmerged=True: recovery adopts it and finishes debit → label → close → comment without a second merge request. The existingtest_merged_pr_gone_from_open_scan_recovered_after_post_merge_failure(PR disappears from the open scan) is retained and passes against the production-shaped fake.Finding 3 —
state == "closed"treated as proof of a merge. A closed-but-unmerged PR was adopted as merged, triggering a debit, label removal, issue close, and success comment on a merge that never happened.Fix: both adoption sites (
_safe_mergeandrecover_incomplete) now require positivemerged is True;state == "closed"alone never counts.Negative regression:
test_closed_unmerged_pr_recovery_never_debits_or_closes— pending outcome plan + live PR closed/unmerged: zero side effects (no debit, no label removal, no issue close, no success comment), row left unrecovered.Evidence:
python3 -m pytest -q: 201 passed, 1 skipped (was 172 passed, 1 skipped; +3 new recovery regressions, and the merged PR #31 client tests now run in-suite).tests/test_review_policy.py: 32 passed.git diff --check: clean.30e71d4only:gitea_saas/review_policy.py,tests/test_review_policy.py, plus the sanctioned client-shape coordination ingitea_saas/gitea_client.py(2 lines + docstrings). No other files, no scope expansion.PR left open for manual review; not merged by worker.
[governor-review] BOUNCED — recovery can still attribute the wrong merged head
Reviewed repaired head
30e71d44bafc0bccd35a36c920bd29165d47959eagainst current basefc55c41dd0d85100ce3b9896331bdb7ee9872ffc(including merged PR #31) in a fresh synthetic merge. Full suite: 201 passed, 1 skipped; diff check clean. The reviewed-SHA call site, productionmerged/stateshape, and closed-unmerged guard are fixed.Two head-integrity gaps remain:
recover_incomplete()adopts anyget_pr()record withmerged is Truefor the same PR number, without verifyinglive["head_sha"] == review_state.head_sha. If plan A was persisted, then the branch moved and head B was merged externally, the retry row for A will debit/close/comment as though reviewed head A was the delivered merge. Require both positive merge proof and exact planned-head equality. A merged different head must fail closed and surface a structured mismatch; it must not debit, remove labels, close the linked issue, or post a success comment. Add that negative regression._safe_merge()passesexpected_head_sha=pr.get("head_sha"); when the candidate lacks a head SHA this isNone, which invokes the production client's backward-compatible live-head fallback and can merge a head the policy never reviewed. Reject a missing/empty reviewed SHA before calling the client and prove no merge attempt or downstream side effect occurs.Preserve all current durable recovery/checkpoint coverage and the same-head crash-window success case. Repair this same branch and PR.
[owner-guidance]
Authorize another repair iteration on this same
issue-29branch and PR #32. The latest instruction to work through the PR queue authorizes closing these newly observed head-integrity gaps; do not accept them as follow-up work and do not relax the review bar.Implement governor comment
10539exactly: recovery may adopt a merged PR only whenmerged is Trueand the live merged head equals the persisted reviewed head; a different merged head must produce a visible fail-closed mismatch with no billing/label/issue/comment side effects. Also reject a missing reviewed head before calling the merge client, proving no live-head fallback or downstream action occurs.Preserve the reviewed-SHA merge pin, production normalized merge fields, closed-unmerged negative gate, durable recovery independent of open-PR scans, and current checkpoint/idempotency coverage. Repair this PR; do not open a replacement.
This authorization supersedes the retry-budget gate for this finding.
[worker-bounce-fix attempt 3]
Commit:
b46c356on branchissue-29(PR #32 retained, head30e71d4->b46c356). Files:gitea_saas/review_policy.py,tests/test_review_policy.py. Full suite:python3 -m pytest -q-> 203 passed, 1 skipped (was 201 passed, 1 skipped; +2 new regressions).git diff --checkclean.Implements the owner decision (2026-08-29T10:04, for governor review 10539) — the two head-integrity gaps, finding by finding:
Recovery must verify BOTH positive merge proof AND exact planned-head equality.
recover_incomplete()crash-window adoption previously accepted any live record withmerged is Truefor the PR number without comparing heads — if plan A was persisted and head B was merged externally, the retry row for A would debit/close/comment as though reviewed head A had been delivered. Adoption now requireslive["head_sha"] == review_state.head_sha. A merged DIFFERENT head fails closed with a structuredHEAD_MISMATCHresult (persisted attempt with the mismatch message + result entry), performs NO debit, NO label removal, NO issue close, NO success comment, leavesmerged_stepunmarked, and leaves the row for the operator. The same-head crash-window success case is preserved.Negative regression:
test_recovery_merged_different_head_fails_closed— plan at sha-A, live merged at sha-B: outcome failed, code HEAD_MISMATCH, message names both heads, all side-effect lists empty, merged_step unmarked, head not recorded as reviewed.Missing/empty reviewed head rejected before the merge client.
expected_head_sha=Nonewould invoke the production client's backward-compatible live-head fallback and could merge a head the policy never reviewed. Two-layered rejection:_apply_policy()now refuses a missing/emptyhead_shaBEFORE the plan write (with the semanticmerge refused: candidate PR carries no reviewed head_sha — ... refusing to fall back to the live headfailure; notereview_state.head_shais NOT NULL, so the DB would have rejected the plan anyway — now with the correct meaning), and_safe_merge()independently refuses to callmerge_prwithout a pinned reviewed head (expected_head_sha is now the guard-verifiedreviewed_head, never a bare.get()). Zero merge attempts, zero downstream side effects.Negative regression:
test_missing_reviewed_head_never_reaches_merge_client— candidate withhead_sha=Noneunder an approve+auto-merge gold tier: structured failure naming the missing reviewed head,gitea.merged == [], no debit/label/close/comment, head not recorded as reviewed.Preserved per the owner directive: the reviewed-SHA merge pin, production normalized
merged/stateshape, closed-unmerged negative gate (state == "closed"never counts), durable recovery independent of the open-PR scan, and the full checkpoint/idempotency coverage (one merge, one debit, one label removal, one close, one success comment).Evidence:
python3 -m pytest -q-> 203 passed, 1 skipped;git diff --checkclean; PR #32 verified after push: state=open, merged=false, headissue-29@b46c356, mergeable=true, changed files = 3 with non-empty diff.bouncedremoved; issue #29 staysin-review. Ready for manual re-review.[governor-review] MERGED — exact head
b46c356d64f1891e32024f3107937d35c7cc3e9areviewed against basefc55c41dd0d85100ce3b9896331bdb7ee9872ffc.Evidence:
git diff --checkpassed.HEAD_MISMATCHwith no debit, label removal, issue close, or success comment.head_shanow fails before any plan write or merge-client call, so the production client's live-head compatibility fallback cannot merge an unreviewed commit.Linked issue #29 is closed.