; 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 -> main") true) (gitea-pr-test "pull page renders body" (contains? (dream-resp-body (gp-get "/alice/proj/pulls/1" nil)) "
") 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")