Gitea client: production review-policy client operations (#28) #31
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#31
Loading…
Reference in New Issue
Block a user
No description provided.
Delete Branch "issue-28"
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?
Closes #28
Implements the production review-policy protocol on
GiteaClient, plus hermetic tests.What landed
gitea_saas/gitea_client.py:list_open_prs(repo)— all and only open PRs (state=open), follows Gitea pagination (page/limit, 50/page, defensive 100-page cap), normalized shape.add_label(repo, issue_number, label)/remove_label(repo, issue_number, label)— resolve the repo label ID, POST{"labels": [id]}/ DELETE/labels/{id}, then verify the resulting label state via a re-GET. Idempotent no-op when already present/absent. Unknown label or failed verification raisesGiteaClientError(never silently skipped).merge_pr(repo, pr_number) -> bool— Gitea merge endpoint; already-merged PR short-circuits to True; after the merge request the PR is re-fetched andmergedconfirmed. Non-2xx (e.g. 409) raises.close_issue(repo, issue_number)— PATCHstate: closedand confirm the issue reportsclosed.get_pr()now returns the normalized candidate shape:{number, body, head_sha, base_ref, labels (names), repo, changed_files}(changed_files kept for the review gate).GiteaApiErrorwith method/path/status/redacted body. Token and any echoed Authorization header are scrubbed from error text (defensively, even when a server echoes the request back).tests/test_gitea_client.py(new) — local threaded HTTP stub on 127.0.0.1; asserts request methods, paths, bodies, pagination, normalized results, failure responses, no-token-leak, and a source-derived contract test that the production client exposes every operation ReviewPolicy/the reviewer call. No live Gitea contact.Acceptance evidence
test_list_open_prs_follows_pagination_and_normalizes(50-item page 1 -> page 2 -> stop;state=open&limit=50on every request).merge_prposts to/pulls/{n}/mergeand confirms merged: 3 tests (incl. 409 raise, already-merged short-circuit).close_issuePATCHes{"state": "closed"}and confirms: 2 tests.test_production_client_exposes_review_policy_contract.python -m pytest -q-> 180 passed, 1 skipped (pre-existing intake-eval guard, unrelated to this change);git diff --checkclean.Out of scope (untouched)
Auth/dashboard #24-#27, credentials/config, live Gitea mutations, Stripe provisioning, review checkout behavior.
Blocking:
merge_pr()posts an empty object to/pulls/{n}/merge. This Gitea instance's published API schema requiresMergePullRequestOption.Do, so the production request will be rejected even though the mocked test passes. The test currently locks in the invalid{}payload.Please send a supported merge mode (at minimum
{"Do": "merge"}), includehead_commit_idfrom the reviewed PR head so a changed branch cannot be merged accidentally, and add a regression test that validates the real request shape. Re-run the full suite on the updated branch.The merge request previously posted an empty object, which this Gitea instance's published MergePullRequestOption schema rejects. Post the supported merge mode {"Do": "merge"} plus the head_commit_id read from the PR, so a branch that moved after review cannot be merged accidentally. Fail closed (GiteaClientError) when the PR record carries no head commit id. Regression tests assert the exact request shape.[worker-bounce-fix attempt 1]
Review finding (comment 10386):
merge_pr()posted an empty object to/pulls/{n}/merge; tests locked in the invalid{}shape.Fixed on the existing head branch
issue-28, commit1f2c85b(pushed):merge_prnow posts the GiteaMergePullRequestOptionschema shape{"Do": "merge", "head_commit_id": <reviewed head sha>}—Dois the mandatory merge mode, andhead_commit_idis read from the PR'shead.shaon the pre-GET, so a branch that moved after review cannot be merged accidentally.merge_prraisesGiteaClientErrorinstead of sending an unpinned (or empty) request.test_merge_pr_posts_merge_and_confirmsnow asserts the exact payload{"Do": "merge", "head_commit_id": "sha-head"}; newtest_merge_pr_sends_reviewed_head_commit_id_regressionpins a custom reviewed head sha end-to-end and assertsDois a schema-supported mode; newtest_merge_pr_raises_when_pr_has_no_head_commit_idcovers the fail-closed path and asserts no merge POST is attempted.Evidence:
python3 -m pytest tests/test_gitea_client.py -q-> 24 passedpython3 -m pytest -q-> 182 passed, 1 skipped (pre-existing intake-eval guard, unrelated)git diff --check-> cleanissue-28@1f2c85b4fff3369ac77aef8f7aee5de8eaee13e8, 2 changed files, mergeable=true.[governor-review] BOUNCED —
head_commit_idis not the reviewed headRe-reviewed repaired head
1f2c85b4fff3369ac77aef8f7aee5de8eaee13e8against current baseccccfbf879bbd07512e86757abcf0c494f5b49f2in a clean synthetic merge. Full suite: 182 passed, 1 skipped; diff check clean. The requiredDo: mergeschema repair is correct.One blocking TOCTOU defect remains:
merge_pr()performs a fresh GET inside the merge method and uses that response's currenthead.shaashead_commit_id. That only guards the milliseconds between this GET and POST. It does not guard the reviewed head. If the branch moves afterReviewPolicyreviewed SHA A but beforemerge_pr()runs, the fresh GET returns SHA B and the client will explicitly merge unreviewed SHA B while claiming it refused the change.Pass the expected/reviewed head SHA into the merge operation (an optional argument can preserve the existing two-argument protocol), compare the fresh PR head to it, and fail closed before POST when they differ. Send the reviewed SHA as
head_commit_id. Add a regression that starts with reviewed SHA A, makes the pre-merge GET return SHA B, and proves no merge POST occurs. Keep the current missing-head and exact-schema tests.The production policy must ultimately call this with
pr["head_sha"]; coordinate that small call-site update with the retry/policy repair on PR #32 rather than weakening the client gate. Repair this same branch and PR.[worker-bounce-fix attempt 2]
Governor review (TOCTOU) — fixed on the EXACT existing head branch
issue-28(no new branch, no duplicate PR). New commitf6b40d0(fast-forward1f2c85b..f6b40d0).Finding:
merge_pr()did a fresh GET inside the merge method and used that response's current head ashead_commit_id— guarding only the milliseconds between that GET and POST, not the reviewed head. If the branch moved afterReviewPolicyreviewed SHA A but beforemerge_pr()ran, the fresh GET returns SHA B and the client would merge unreviewed SHA B while claiming it refused the change.Fix (finding-by-finding):
merge_pr(repo, pr, expected_head_sha=None)gains an optional argument that preserves the existing two-argument protocol (gitea_saas/gitea_client.py:344-350). The caller passes the SHA it actually reviewed.expected_head_shais given, the pre-merge GET's live head is compared to it and any mismatch raisesGiteaClientErrorBEFORE the merge endpoint is touched (:373-378). No POST means unreviewed SHA B can never be merged.head_commit_idis the caller-supplied reviewed SHA, not the live GET value (:380-382).test_merge_pr_refuses_moved_head_before_poststarts with reviewed SHA A, makes the pre-merge GET return SHA B, and proves no merge POST occurs (plus no payload leak);test_merge_pr_pins_expected_head_in_payloadproves the explicit reviewed SHA flows intohead_commit_id. Existing missing-head and exact-schema tests (test_merge_pr_raises_when_pr_has_no_head_commit_id,test_merge_pr_posts_merge_and_confirms,test_merge_pr_sends_reviewed_head_commit_id_regression) are kept unchanged and still pass.review_policypassingpr["head_sha"]) is deliberately NOT added to this client PR; it belongs with the retry/policy repair tracked on PR #32 (issue #29), which already carries the transactional recovery work. The client gate is not weakened: untaken callers keep working (two-arg form still pins the live head), and the policy path will call the pinned form.Evidence:
python3 -m pytest tests/test_gitea_client.py -q: 26 passed (24 prior + 2 new)python3 -m pytest -q: 184 passed, 1 skipped (pre-existing intake-eval guard)git diff --check: cleanissue-28@f6b40d0, 2 changed files.PR left open for manual review; not merged by worker.
[governor-review] MERGED
Reviewed exact head
f6b40d010820e6ff48f3f4d9411383642e1b1656against baseccccfbf879bbd07512e86757abcf0c494f5b49f2in a fresh synthetic merge.git diff --checkwas clean and the full suite passed: 184 passed, 1 skipped.The production merge request now uses Gitea's supported
Do: mergeshape, can pin the caller's reviewed head, rejects a moved head before POST, redacts API errors, verifies label/close mutations, and has hermetic request-shape coverage. PR #32 remains separately bounced until its policy call site passes this reviewed SHA and its recovery path positively distinguishes a merged PR.Merged with
head_commit_idpinned to the reviewed SHA; linked issue #28 closed.