lib/gitea/issues.sx: issues as kv records (zero-padded per-repo numbering, title/author/state, sorted label+assignee sets, Markdown body, comment thread). Bodies and comments are content-on-sx documents: content/from-markdown -> block doc -> content/html for pages, with the round-trip law asserted in the suite. The issue graph (issue->repo parent, author origin, assignee member, label link, commenter reply) is DERIVED into lib/relations facts and rebuilt on fact change — same pattern as the acl db, so deleting a repo can never dangle edges. Views: open/closed/by-label/by-assignee; graph queries: repo-issue-nodes, user-authored, user-assigned, label-issues, issue-participants. Web: issues list + issue page (rendered HTML body + comments), JSON API: create (any authenticated reader), comment, close/reopen (author or write), label/assignee management (write). All read-gated like the rest. Infra: gitea/route-packs registry — wire/issues append their routes at load; gitea/app serves all packs. repo-delete! now purges collab/issue/ issue-seq rows too (ghost-state regression tested). Conformance runner gains per-suite extra modules; the issues suite loads relations + smalltalk + content (~5s). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
572 lines
16 KiB
Plaintext
572 lines
16 KiB
Plaintext
; lib/gitea/tests/issues.sx — Phase 4: issue CRUD, comments, labels,
|
|
; assignees, content-document bodies (Markdown round-trip + HTML render),
|
|
; the derived relations graph, repo-delete purge regression, and the
|
|
; issue web routes + JSON API.
|
|
|
|
(st-bootstrap-classes!)
|
|
(content/bootstrap!)
|
|
(content-bootstrap-markdown!)
|
|
(content-bootstrap-table!)
|
|
|
|
(define gitea-issues-pass 0)
|
|
(define gitea-issues-fail 0)
|
|
(define gitea-issues-fails (list))
|
|
|
|
(define
|
|
gitea-issues-test
|
|
(fn
|
|
(name actual expected)
|
|
(if
|
|
(= actual expected)
|
|
(set! gitea-issues-pass (+ gitea-issues-pass 1))
|
|
(begin
|
|
(set! gitea-issues-fail (+ gitea-issues-fail 1))
|
|
(set!
|
|
gitea-issues-fails
|
|
(append gitea-issues-fails (list {:name name :expected (inspect expected) :actual (inspect actual)})))))))
|
|
|
|
; ── helpers ──────────────────────────────────────────────────────────
|
|
|
|
(gitea-issues-test "pad8" (gitea/pad8 7) "00000007")
|
|
(gitea-issues-test "pad8 wide" (gitea/pad8 12345) "00012345")
|
|
(gitea-issues-test "digits? yes" (gitea/digits? "123") true)
|
|
(gitea-issues-test "digits? no" (gitea/digits? "12a") false)
|
|
(gitea-issues-test "digits? empty" (gitea/digits? "") false)
|
|
|
|
; ── setup ────────────────────────────────────────────────────────────
|
|
|
|
(define gi-db (persist/mem-backend))
|
|
(define gi-forge (gitea/forge gi-db))
|
|
(gitea/user-create! gi-forge "alice")
|
|
(gitea/user-create! gi-forge "bob")
|
|
(gitea/user-create! gi-forge "carol")
|
|
(gitea/user-create! gi-forge "eve")
|
|
(gitea/repo-create! gi-forge "alice" "proj" {})
|
|
(gitea/repo-create! gi-forge "alice" "sec" {:visibility "private"})
|
|
(gitea/collab-add! gi-forge "alice" "sec" "bob" "read")
|
|
(gitea/token-create! gi-forge "alice" "tok-a")
|
|
(gitea/token-create! gi-forge "bob" "tok-b")
|
|
(gitea/token-create! gi-forge "eve" "tok-e")
|
|
|
|
; ── issue CRUD ───────────────────────────────────────────────────────
|
|
|
|
(define
|
|
gi-i1
|
|
(gitea/issue-create!
|
|
gi-forge
|
|
"alice"
|
|
"proj"
|
|
"alice"
|
|
"Crash on boot"
|
|
"It crashes."
|
|
{:created-at 10}))
|
|
|
|
(gitea-issues-test "create number" (get gi-i1 :number) 1)
|
|
(gitea-issues-test "create state" (get gi-i1 :state) "open")
|
|
(gitea-issues-test "create title" (get gi-i1 :title) "Crash on boot")
|
|
(gitea-issues-test "create author" (get gi-i1 :author) "alice")
|
|
(gitea-issues-test "create created-at" (get gi-i1 :created-at) 10)
|
|
|
|
(define
|
|
gi-i2
|
|
(gitea/issue-create!
|
|
gi-forge
|
|
"alice"
|
|
"proj"
|
|
"bob"
|
|
"Add docs"
|
|
"Docs please."
|
|
{}))
|
|
(gitea-issues-test "second number" (get gi-i2 :number) 2)
|
|
|
|
(gitea-issues-test
|
|
"issue-get"
|
|
(get (gitea/issue-get gi-forge "alice" "proj" 1) :title)
|
|
"Crash on boot")
|
|
(gitea-issues-test
|
|
"issues list"
|
|
(gitea/issues gi-forge "alice" "proj")
|
|
(list 1 2))
|
|
(gitea-issues-test
|
|
"issue-records len"
|
|
(len (gitea/issue-records gi-forge "alice" "proj"))
|
|
2)
|
|
|
|
(gitea-issues-test
|
|
"create on missing repo"
|
|
(get
|
|
(gitea/issue-create! gi-forge "alice" "none" "alice" "t" "" {})
|
|
:error)
|
|
"no-such-repo")
|
|
(gitea-issues-test
|
|
"create by missing user"
|
|
(get
|
|
(gitea/issue-create! gi-forge "alice" "proj" "zeb" "t" "" {})
|
|
:error)
|
|
"no-such-user")
|
|
(gitea-issues-test
|
|
"create empty title"
|
|
(get
|
|
(gitea/issue-create! gi-forge "alice" "proj" "alice" "" "" {})
|
|
:error)
|
|
"empty-title")
|
|
|
|
(gitea/issue-close! gi-forge "alice" "proj" 2)
|
|
(gitea-issues-test
|
|
"close!"
|
|
(get (gitea/issue-get gi-forge "alice" "proj" 2) :state)
|
|
"closed")
|
|
(gitea/issue-reopen! gi-forge "alice" "proj" 2)
|
|
(gitea-issues-test
|
|
"reopen!"
|
|
(get (gitea/issue-get gi-forge "alice" "proj" 2) :state)
|
|
"open")
|
|
(gitea-issues-test
|
|
"close missing"
|
|
(gitea/issue-close! gi-forge "alice" "proj" 99)
|
|
nil)
|
|
(gitea/issue-close! gi-forge "alice" "proj" 2)
|
|
|
|
; ── comments ─────────────────────────────────────────────────────────
|
|
|
|
(gitea-issues-test
|
|
"comment author"
|
|
(get
|
|
(gitea/issue-comment!
|
|
gi-forge
|
|
"alice"
|
|
"proj"
|
|
1
|
|
"bob"
|
|
"Repro *here*."
|
|
{:at 11})
|
|
:author)
|
|
"bob")
|
|
(gitea/issue-comment!
|
|
gi-forge
|
|
"alice"
|
|
"proj"
|
|
1
|
|
"carol"
|
|
"Same for me."
|
|
{:at 12})
|
|
|
|
(gitea-issues-test
|
|
"comments appended"
|
|
(len (get (gitea/issue-get gi-forge "alice" "proj" 1) :comments))
|
|
2)
|
|
(gitea-issues-test
|
|
"comment order"
|
|
(get
|
|
(first
|
|
(get (gitea/issue-get gi-forge "alice" "proj" 1) :comments))
|
|
:body)
|
|
"Repro *here*.")
|
|
(gitea-issues-test
|
|
"comment on missing issue"
|
|
(get
|
|
(gitea/issue-comment!
|
|
gi-forge
|
|
"alice"
|
|
"proj"
|
|
99
|
|
"bob"
|
|
"x"
|
|
{})
|
|
:error)
|
|
"no-such-issue")
|
|
(gitea-issues-test
|
|
"comment by missing user"
|
|
(get
|
|
(gitea/issue-comment!
|
|
gi-forge
|
|
"alice"
|
|
"proj"
|
|
1
|
|
"zeb"
|
|
"x"
|
|
{})
|
|
:error)
|
|
"no-such-user")
|
|
|
|
; ── labels / assignees ───────────────────────────────────────────────
|
|
|
|
(gitea/issue-label! gi-forge "alice" "proj" 1 "ui")
|
|
(gitea/issue-label! gi-forge "alice" "proj" 1 "bug")
|
|
(gitea-issues-test
|
|
"labels sorted"
|
|
(get (gitea/issue-get gi-forge "alice" "proj" 1) :labels)
|
|
(list "bug" "ui"))
|
|
(gitea/issue-label! gi-forge "alice" "proj" 1 "bug")
|
|
(gitea-issues-test
|
|
"label idempotent"
|
|
(get (gitea/issue-get gi-forge "alice" "proj" 1) :labels)
|
|
(list "bug" "ui"))
|
|
(gitea/issue-unlabel! gi-forge "alice" "proj" 1 "bug")
|
|
(gitea-issues-test
|
|
"unlabel"
|
|
(get (gitea/issue-get gi-forge "alice" "proj" 1) :labels)
|
|
(list "ui"))
|
|
(gitea-issues-test
|
|
"invalid label"
|
|
(get (gitea/issue-label! gi-forge "alice" "proj" 1 "") :error)
|
|
"invalid-label")
|
|
|
|
(gitea/issue-assign! gi-forge "alice" "proj" 2 "carol")
|
|
(gitea-issues-test
|
|
"assign"
|
|
(get (gitea/issue-get gi-forge "alice" "proj" 2) :assignees)
|
|
(list "carol"))
|
|
(gitea-issues-test
|
|
"assign unknown user"
|
|
(get (gitea/issue-assign! gi-forge "alice" "proj" 2 "zeb") :error)
|
|
"no-such-user")
|
|
|
|
; ── views ────────────────────────────────────────────────────────────
|
|
|
|
(gitea-issues-test
|
|
"issues-open"
|
|
(len (gitea/issues-open gi-forge "alice" "proj"))
|
|
1)
|
|
(gitea-issues-test
|
|
"issues-closed"
|
|
(len (gitea/issues-closed gi-forge "alice" "proj"))
|
|
1)
|
|
(gitea-issues-test
|
|
"issues-with-label"
|
|
(map
|
|
(fn (r) (get r :number))
|
|
(gitea/issues-with-label gi-forge "alice" "proj" "ui"))
|
|
(list 1))
|
|
(gitea-issues-test
|
|
"issues-assigned"
|
|
(map
|
|
(fn (r) (get r :number))
|
|
(gitea/issues-assigned gi-forge "alice" "proj" "carol"))
|
|
(list 2))
|
|
|
|
; ── content documents ────────────────────────────────────────────────
|
|
|
|
(define gi-md "# Heading\n\npara text.\n\n```sx\n(+ 1 2)\n```")
|
|
(define
|
|
gi-i3
|
|
(gitea/issue-create!
|
|
gi-forge
|
|
"alice"
|
|
"proj"
|
|
"alice"
|
|
"With md body"
|
|
gi-md
|
|
{}))
|
|
(define gi-doc (gitea/issue-doc "alice" "proj" gi-i3))
|
|
|
|
(gitea-issues-test "issue doc block count" (content/count gi-doc) 3)
|
|
(gitea-issues-test
|
|
"issue doc types"
|
|
(content/types gi-doc)
|
|
(list "heading" "text" "code"))
|
|
(gitea-issues-test
|
|
"issue html heading"
|
|
(contains? (gitea/issue-html "alice" "proj" gi-i3) "<h1>Heading</h1>")
|
|
true)
|
|
(gitea-issues-test
|
|
"issue html code block"
|
|
(contains? (gitea/issue-html "alice" "proj" gi-i3) "<pre><code")
|
|
true)
|
|
(gitea-issues-test "markdown round trip" (content/markdown gi-doc) gi-md)
|
|
(gitea-issues-test
|
|
"comment md renders"
|
|
(contains? (gitea/md-html "Repro *here*." "t1") "<p>")
|
|
true)
|
|
|
|
; ── relations graph ──────────────────────────────────────────────────
|
|
|
|
(gitea-issues-test
|
|
"repo issue nodes"
|
|
(gitea/repo-issue-nodes gi-forge "alice" "proj")
|
|
(list "issue:alice/proj#1" "issue:alice/proj#2" "issue:alice/proj#3"))
|
|
(gitea-issues-test
|
|
"authored by alice"
|
|
(gitea/user-authored gi-forge "alice")
|
|
(list "issue:alice/proj#1" "issue:alice/proj#3"))
|
|
(gitea-issues-test
|
|
"authored by bob"
|
|
(gitea/user-authored gi-forge "bob")
|
|
(list "issue:alice/proj#2"))
|
|
(gitea-issues-test
|
|
"assigned to carol"
|
|
(gitea/user-assigned gi-forge "carol")
|
|
(list "issue:alice/proj#2"))
|
|
(gitea-issues-test
|
|
"label issues"
|
|
(gitea/label-issues gi-forge "alice" "proj" "ui")
|
|
(list "issue:alice/proj#1"))
|
|
(gitea-issues-test
|
|
"participants incl commenters"
|
|
(gitea/issue-participants gi-forge "alice" "proj" 1)
|
|
(list "user:alice" "user:bob" "user:carol"))
|
|
(gitea-issues-test
|
|
"participants author+assignee"
|
|
(gitea/issue-participants gi-forge "alice" "proj" 2)
|
|
(list "user:bob" "user:carol"))
|
|
|
|
; ── repo delete purges issue state ───────────────────────────────────
|
|
|
|
(gitea/repo-create! gi-forge "alice" "tmp" {})
|
|
(gitea/issue-create! gi-forge "alice" "tmp" "alice" "Ghost?" "" {})
|
|
(gitea/collab-add! gi-forge "alice" "tmp" "carol" "write")
|
|
(gitea/repo-delete! gi-forge "alice" "tmp")
|
|
(gitea/repo-create! gi-forge "alice" "tmp" {})
|
|
|
|
(gitea-issues-test
|
|
"recreated repo has no ghost issues"
|
|
(gitea/issues gi-forge "alice" "tmp")
|
|
(list))
|
|
(gitea-issues-test
|
|
"recreated repo has no ghost collabs"
|
|
(gitea/collabs gi-forge "alice" "tmp")
|
|
(list))
|
|
(gitea-issues-test
|
|
"issue numbering restarts"
|
|
(get
|
|
(gitea/issue-create! gi-forge "alice" "tmp" "alice" "Fresh" "" {})
|
|
:number)
|
|
1)
|
|
(gitea/repo-delete! gi-forge "alice" "tmp")
|
|
(gitea-issues-test
|
|
"deleted repo leaves no issue edges"
|
|
(gitea/repo-issue-nodes gi-forge "alice" "tmp")
|
|
(list))
|
|
|
|
; ── web routes ───────────────────────────────────────────────────────
|
|
|
|
(define gi-app (gitea/app gi-forge))
|
|
(define gi-hdr (fn (tok) (if (nil? tok) {} {:authorization (str "Bearer " tok)})))
|
|
(define
|
|
gi-get
|
|
(fn (target tok) (gi-app (dream-request "GET" target (gi-hdr tok) ""))))
|
|
(define
|
|
gi-post
|
|
(fn
|
|
(target tok body)
|
|
(gi-app (dream-request "POST" target (gi-hdr tok) body))))
|
|
(define
|
|
gi-put
|
|
(fn
|
|
(target tok body)
|
|
(gi-app (dream-request "PUT" target (gi-hdr tok) body))))
|
|
(define
|
|
gi-del
|
|
(fn
|
|
(target tok)
|
|
(gi-app (dream-request "DELETE" target (gi-hdr tok) ""))))
|
|
|
|
(gitea-issues-test
|
|
"issues page 200"
|
|
(dream-status (gi-get "/alice/proj/issues" nil))
|
|
200)
|
|
(gitea-issues-test
|
|
"issues page lists title"
|
|
(contains?
|
|
(dream-resp-body (gi-get "/alice/proj/issues" nil))
|
|
"Crash on boot")
|
|
true)
|
|
(gitea-issues-test
|
|
"issues page shows state"
|
|
(contains? (dream-resp-body (gi-get "/alice/proj/issues" nil)) "[closed]")
|
|
true)
|
|
|
|
(gitea-issues-test
|
|
"issue page 200"
|
|
(dream-status (gi-get "/alice/proj/issues/1" nil))
|
|
200)
|
|
(gitea-issues-test
|
|
"issue page shows author"
|
|
(contains? (dream-resp-body (gi-get "/alice/proj/issues/1" nil)) "alice")
|
|
true)
|
|
(gitea-issues-test
|
|
"issue page renders body html"
|
|
(contains?
|
|
(dream-resp-body (gi-get "/alice/proj/issues/3" nil))
|
|
"<h1>Heading</h1>")
|
|
true)
|
|
(gitea-issues-test
|
|
"issue page renders comments"
|
|
(contains?
|
|
(dream-resp-body (gi-get "/alice/proj/issues/1" nil))
|
|
"Same for me.")
|
|
true)
|
|
(gitea-issues-test
|
|
"issue page bad number 404"
|
|
(dream-status (gi-get "/alice/proj/issues/abc" nil))
|
|
404)
|
|
(gitea-issues-test
|
|
"issue page missing 404"
|
|
(dream-status (gi-get "/alice/proj/issues/99" nil))
|
|
404)
|
|
(gitea-issues-test
|
|
"private issues anon 404"
|
|
(dream-status (gi-get "/alice/sec/issues" nil))
|
|
404)
|
|
(gitea-issues-test
|
|
"private issues collab 200"
|
|
(dream-status (gi-get "/alice/sec/issues" "tok-b"))
|
|
200)
|
|
|
|
(gitea-issues-test
|
|
"api issues len"
|
|
(len
|
|
(dream-json-parse
|
|
(dream-resp-body (gi-get "/api/repos/alice/proj/issues" nil))))
|
|
3)
|
|
(gitea-issues-test
|
|
"api issues first number"
|
|
(get
|
|
(first
|
|
(dream-json-parse
|
|
(dream-resp-body (gi-get "/api/repos/alice/proj/issues" nil))))
|
|
:number)
|
|
1)
|
|
|
|
(gitea-issues-test
|
|
"api create anon 401"
|
|
(dream-status
|
|
(gi-post
|
|
"/api/repos/alice/proj/issues"
|
|
nil
|
|
(dream-json-encode {:title "t"})))
|
|
401)
|
|
(gitea-issues-test
|
|
"api create reader 201"
|
|
(dream-status
|
|
(gi-post
|
|
"/api/repos/alice/proj/issues"
|
|
"tok-e"
|
|
(dream-json-encode {:title "From eve" :body "hi"})))
|
|
201)
|
|
(gitea-issues-test
|
|
"api created number"
|
|
(len (gitea/issues gi-forge "alice" "proj"))
|
|
4)
|
|
(gitea-issues-test
|
|
"api create on private hidden 404"
|
|
(dream-status
|
|
(gi-post
|
|
"/api/repos/alice/sec/issues"
|
|
"tok-e"
|
|
(dream-json-encode {:title "x"})))
|
|
404)
|
|
(gitea-issues-test
|
|
"api create empty title 400"
|
|
(dream-status
|
|
(gi-post
|
|
"/api/repos/alice/proj/issues"
|
|
"tok-e"
|
|
(dream-json-encode {:title ""})))
|
|
400)
|
|
|
|
(gitea-issues-test
|
|
"api comment 200"
|
|
(dream-status
|
|
(gi-post
|
|
"/api/repos/alice/proj/issues/4/comments"
|
|
"tok-b"
|
|
(dream-json-encode {:body "noted"})))
|
|
200)
|
|
(gitea-issues-test
|
|
"api comment recorded"
|
|
(len (get (gitea/issue-get gi-forge "alice" "proj" 4) :comments))
|
|
1)
|
|
(gitea-issues-test
|
|
"api comment anon 401"
|
|
(dream-status
|
|
(gi-post
|
|
"/api/repos/alice/proj/issues/4/comments"
|
|
nil
|
|
(dream-json-encode {:body "x"})))
|
|
401)
|
|
(gitea-issues-test
|
|
"api comment missing issue 404"
|
|
(dream-status
|
|
(gi-post
|
|
"/api/repos/alice/proj/issues/99/comments"
|
|
"tok-b"
|
|
(dream-json-encode {:body "x"})))
|
|
404)
|
|
|
|
; eve authored #4 and may close it without write; reopen as alice (write)
|
|
(gitea-issues-test
|
|
"api close by author 200"
|
|
(dream-status (gi-post "/api/repos/alice/proj/issues/4/close" "tok-e" "{}"))
|
|
200)
|
|
(gitea-issues-test
|
|
"api close applied"
|
|
(get (gitea/issue-get gi-forge "alice" "proj" 4) :state)
|
|
"closed")
|
|
(gitea-issues-test
|
|
"api reopen by write 200"
|
|
(dream-status
|
|
(gi-post "/api/repos/alice/proj/issues/4/reopen" "tok-a" "{}"))
|
|
200)
|
|
|
|
; issue #5: authored by alice — eve (reader, not author) may not close
|
|
(gitea/issue-create!
|
|
gi-forge
|
|
"alice"
|
|
"proj"
|
|
"alice"
|
|
"Owner issue"
|
|
""
|
|
{})
|
|
(gitea-issues-test
|
|
"api close by stranger 403"
|
|
(dream-status (gi-post "/api/repos/alice/proj/issues/5/close" "tok-e" "{}"))
|
|
403)
|
|
|
|
(gitea-issues-test
|
|
"api label put by write 200"
|
|
(dream-status
|
|
(gi-put "/api/repos/alice/proj/issues/5/labels/bug" "tok-a" "{}"))
|
|
200)
|
|
(gitea-issues-test
|
|
"api label applied"
|
|
(get (gitea/issue-get gi-forge "alice" "proj" 5) :labels)
|
|
(list "bug"))
|
|
(gitea-issues-test
|
|
"api label by reader 403"
|
|
(dream-status
|
|
(gi-put "/api/repos/alice/proj/issues/5/labels/x" "tok-e" "{}"))
|
|
403)
|
|
(gitea-issues-test
|
|
"api label delete 200"
|
|
(dream-status (gi-del "/api/repos/alice/proj/issues/5/labels/bug" "tok-a"))
|
|
200)
|
|
(gitea-issues-test
|
|
"api label removed"
|
|
(get (gitea/issue-get gi-forge "alice" "proj" 5) :labels)
|
|
(list))
|
|
|
|
(gitea-issues-test
|
|
"api assign 200"
|
|
(dream-status
|
|
(gi-put "/api/repos/alice/proj/issues/5/assignees/bob" "tok-a" "{}"))
|
|
200)
|
|
(gitea-issues-test
|
|
"api assign applied"
|
|
(get (gitea/issue-get gi-forge "alice" "proj" 5) :assignees)
|
|
(list "bob"))
|
|
(gitea-issues-test
|
|
"api assign unknown user 400"
|
|
(dream-status
|
|
(gi-put "/api/repos/alice/proj/issues/5/assignees/zeb" "tok-a" "{}"))
|
|
400)
|
|
(gitea-issues-test
|
|
"api unassign 200"
|
|
(dream-status
|
|
(gi-del "/api/repos/alice/proj/issues/5/assignees/bob" "tok-a"))
|
|
200)
|
|
(gitea-issues-test
|
|
"api unassign applied"
|
|
(get (gitea/issue-get gi-forge "alice" "proj" 5) :assignees)
|
|
(list))
|