Files
rose-ash/lib/gitea/repo.sx
giles d96529effe sx-gitea Phase 4: issues — content-document bodies + relations graph (TDD, 360/360)
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>
2026-07-03 13:53:21 +00:00

271 lines
8.2 KiB
Plaintext

; lib/gitea/repo.sx — sx-gitea Phase 1: forge core.
;
; The forge is a handle over a persist backend. Owner principals and repo
; records live in the kv store under "gitea/..."; each repo's git objects
; and refs live in their own git/repo-named namespace "forge/<owner>/<name>",
; so deleting a repo is a prefix purge and repos are invisible to each other.
; Owner principals are a lightweight directory here; Phase 2 (access) backs
; them with identity users/orgs.
;
; Requires: lib/persist/{event,backend,log,kv}.sx, lib/artdag/dag.sx,
; lib/git/{object,ref,dag,worktree,diff,merge,porcelain}.sx
(define gitea/forge (fn (db) {:cache {} :db db}))
(define gitea/forge-db (fn (forge) (get forge :db)))
; ── names ────────────────────────────────────────────────────────────
; Owner and repo names share one rule: nonempty, no "/" or spaces, and not
; a word the router owns (an owner called "api" would shadow /api routes).
(define
gitea/reserved-names
(list "api" "tree" "blob" "raw" "commit" "commits" "branches"))
(define
gitea/valid-name?
(fn
(name)
(and
(string? name)
(> (string-length name) 0)
(not (contains? name "/"))
(not (contains? name " "))
(not (contains? gitea/reserved-names name)))))
; ── owners ───────────────────────────────────────────────────────────
(define gitea/owner-key (fn (name) (str "gitea/owner/" name)))
(define
gitea/owner-get
(fn
(forge name)
(persist/kv-get (gitea/forge-db forge) (gitea/owner-key name))))
(define
gitea/owner-exists?
(fn
(forge name)
(persist/kv-has? (gitea/forge-db forge) (gitea/owner-key name))))
(define
gitea/owner-create!
(fn
(forge kind name)
(if
(not (gitea/valid-name? name))
{:name name :error "invalid-name"}
(persist/kv-put-new
(gitea/forge-db forge)
(gitea/owner-key name)
{:name name :kind kind}))))
(define
gitea/user-create!
(fn (forge name) (gitea/owner-create! forge "user" name)))
(define
gitea/org-create!
(fn (forge name) (gitea/owner-create! forge "org" name)))
(define gitea/user? (fn (owner) (equal? (get owner :kind) "user")))
(define gitea/org? (fn (owner) (equal? (get owner :kind) "org")))
(define
gitea/names-under
(fn
(forge pfx)
(artdag/sort-strings
(map
(fn (k) (substr k (string-length pfx)))
(filter
(fn (k) (starts-with? k pfx))
(persist/kv-keys (gitea/forge-db forge)))))))
(define gitea/owners (fn (forge) (gitea/names-under forge "gitea/owner/")))
; ── repo records ─────────────────────────────────────────────────────
(define gitea/repo-key (fn (owner name) (str "gitea/repo/" owner "/" name)))
(define gitea/repo-ns (fn (owner name) (str "forge/" owner "/" name)))
(define gitea/repo-record (fn (owner name opts) {:name name :description (or (get opts :description) "") :default-branch (or (get opts :default-branch) "main") :owner owner :created-at (or (get opts :created-at) 0) :visibility (or (get opts :visibility) "public")}))
; create-only: {:error ...} on bad input, {:conflict ...} if it exists,
; else initialize the git store (HEAD -> unborn heads/main) and return
; the record.
(define
gitea/repo-create!
(fn
(forge owner name opts)
(cond
((not (gitea/owner-exists? forge owner)) {:error "no-such-owner" :owner owner})
((not (gitea/valid-name? name)) {:name name :error "invalid-name"})
(else
(let
((rec (gitea/repo-record owner name (or opts {}))))
(let
((res (persist/kv-put-new (gitea/forge-db forge) (gitea/repo-key owner name) rec)))
(if
(get res :conflict)
res
(begin
(git/init! (gitea/forge-db forge) (gitea/repo-ns owner name))
rec))))))))
(define
gitea/repo-get
(fn
(forge owner name)
(persist/kv-get (gitea/forge-db forge) (gitea/repo-key owner name))))
(define
gitea/repo-exists?
(fn
(forge owner name)
(persist/kv-has? (gitea/forge-db forge) (gitea/repo-key owner name))))
(define
gitea/repo-update!
(fn
(forge owner name f)
(let
((rec (gitea/repo-get forge owner name)))
(if
(nil? rec)
nil
(persist/kv-put
(gitea/forge-db forge)
(gitea/repo-key owner name)
(f rec))))))
; the sx-git handle for a repo's own object/ref namespace
(define
gitea/repo-git
(fn
(forge owner name)
(git/repo-named (gitea/forge-db forge) (gitea/repo-ns owner name))))
; everything owned by a repo record, beyond the record itself: its git
; namespace and any per-repo rows other phases hang off it
(define
gitea/repo-purge-prefixes
(fn
(owner name)
(list
(str (gitea/repo-ns owner name) "/")
(str "gitea/collab/" owner "/" name "/")
(str "gitea/issue/" owner "/" name "/"))))
(define
gitea/repo-purge-keys
(fn (owner name) (list (str "gitea/issue-seq/" owner "/" name))))
; delete the record and purge every key the repo owns — a recreated repo
; under the same name must start truly empty (no ghost collaborators,
; issues, or objects)
(define
gitea/repo-delete!
(fn
(forge owner name)
(if
(not (gitea/repo-exists? forge owner name))
false
(let
((db (gitea/forge-db forge))
(prefixes (gitea/repo-purge-prefixes owner name)))
(begin
(for-each
(fn
(k)
(if
(reduce
(fn (acc p) (or acc (starts-with? k p)))
false
prefixes)
(persist/kv-delete db k)
nil))
(persist/kv-keys db))
(for-each
(fn (k) (persist/kv-delete db k))
(gitea/repo-purge-keys owner name))
(persist/kv-delete db (gitea/repo-key owner name))
true)))))
(define gitea/repos (fn (forge) (gitea/names-under forge "gitea/repo/")))
(define
gitea/repos-for
(fn (forge owner) (gitea/names-under forge (str "gitea/repo/" owner "/"))))
; ── ref resolution / tree navigation (shared by browse views) ────────
; follow annotated tag objects down to the commit they name
(define
gitea/peel-to-commit
(fn
(grepo cid)
(let
((obj (git/read grepo cid)))
(cond
((nil? obj) nil)
((git/tag? obj) (gitea/peel-to-commit grepo (git/tag-target obj)))
(else cid)))))
; a browse ref is a branch name, a tag name, or a raw cid — in that order
(define
gitea/resolve-ref
(fn
(grepo refname)
(let
((b (git/branch-get grepo refname)))
(if
b
(gitea/peel-to-commit grepo b)
(let
((t (git/tag-get grepo refname)))
(if
t
(gitea/peel-to-commit grepo t)
(if
(git/has? grepo refname)
(gitea/peel-to-commit grepo refname)
nil)))))))
(define
gitea/path-segs
(fn (path) (filter (fn (s) (not (equal? s ""))) (split path "/"))))
; walk tree entries by path segments => {:kind "tree"|"blob" :cid cid} | nil
(define
gitea/entry-at
(fn
(grepo tree-cid segs)
(if
(empty? segs)
{:kind "tree" :cid tree-cid}
(let
((tree (git/read grepo tree-cid)))
(if
(not (git/tree? tree))
nil
(let
((entry (git/tree-entry-for tree (first segs))))
(cond
((nil? entry) nil)
((empty? (rest segs)) {:kind (git/entry-kind entry) :cid (git/entry-cid entry)})
((equal? (git/entry-kind entry) "tree")
(gitea/entry-at grepo (git/entry-cid entry) (rest segs)))
(else nil))))))))
; entry at path under a COMMIT's tree ("" => the root tree)
(define
gitea/tree-at
(fn
(grepo commit-cid path)
(let
((c (git/read grepo commit-cid)))
(if
(not (git/commit? c))
nil
(gitea/entry-at grepo (git/commit-tree c) (gitea/path-segs path))))))