Review policy + merge loop (tier auto-merge, bounce routing, delivery debit) (#6) #15
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#15
Loading…
Reference in New Issue
Block a user
No description provided.
Delete Branch "issue-6"
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 #6
Implements the review-gate POLICY + loop: drives the reviewer over open PRs,
applies per-client-tier merge policy, routes BOUNCE back to the lane
machinery, and triggers the delivery debit on merge.
Scope (issue #6 allowed files only):
gitea_saas/config.py— tier map (auto_merge,rate_minor) withSAAS_TIERS_JSONoverrides,REVIEW_ALL, provider/model/timeout/worktreesenvs.
gitea_saas/review_policy.py—ReviewPolicy(core, gitea, reviewer, config):poll_and_review(repo=None)— open PRs within-reviewlabel (or ALLwhen
REVIEW_ALL=1), reviewed once per head (reviewstable keyed by(repo, pr, head_sha), UNIQUE).merge_pr+debit_for_merge(idempotency key
repo#pr@head_sha) + close linked issue + stripin-review+ "Merged by review-gate" comment (fail-closed: a faileddebit never closes the issue); STANDARD + APPROVE -> comment only;
BOUNCE ->
bouncedlabel + verdict comment; NEEDS_GUIDANCE ->needs-guidancelabel + verdict comment.handle_merge_event(repo, pr)— webhook path: debit + receipt + close(idempotent replay shares the billing-core idempotency key).
gitea_saas/server.py—POST /api/review/poll,POST /api/webhooks/gitea(merge events),GET /healthz; all API routesrequire
X-API-Key(SAAS_API_KEY), 503 fail-closed when unset.tests/test_review_policy.py— hermetic (FakeGitea, FakeReviewer, realBillingCore on tmp DBs): 15 tests covering re-review safety, GOLD
auto-merge + single idempotent debit, webhook double-fire replay, bounce,
merge failure, debit fail-closed, STANDARD comment-only, NEEDS_GUIDANCE,
config-driven rates, and linked-issue parsing.
Verification:
python -m pytest tests/test_review_policy.py -q-> 15 passed(full suite: 57 passed);
git diff --checkclean.[lane2] Implementation complete — ready for manual review.
Commit:
7c0d3fb(branchissue-6, basemain) — 4 files, 873 insertions(all within the issue's allowed set:
gitea_saas/config.py,gitea_saas/review_policy.py,gitea_saas/server.py,tests/test_review_policy.py).Test evidence (worktree
/home/ideostation/worktrees/gitea-saas-core-issue-6):python -m pytest tests/test_review_policy.py -q-> 15 passedpython -m pytest -q(full repo gate) -> 57 passedgit diff --check-> cleanCoverage (issue #6 acceptance):
head_shamoves(
reviewstable keyed(repo, pr, head_sha), UNIQUE) — teststest_review_once_per_head_and_skip_same_head,test_head_move_triggers_rereview.in-reviewstripped, exactly ONE debit at the tier's config rate with idempotency
key
repo#pr@head_sha—test_gold_approve_merges_closes_strips_debits_once.—
test_gold_double_delivery_never_double_debits.bouncedlabel, PR stays open, no merge, no debit —test_gold_bounce_adds_label_no_merge_no_debit.test_standard_approve_comment_only_no_merge.needs-guidancelabel + verdict comment —test_needs_guidance_adds_label_and_comment.merge-without-debit and
SAAS_TIERS_JSONoverrides —test_debit_rate_comes_from_tier_config,test_free_tier_merges_without_debit_when_auto_merge,test_load_config_merges_saas_tiers_json.debit never closes the issue —
test_merge_failure_leaves_pr_open_and_no_close,test_debit_failure_is_fail_closed.handle_merge_eventwebhook path: receipt debit + close + comment —test_handle_merge_event_records_receipt_and_closes.Note: this branch stacks on #1 (billing core) and #5 (reviewer core) — the
PR diff includes those files (PRs #10/#12). Tier/client markers are parsed
from the linked issue body (
**Tier:** gold/**Client:** id) oroverridden via config.
Reviewed, verified, and held for the owner (auth + credentials)
Largest of the five: 8 files, +1673/-14, and it carries
server.py(tokenauth) and
gitea_client.py(GITEA_TOKEN), which is what puts it behind theowner gate rather than any defect I found.
server.py:171useshmac.compare_digest, which is correct.Verified composition of all five non-bounced PRs (measured, not inferred)
Every one of these reports
mergeable=true; that flag is computed againstmainin isolation and says nothing about whether they coexist. Merged forreal into a throwaway worktree off
origin/main(59c3d03), in this order:Resolving that one conflict by taking !17's client and re-running:
26 + 16 + 11 + 31 + 0 + 17 = 101 exactly, so nothing was lost in the resolution.
The conflict, and why taking !17's side is the right resolution
Three PRs each add
gitea_saas/gitea_client.pyas a new file. !12 and !15ship a byte-identical 69-line version (same sha256); !17 ships a divergent
162-line one. !17's is a true superset by method name --
-- and the only client calls in the dependent modules are
get_pr/get_pr_diff/post_comment(reviewer.py) andcreate_org_repo/create_issue(intake_factory.py), all present in !17's.Taking !17's version and running the combined suite gives the 101 above, so the
resolution is verified rather than assumed.
!15 subsumes !12
git merge-base --is-ancestor origin/issue-5 origin/issue-6-> true, and theirgitea_client.pyblobs hash identically. Merging !15 lands !12 in full, whichis why !12 contributes +0 tests when applied afterwards. !12 should be
closed as subsumed rather than merged separately, unless it is meant to land
on its own.
Not merging this one -- owner call
This sweep does not merge changes that touch auth or credentials, and this repo
is a billing service: every open PR here handles either a payment secret
(
STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET), a Gitea API token(
GITEA_TOKEN), or an API key that guards a ledger debit (SAAS_API_KEY).That is a deliberate hold on the merge decision, not a finding against this
PR -- the verification above is green and the landing order is worked out, so
this should be a short decision rather than a re-derivation.
Bounced: this branch collides with three other PRs on two files
Merging every open branch into one worktree off
main:This is the widest of the ten open PRs (8 files) and it is the only one that collides on all three shared files. Two separate things to fix.
1.
gitea_saas/gitea_client.py-- delete itFour PRs create this file. Ownership decision and full analysis on #21: !17's version is canonical.
diffbetween blobs).get_issue,get_pr,get_pr_diff,post_comment-- all present on !17's client.Delete it. Zero capability loss, measured.
2.
gitea_saas/server.py+gitea_saas/config.py-- coordinate with !13!13 (issue-2) creates both files as the HTTP API. Your branch creates its own versions. That is a genuine two-way design collision, not a redundant copy, so I am not calling a winner from the outside: !13 owns the app factory and the API-key discipline, and this PR should MOUNT its review endpoints onto that app rather than defining a second one.
!22 (issue-9) has the same problem with
server.py, so whatever shape !13 settles on, three PRs depend on it.Also:
mainmoved under you today!19 (#16) merged this morning and renamed
create_client's returned identifierclient_id->id. Check yourclient_idreads -- this branch has 17 references to that name. Parameters namedclient_idare unchanged; only the returned record's key moved.[worker-bounce-fix attempt 1]
All three bounce findings addressed on the exact head branch
issue-6(basemain), commit7a6fcdb:1.
gitea_saas/gitea_client.py— deletedgit rm gitea_saas/gitea_client.py(69 lines). Branch code calls exactlyget_issue/get_pr/get_pr_diff/post_comment, all present on !17's canonical client. The PR diff no longer contains the file.2.
server.py+config.py— coordinated with !13 (mounted, not duplicated)origin/issue-2merged into this branch; this PR'sgitea_saas/server.pyis now !13's app factory (create_app(core, api_key),X-API-Keyviahmac.compare_digest, fail-closed on unset key) plus a newmount_review_routes(app, policy)that registersPOST /api/review/pollandPOST /api/webhooks/giteaon the SAME app — the routes inherit !13'sbefore_requestAPI-key guard (smoke-tested: no-key 401, wrong-key 401, valid-key 200 poll/webhook, bad payload 400).config.pyis the unified module: !13's frozenServerConfig/API_KEY_HEADER/load_configkept intact; #6's tier policy + review knobs live in newload_review_config()/DEFAULT_TIERS. No second server, no second config loader. Landing order now implicit: !13 first, then this PR.3.
client_id->id(main moved under the branch)origin/mainmerged in;billing_core.pyis now identical to main's (#16 canonical,create_clientreturnsid). Audited every record-key read:tests/test_review_policy.py— 10 readsc["client_id"]->c["id"];review_policy.pyrefs are parameters/config-map keys (unchanged, correct). Server tolerates both keys via_client_outTest evidence
python -m pytest -qon the branch -> 79 passed (29 main/billing + 19 !13 server + 16 reviewer + 15 policy incl. the renamedtest_load_review_config_merges_saas_tiers_json);git diff --checkclean; zero conflict markers. Flask test-client smoke of the mounted routes: /health 200, poll/webhook auth semantics verified. Post-push verification: PR !15 state=open, merged=false, head=issue-6@7a6fcdb, mergeable=true.gitea_client.pyremains absent from this branch; the canonical copy still lands via !17 (issue #4) per the #21 ownership decision.Verified green against the merge result - and deliberately NOT merged: this PR is auth
Review policy + merge loop (#6)
origin/main@bcf03bd+34, the largest of the cluster (8 files, +2302/-1).
This is the one I would most want an explicit owner decision on, and not only because of the API key. It implements tier-based auto-merge: code that merges pull requests without a human. That is a capability question rather than a correctness question - it changes who is allowed to land code in a client's repository - and it is squarely the kind of thing this sweep is instructed to escalate rather than wave through on a green suite.
It also carries the repo's only file collision, with !13, on
config.py/server.py/requirements.txt/tests/test_server.py.Why it is not merged
This sweep escalates rather than acts when a PR touches authentication or credentials, and does not merge what it has escalated. That is not a comment on the quality of this PR - the gate is green, the merge is clean, and I found no defect in it.
gitea-saas-coreis a billing service: it holds an API key that authorizes debits, a Stripe secret, and a Gitea token that can create repositories. Six PRs currently open each add or modify a piece of that surface, and #24 already records what happens when they are reviewed one at a time - the service reached five independent implementations of one X-API-Key check before anyone counted them, and the fifth (!22, now bounced) was not constant-time.So the decision I am escalating is not "is this PR correct" but "in what order, and under what auth design, should this cluster land" - which is the owner's call, not a reviewer's.
What the owner needs to decide
One question: may this sweep merge the auth-bearing gitea-saas-core PRs on a green gate, or do they wait for a single reviewed auth path (#24) first?
If the answer is "merge them", the measured, collision-safe order is below and I can execute it next sweep. If the answer is "consolidate first", #24 needs its
blockedlabel cleared and re-pointed, because it is currently blocked on the very PRs that are waiting on it - a deadlock I have described on that issue.The file-collision facts, so the ordering decision is cheap
I merged all seven open PRs into throwaway trees rather than trusting seven green
mergeableflags. Measured overlap across the whole set:That is the only collision. !11, !17, !18, !20 and !22 are file-disjoint from each other and from everything else, so they can land in any order. Only !13 and !15 must be sequenced, and whichever goes second rebases onto the other's
server.pyauth block.Merged by the sweep after re-gating against the actual merge result (
pytest -q tests-> 112 passed in 8.73s). Auth path reviewed: this PR's X-API-Key check useshmac.compare_digest. Consolidation into a single shared helper is tracked separately.