Files
rose-ash/lib/gitea/tests/pr.sx
giles 24821e3f77 sx-gitea Phase 5: pr — merge-base diffs, reviews, flow lifecycle, 3-way merge, merge queue (TDD, 460/460)
lib/gitea/pr.sx: PRs as kv records sharing the per-repo number counter
with issues. Diffs are LIVE, computed from the merge base of the current
branch heads to the source head via sx-git (no spurious deletions when
the target moves on). Reviews: latest verdict per reviewer wins; authors
cannot review their own PR; approved? = some approve and no outstanding
request-changes.

Lifecycle is a lib/flow durable workflow (deterministic-replay suspend):
open -(approval)-> approved -(merge)-> merged; review! resumes the
approval suspend when the verdict set first approves, merge! resumes the
rest, close! cancels, reopen! starts a fresh flow. The flow env lives in
the forge handle; the record's :state stays the source of truth.

Merge via git/merge-commits over the merge base: up-to-date, fast-
forward (ref move only), true two-parent merge commit, or conflicts with
the conflicting paths. Every ref move is branch-cas! — concurrent pushes
surface as 'stale'. Merge queue: approved PRs merge in order,
failures stay queued.

Web: pulls list + PR page (body html, reviews, lifecycle, unified diff),
JSON API for create/review/merge (409 on conflicts/stale)/close (author
or write)/enqueue/queue-process.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:07:29 +00:00

866 lines
20 KiB
Plaintext

; lib/gitea/tests/pr.sx — Phase 5: PR records, live merge-base diffs,
; review threads (latest verdict per reviewer), the durable flow
; lifecycle, all four merge shapes (merge/ff/up-to-date/conflicts), the
; merge queue, and the PR web routes + JSON API.
(st-bootstrap-classes!)
(content/bootstrap!)
(content-bootstrap-markdown!)
(content-bootstrap-table!)
(define gitea-pr-pass 0)
(define gitea-pr-fail 0)
(define gitea-pr-fails (list))
(define
gitea-pr-test
(fn
(name actual expected)
(if
(= actual expected)
(set! gitea-pr-pass (+ gitea-pr-pass 1))
(begin
(set! gitea-pr-fail (+ gitea-pr-fail 1))
(set! gitea-pr-fails (append gitea-pr-fails (list {:name name :expected (inspect expected) :actual (inspect actual)})))))))
; ── setup: repo with diverged branches ───────────────────────────────
(define gp-db (persist/mem-backend))
(define gp-forge (gitea/forge gp-db))
(gitea/user-create! gp-forge "alice")
(gitea/user-create! gp-forge "bob")
(gitea/user-create! gp-forge "carol")
(gitea/user-create! gp-forge "eve")
(gitea/repo-create! gp-forge "alice" "proj" {})
(gitea/repo-create! gp-forge "alice" "sec" {:visibility "private"})
(gitea/token-create! gp-forge "alice" "tok-a")
(gitea/token-create! gp-forge "bob" "tok-b")
(gitea/token-create! gp-forge "carol" "tok-c")
(gitea/token-create! gp-forge "eve" "tok-e")
(define gp-g (gitea/repo-git gp-forge "alice" "proj"))
(git/add! gp-g "README.md" "base\n")
(git/add! gp-g "lib.txt" "lib\n")
(define gp-c1 (git/commit! gp-g {:message "c1" :time 1 :author "alice"}))
(git/branch! gp-g "feat")
(git/checkout! gp-g "feat")
(git/add! gp-g "feature.txt" "feature line\n")
(define gp-c2 (git/commit! gp-g {:message "c2 feature" :time 2 :author "bob"}))
(git/checkout! gp-g "main")
(git/add! gp-g "other.txt" "other\n")
(define gp-c3 (git/commit! gp-g {:message "c3 other" :time 3 :author "alice"}))
; ── create / validate ────────────────────────────────────────────────
(define
gp-pr1
(gitea/pr-create!
gp-forge
"alice"
"proj"
"bob"
"Add feature"
"feat"
"main"
"Adds a *feature*."
{:created-at 5}))
(gitea-pr-test "pr number" (get gp-pr1 :number) 1)
(gitea-pr-test "pr state" (get gp-pr1 :state) "open")
(gitea-pr-test "pr source" (get gp-pr1 :source) "feat")
(gitea-pr-test "pr target" (get gp-pr1 :target) "main")
(gitea-pr-test "pr has flow id" (nil? (get gp-pr1 :flow-id)) false)
(gitea-pr-test
"flow starts at review"
(gitea/pr-flow-status gp-forge gp-pr1)
"review")
(gitea-pr-test
"unknown source"
(get
(gitea/pr-create!
gp-forge
"alice"
"proj"
"bob"
"t"
"nope"
"main"
""
{})
:error)
"no-such-source")
(gitea-pr-test
"unknown target"
(get
(gitea/pr-create!
gp-forge
"alice"
"proj"
"bob"
"t"
"feat"
"nope"
""
{})
:error)
"no-such-target")
(gitea-pr-test
"same branch"
(get
(gitea/pr-create!
gp-forge
"alice"
"proj"
"bob"
"t"
"main"
"main"
""
{})
:error)
"same-branch")
(gitea-pr-test
"empty title"
(get
(gitea/pr-create!
gp-forge
"alice"
"proj"
"bob"
""
"feat"
"main"
""
{})
:error)
"empty-title")
(gitea-pr-test
"missing repo"
(get
(gitea/pr-create!
gp-forge
"alice"
"none"
"bob"
"t"
"feat"
"main"
""
{})
:error)
"no-such-repo")
(gitea-pr-test
"missing author"
(get
(gitea/pr-create!
gp-forge
"alice"
"proj"
"zeb"
"t"
"feat"
"main"
""
{})
:error)
"no-such-user")
(gitea-pr-test
"prs list"
(gitea/prs gp-forge "alice" "proj")
(list 1))
; numbers are shared with issues
(gitea-pr-test
"shared counter with issues"
(get
(gitea/issue-create!
gp-forge
"alice"
"proj"
"alice"
"An issue"
""
{})
:number)
2)
; ── live diff against the merge base ─────────────────────────────────
(gitea-pr-test
"diff added"
(get (gitea/pr-diff gp-forge "alice" "proj" 1) :added)
(list "feature.txt"))
(gitea-pr-test
"diff no spurious deletions"
(get (gitea/pr-diff gp-forge "alice" "proj" 1) :deleted)
(list))
(gitea-pr-test
"diff unified shows addition"
(contains?
(gitea/pr-diff-unified gp-forge "alice" "proj" 1)
"+feature line")
true)
(gitea-pr-test
"diff of missing pr"
(gitea/pr-diff gp-forge "alice" "proj" 99)
nil)
; ── reviews ──────────────────────────────────────────────────────────
(gitea/pr-review!
gp-forge
"alice"
"proj"
1
"carol"
"request-changes"
"needs tests"
{:at 6})
(gitea-pr-test
"changes requested blocks approval"
(gitea/pr-approved? (gitea/pr-get gp-forge "alice" "proj" 1))
false)
(gitea-pr-test
"flow still at review"
(gitea/pr-flow-status
gp-forge
(gitea/pr-get gp-forge "alice" "proj" 1))
"review")
(gitea-pr-test
"author cannot review own pr"
(get
(gitea/pr-review!
gp-forge
"alice"
"proj"
1
"bob"
"approve"
""
{})
:error)
"own-pr")
(gitea-pr-test
"invalid verdict"
(get
(gitea/pr-review!
gp-forge
"alice"
"proj"
1
"carol"
"meh"
""
{})
:error)
"invalid-verdict")
(gitea-pr-test
"unknown reviewer"
(get
(gitea/pr-review!
gp-forge
"alice"
"proj"
1
"zeb"
"approve"
""
{})
:error)
"no-such-user")
(gitea-pr-test
"review missing pr"
(get
(gitea/pr-review!
gp-forge
"alice"
"proj"
99
"carol"
"approve"
""
{})
:error)
"no-such-pr")
(gitea/pr-review!
gp-forge
"alice"
"proj"
1
"carol"
"approve"
"looks good now"
{:at 7})
(gitea-pr-test
"latest verdict wins"
(gitea/pr-approved? (gitea/pr-get gp-forge "alice" "proj" 1))
true)
(gitea-pr-test
"reviews accumulate"
(len (get (gitea/pr-get gp-forge "alice" "proj" 1) :reviews))
2)
(gitea-pr-test
"flow advanced to approved"
(gitea/pr-flow-status
gp-forge
(gitea/pr-get gp-forge "alice" "proj" 1))
"approved")
; ── merge: true 3-way ────────────────────────────────────────────────
(gitea-pr-test
"unapproved merge rejected"
(get
(gitea/pr-merge! gp-forge "alice" "proj" 2 "alice" {})
:error)
"no-such-pr")
(define
gp-m1
(gitea/pr-merge! gp-forge "alice" "proj" 1 "alice" {:time 8}))
(gitea-pr-test "merge state" (get gp-m1 :state) "merged")
(gitea-pr-test "merge cid recorded" (nil? (get gp-m1 :merge-cid)) false)
(gitea-pr-test
"main moved to merge commit"
(git/branch-get gp-g "main")
(get gp-m1 :merge-cid))
(gitea-pr-test
"merge commit has two parents"
(git/commit-parents (git/read gp-g (get gp-m1 :merge-cid)))
(list gp-c3 gp-c2))
(gitea-pr-test
"merged tree keeps target file"
(get (gitea/tree-at gp-g (get gp-m1 :merge-cid) "other.txt") :kind)
"blob")
(gitea-pr-test
"merged tree gains source file"
(get (gitea/tree-at gp-g (get gp-m1 :merge-cid) "feature.txt") :kind)
"blob")
(gitea-pr-test
"flow reports merged"
(gitea/pr-flow-status
gp-forge
(gitea/pr-get gp-forge "alice" "proj" 1))
"merged")
(gitea-pr-test
"merge twice rejected"
(get
(gitea/pr-merge! gp-forge "alice" "proj" 1 "alice" {})
:error)
"not-open")
(gitea-pr-test
"review after merge rejected"
(get
(gitea/pr-review!
gp-forge
"alice"
"proj"
1
"carol"
"approve"
""
{})
:error)
"not-open")
; ── merge: fast-forward ──────────────────────────────────────────────
(git/checkout! gp-g "main")
(git/branch! gp-g "hot")
(git/checkout! gp-g "hot")
(git/add! gp-g "hotfix.txt" "fix\n")
(define gp-c4 (git/commit! gp-g {:message "hotfix" :time 9 :author "bob"}))
(git/checkout! gp-g "main")
(define
gp-pr3
(gitea/pr-create!
gp-forge
"alice"
"proj"
"bob"
"Hotfix"
"hot"
"main"
""
{}))
(gitea-pr-test "pr3 number" (get gp-pr3 :number) 3)
(gitea/pr-review!
gp-forge
"alice"
"proj"
3
"carol"
"approve"
""
{})
(define
gp-m3
(gitea/pr-merge! gp-forge "alice" "proj" 3 "alice" {}))
(gitea-pr-test "ff merge state" (get gp-m3 :state) "merged")
(gitea-pr-test
"ff moves main to source head"
(git/branch-get gp-g "main")
gp-c4)
(gitea-pr-test "ff merge-cid is source head" (get gp-m3 :merge-cid) gp-c4)
; ── merge: up-to-date ────────────────────────────────────────────────
(git/checkout! gp-g "main")
(git/branch! gp-g "same")
(define
gp-pr4
(gitea/pr-create!
gp-forge
"alice"
"proj"
"bob"
"No-op"
"same"
"main"
""
{}))
(gitea/pr-review!
gp-forge
"alice"
"proj"
4
"carol"
"approve"
""
{})
(define
gp-m4
(gitea/pr-merge! gp-forge "alice" "proj" 4 "alice" {}))
(gitea-pr-test "up-to-date merge state" (get gp-m4 :state) "merged")
(gitea-pr-test "up-to-date leaves main" (git/branch-get gp-g "main") gp-c4)
; ── merge: conflicts ─────────────────────────────────────────────────
(git/checkout! gp-g "main")
(git/branch! gp-g "conf")
(git/checkout! gp-g "conf")
(git/add! gp-g "lib.txt" "conf version\n")
(git/commit! gp-g {:message "conf change" :time 10 :author "bob"})
(git/checkout! gp-g "main")
(git/add! gp-g "lib.txt" "main version\n")
(git/commit! gp-g {:message "main change" :time 11 :author "alice"})
(define
gp-pr5
(gitea/pr-create!
gp-forge
"alice"
"proj"
"bob"
"Conflicting"
"conf"
"main"
""
{}))
(gitea/pr-review!
gp-forge
"alice"
"proj"
5
"carol"
"approve"
""
{})
(define
gp-m5
(gitea/pr-merge! gp-forge "alice" "proj" 5 "alice" {}))
(gitea-pr-test "conflict merge errors" (get gp-m5 :error) "conflicts")
(gitea-pr-test "conflict paths" (get gp-m5 :conflicts) (list "lib.txt"))
(gitea-pr-test
"conflict leaves pr open"
(get (gitea/pr-get gp-forge "alice" "proj" 5) :state)
"open")
(gitea-pr-test
"conflict leaves flow at approved"
(gitea/pr-flow-status
gp-forge
(gitea/pr-get gp-forge "alice" "proj" 5))
"approved")
; ── close / reopen ───────────────────────────────────────────────────
(gitea/pr-close! gp-forge "alice" "proj" 5)
(gitea-pr-test
"close state"
(get (gitea/pr-get gp-forge "alice" "proj" 5) :state)
"closed")
(gitea-pr-test
"close cancels flow"
(gitea/pr-flow-status
gp-forge
(gitea/pr-get gp-forge "alice" "proj" 5))
"closed")
(gitea-pr-test
"merge closed pr rejected"
(get
(gitea/pr-merge! gp-forge "alice" "proj" 5 "alice" {})
:error)
"not-open")
(gitea-pr-test
"close twice"
(gitea/pr-close! gp-forge "alice" "proj" 5)
nil)
(gitea/pr-reopen! gp-forge "alice" "proj" 5)
(gitea-pr-test
"reopen state"
(get (gitea/pr-get gp-forge "alice" "proj" 5) :state)
"open")
(gitea-pr-test
"reopen restarts lifecycle"
(gitea/pr-flow-status
gp-forge
(gitea/pr-get gp-forge "alice" "proj" 5))
"review")
; ── merge queue ──────────────────────────────────────────────────────
(git/checkout! gp-g "main")
(git/branch! gp-g "q1")
(git/checkout! gp-g "q1")
(git/add! gp-g "q1.txt" "one\n")
(git/commit! gp-g {:message "q1" :time 12 :author "bob"})
(git/checkout! gp-g "main")
(git/branch! gp-g "q2")
(git/checkout! gp-g "q2")
(git/add! gp-g "q2.txt" "two\n")
(git/commit! gp-g {:message "q2" :time 13 :author "bob"})
(git/checkout! gp-g "main")
(git/branch! gp-g "q3")
(git/checkout! gp-g "q3")
(git/add! gp-g "q3.txt" "three\n")
(git/commit! gp-g {:message "q3" :time 14 :author "bob"})
(git/checkout! gp-g "main")
(define
gp-pr6
(gitea/pr-create!
gp-forge
"alice"
"proj"
"bob"
"Queue one"
"q1"
"main"
""
{}))
(define
gp-pr7
(gitea/pr-create!
gp-forge
"alice"
"proj"
"bob"
"Queue two"
"q2"
"main"
""
{}))
(define
gp-pr8
(gitea/pr-create!
gp-forge
"alice"
"proj"
"bob"
"Queue three"
"q3"
"main"
""
{}))
(gitea/pr-review!
gp-forge
"alice"
"proj"
6
"carol"
"approve"
""
{})
(gitea/pr-review!
gp-forge
"alice"
"proj"
7
"carol"
"approve"
""
{})
; pr8 stays unapproved; pr5 (reopened, approved, conflicting) joins the queue
(gitea-pr-test
"queue starts empty"
(gitea/queue gp-forge "alice" "proj")
(list))
(gitea-pr-test
"queue rejects unapproved"
(get (gitea/queue-add! gp-forge "alice" "proj" 8) :error)
"not-approved")
(gitea-pr-test
"queue rejects missing"
(get (gitea/queue-add! gp-forge "alice" "proj" 99) :error)
"no-such-pr")
(gitea/queue-add! gp-forge "alice" "proj" 6)
(gitea/queue-add! gp-forge "alice" "proj" 6)
(gitea/queue-add! gp-forge "alice" "proj" 7)
(gitea/queue-add! gp-forge "alice" "proj" 5)
(gitea-pr-test
"queue dedups"
(gitea/queue gp-forge "alice" "proj")
(list 6 7 5))
(define gp-qres (gitea/queue-process! gp-forge "alice" "proj" "alice"))
(gitea-pr-test "queue processed all" (len gp-qres) 3)
(gitea-pr-test
"queue pr6 merged"
(get (nth gp-qres 0) :merged)
true)
(gitea-pr-test
"queue pr7 merged"
(get (nth gp-qres 1) :merged)
true)
(gitea-pr-test
"queue pr5 conflicts"
(get (nth gp-qres 2) :error)
"conflicts")
(gitea-pr-test
"failures stay queued"
(gitea/queue gp-forge "alice" "proj")
(list 5))
(gitea-pr-test
"pr6 state merged"
(get (gitea/pr-get gp-forge "alice" "proj" 6) :state)
"merged")
(gitea-pr-test
"pr7 state merged"
(get (gitea/pr-get gp-forge "alice" "proj" 7) :state)
"merged")
(gitea-pr-test
"main has both queue files"
(get (gitea/tree-at gp-g (git/branch-get gp-g "main") "q2.txt") :kind)
"blob")
(gitea/queue-remove! gp-forge "alice" "proj" 5)
(gitea-pr-test "queue-remove!" (gitea/queue gp-forge "alice" "proj") (list))
; ── web routes ───────────────────────────────────────────────────────
(define gp-app (gitea/app gp-forge))
(define gp-hdr (fn (tok) (if (nil? tok) {} {:authorization (str "Bearer " tok)})))
(define
gp-get
(fn (target tok) (gp-app (dream-request "GET" target (gp-hdr tok) ""))))
(define
gp-post
(fn
(target tok body)
(gp-app (dream-request "POST" target (gp-hdr tok) body))))
(gitea-pr-test
"pulls page 200"
(dream-status (gp-get "/alice/proj/pulls" nil))
200)
(gitea-pr-test
"pulls page shows title"
(contains?
(dream-resp-body (gp-get "/alice/proj/pulls" nil))
"Add feature")
true)
(gitea-pr-test
"pulls page shows state"
(contains? (dream-resp-body (gp-get "/alice/proj/pulls" nil)) "[merged]")
true)
(gitea-pr-test
"pull page 200"
(dream-status (gp-get "/alice/proj/pulls/1" nil))
200)
(gitea-pr-test
"pull page shows branches"
(contains?
(dream-resp-body (gp-get "/alice/proj/pulls/1" nil))
"feat -&gt; main")
true)
(gitea-pr-test
"pull page renders body"
(contains? (dream-resp-body (gp-get "/alice/proj/pulls/1" nil)) "<p>")
true)
(gitea-pr-test
"pull page shows review verdict"
(contains?
(dream-resp-body (gp-get "/alice/proj/pulls/1" nil))
"carol: approve")
true)
(gitea-pr-test
"pull page shows lifecycle"
(contains? (dream-resp-body (gp-get "/alice/proj/pulls/1" nil)) "merged")
true)
(gitea-pr-test
"pull page bad number 404"
(dream-status (gp-get "/alice/proj/pulls/abc" nil))
404)
(gitea-pr-test
"pull page missing 404"
(dream-status (gp-get "/alice/proj/pulls/99" nil))
404)
(gitea-pr-test
"private pulls anon 404"
(dream-status (gp-get "/alice/sec/pulls" nil))
404)
(gitea-pr-test
"api pulls len"
(len
(dream-json-parse
(dream-resp-body (gp-get "/api/repos/alice/proj/pulls" nil))))
7)
(gitea-pr-test
"api pulls first source"
(get
(first
(dream-json-parse
(dream-resp-body (gp-get "/api/repos/alice/proj/pulls" nil))))
:source)
"feat")
(gitea-pr-test
"api create anon 401"
(dream-status
(gp-post "/api/repos/alice/proj/pulls" nil (dream-json-encode {:source "q3" :title "t" :target "main"})))
401)
(gitea-pr-test
"api create 201"
(dream-status
(gp-post
"/api/repos/alice/proj/pulls"
"tok-e"
(dream-json-encode {:source "q3" :title "Eve PR" :body "please" :target "main"})))
201)
(gitea-pr-test
"api create bad source 400"
(dream-status
(gp-post
"/api/repos/alice/proj/pulls"
"tok-e"
(dream-json-encode {:source "zz" :title "t" :target "main"})))
400)
; eve's PR is #9
(gitea-pr-test
"api review 200"
(dream-status
(gp-post
"/api/repos/alice/proj/pulls/9/reviews"
"tok-c"
(dream-json-encode {:verdict "approve" :body "ok"})))
200)
(gitea-pr-test
"api self-review 400"
(dream-status
(gp-post
"/api/repos/alice/proj/pulls/9/reviews"
"tok-e"
(dream-json-encode {:verdict "approve"})))
400)
(gitea-pr-test
"api review anon 401"
(dream-status
(gp-post
"/api/repos/alice/proj/pulls/9/reviews"
nil
(dream-json-encode {:verdict "approve"})))
401)
(gitea-pr-test
"api review missing pr 404"
(dream-status
(gp-post
"/api/repos/alice/proj/pulls/99/reviews"
"tok-c"
(dream-json-encode {:verdict "approve"})))
404)
(gitea-pr-test
"api merge reader 403"
(dream-status (gp-post "/api/repos/alice/proj/pulls/9/merge" "tok-e" "{}"))
403)
(gitea-pr-test
"api merge anon 401"
(dream-status (gp-post "/api/repos/alice/proj/pulls/9/merge" nil "{}"))
401)
(gitea-pr-test
"api merge write 200"
(dream-status (gp-post "/api/repos/alice/proj/pulls/9/merge" "tok-a" "{}"))
200)
(gitea-pr-test
"api merge applied"
(get (gitea/pr-get gp-forge "alice" "proj" 9) :state)
"merged")
; reopened conflicting pr 5 still conflicts over the api
(gitea-pr-test
"api merge conflict 409"
(dream-status (gp-post "/api/repos/alice/proj/pulls/5/merge" "tok-a" "{}"))
409)
; eve authors #10 and may close it herself; carol (reader) may not
(gp-post "/api/repos/alice/proj/pulls" "tok-e" (dream-json-encode {:source "conf" :title "To close" :target "main"}))
(gitea-pr-test
"api close by reader 403"
(dream-status (gp-post "/api/repos/alice/proj/pulls/10/close" "tok-c" "{}"))
403)
(gitea-pr-test
"api close by author 200"
(dream-status (gp-post "/api/repos/alice/proj/pulls/10/close" "tok-e" "{}"))
200)
(gitea-pr-test
"api close applied"
(get (gitea/pr-get gp-forge "alice" "proj" 10) :state)
"closed")
(gitea-pr-test
"api close again 409"
(dream-status (gp-post "/api/repos/alice/proj/pulls/10/close" "tok-e" "{}"))
409)
; queue over the api: pr5 is approved (reviews survive reopen)
(gitea-pr-test
"api enqueue reader 403"
(dream-status
(gp-post "/api/repos/alice/proj/pulls/5/enqueue" "tok-e" "{}"))
403)
(gitea-pr-test
"api enqueue 200"
(dream-status
(gp-post "/api/repos/alice/proj/pulls/5/enqueue" "tok-a" "{}"))
200)
(gitea-pr-test
"api queue json"
(dream-json-parse
(dream-resp-body (gp-get "/api/repos/alice/proj/merge-queue" nil)))
(list 5))
(gitea-pr-test
"api queue process 200"
(dream-status
(gp-post "/api/repos/alice/proj/merge-queue/process" "tok-a" "{}"))
200)
(gitea-pr-test
"api queue process reports conflict"
(get
(first
(dream-json-parse
(dream-resp-body
(gp-post "/api/repos/alice/proj/merge-queue/process" "tok-a" "{}"))))
:error)
"conflicts")