diff --git a/lib/gitea/README.md b/lib/gitea/README.md new file mode 100644 index 00000000..ab29f827 --- /dev/null +++ b/lib/gitea/README.md @@ -0,0 +1,61 @@ +# sx-gitea — a federated git forge in plain SX + +A git forge built by **composing the x-on-sx subsystems**: every phase +wires one more substrate onto the forge. No third-party dependencies — +the whole stack is SX on the OCaml kernel. + +Run the suite: `bash lib/gitea/conformance.sh` (per-suite scores in +`scoreboard.md`). Suites are independent `sx_server` sessions; heavyweight +substrates (Smalltalk/content, Scheme/flow, APL/feed, Haskell/search) load +only for the suites that need them. + +## Composition map + +| Phase | Module | Built on | +|-------|--------|----------| +| 1 repo | `repo.sx` | **sx-git** (`lib/git`, native-CID object store), persist kv | +| 2 access | `access.sx` | **acl** (datalog): repo role groups, collaborators, org teams; bearer tokens | +| 3 wire | `wire.sx` | git-style smart HTTP: pkt-line framing, upload/receive-pack, CID-verified packs; client (`clone!`/`fetch!`/`push!`) drives any dream app fn | +| 4 issues | `issues.sx` | **content** (Smalltalk): Markdown bodies as block documents; **relations** (datalog): derived issue graph | +| 5 pr | `pr.sx` | **sx-git** merge-base diffs + 3-way merge; **flow** (Scheme): durable open→approval→merge lifecycle; merge queue | +| 6 activity | `activity.sx` | **feed** (APL): timelines/dashboard; **events** (flow): durable at-least-once notifications | +| 7 search | `search.sx` | **search** (Haskell): tf-idf ranked code/issue/PR search, batched evaluations | +| 8 fed | `fed.sx` | ForgeFed: AP actors, trust-gated inbox with provenance + materialized federated issues/PRs, mirrors over the wire client, cursor-based delivery | +| web | `web.sx` | **dream**: routes, auth gating (401/403/404-hides-private), route-pack registry | + +## Architectural rules of thumb + +- **The kv store is the source of truth.** Owners, repo records, issues, + PRs, collaborators, teams, tokens, follows, trust, mirrors — all plain + dicts under `gitea/...` keys on one persist backend per forge. + Deleting a repo is a prefix purge (no ghost state on recreate). +- **Derived, not maintained.** The acl database and the relations graph + are *derived* from kv state and rebuilt when the derived facts change + (cached in the forge handle) — deletions can never dangle. +- **Instrument in the runtime.** Activity logging wraps the mutation + verbs by redefinition (`gitea/base-*!` + wrapper), so every caller + emits activity with zero call-site edits. +- **Everything is testable without sockets.** A forge is a value over a + `persist/mem-backend`; `gitea/app` is a pure request→response fn; the + wire client federates two in-memory forges directly. +- **Trust is re-checked, never cached.** Federation operations + (inbox, mirror sync, delivery) consult the trust set at use time. + +## Per-repo git stores + +Each repo's objects/refs live in their own `git/repo-named` namespace +`forge//` — identical content still shares CIDs, but repos +cannot see each other's objects. All ref moves go through `ref-cas!`; +concurrent pushes surface as `stale`/`non-fast-forward` per-ref statuses. + +## Known limits (deliberate, documented) + +- Wire packs carry one object per pkt line (~64KB); side-band chunking + is a future extension (`gitea/pkt-fits?` reports it). SHA-1/packfile + byte compat for stock git clients lives in `lib/git/{export,import}.sx` + and is not yet wired into the HTTP endpoints. +- Inbox activities are trust-gated but not signature-verified. +- Reopening a PR restarts its lifecycle flow (a cancelled flow cannot + resume); reviews survive. +- Issue web close/reopen does not emit activity (no actor at the core + call sites for `issue-close!`). diff --git a/lib/gitea/fed.sx b/lib/gitea/fed.sx new file mode 100644 index 00000000..2c6d3e79 --- /dev/null +++ b/lib/gitea/fed.sx @@ -0,0 +1,449 @@ +; lib/gitea/fed.sx — sx-gitea Phase 8: ForgeFed federation. +; +; Forges federate as fed-sx-style peers. Each forge carries an instance +; id; users and repos project as AP actor documents (Person/Repository +; with inbox/outbox); the outbox is the forge's activity log in an +; AP-shaped envelope ({:actor "/user:" :verb :object +; :published}). +; +; Trust is the events-federation pattern: a kv set of peer ids, +; RE-CHECKED on every operation, so revoking a peer takes effect +; immediately. Peer transports (dream app fns) live in the forge handle's +; runtime cache — only the trust set persists. +; +; The inbox (POST /api/ap/inbox, {:peer :activity}) accepts activities +; from trusted peers only. Every accepted activity lands in a federated +; activity log with :origin provenance; open-issue/comment/open-pr +; activities also MATERIALIZE: the foreign author becomes a proxy user +; "@" (auto-created), and the issue/comment/PR is created +; locally under that identity — federated issues and PRs with honest +; provenance. fed-deliver! pushes public-repo activities (cursor-based, +; never private ones) to every trusted peer's inbox. +; +; Cross-instance repo follow = mirror!: clone a trusted peer's repo over +; the Phase 3 wire client, remember the source, and mirror-sync! to +; fast-forward — trust re-checked on every sync. +; +; Requires: lib/gitea/{repo,access,web,wire,issues,pr,activity}.sx and +; their stacks. + +; ── instance identity ──────────────────────────────────────────────── + +(define gitea/instance-key "gitea/instance") + +(define + gitea/instance! + (fn + (forge id) + (persist/kv-put (gitea/forge-db forge) gitea/instance-key {:id id}))) + +(define + gitea/instance-id + (fn + (forge) + (get + (or + (persist/kv-get (gitea/forge-db forge) gitea/instance-key) + {}) + :id))) + +(define + gitea/actor-id + (fn (forge node) (str (or (gitea/instance-id forge) "local") "/" node))) + +; ── peers + trust ──────────────────────────────────────────────────── + +; transports are live functions — runtime registry in the forge cache +(define + gitea/peers-registry + (fn + (forge) + (let + ((cache (get forge :cache))) + (begin + (if + (and cache (nil? (get cache "peers"))) + (dict-set! cache "peers" {}) + nil) + (or (get cache "peers") {}))))) + +(define + gitea/peer-register! + (fn + (forge id app token) + (let + ((cache (get forge :cache))) + (begin + (dict-set! + cache + "peers" + (assoc (gitea/peers-registry forge) id {:id id :token token :app app})) + id)))) + +(define + gitea/peer-get + (fn (forge id) (get (gitea/peers-registry forge) id))) + +(define gitea/trust-key (fn (id) (str "gitea/trust/" id))) + +(define + gitea/trust! + (fn + (forge id) + (persist/kv-put (gitea/forge-db forge) (gitea/trust-key id) {:id id}))) + +(define + gitea/untrust! + (fn + (forge id) + (persist/kv-delete (gitea/forge-db forge) (gitea/trust-key id)))) + +(define + gitea/trusted? + (fn + (forge id) + (persist/kv-has? (gitea/forge-db forge) (gitea/trust-key id)))) + +(define + gitea/trusted-peers + (fn + (forge) + (filter + (fn (id) (gitea/trusted? forge id)) + (artdag/sort-strings (keys (gitea/peers-registry forge)))))) + +; ── AP actor documents ─────────────────────────────────────────────── + +(define + gitea/ap-user + (fn + (forge user) + (let + ((rec (gitea/owner-get forge user))) + (if (nil? rec) nil {:id (gitea/actor-id forge (str "user:" user)) :type (if (gitea/org? rec) "Group" "Person") :outbox (str "/api/ap/users/" user "/outbox") :preferredUsername user :inbox "/api/ap/inbox"})))) + +(define + gitea/ap-repo + (fn + (forge owner name) + (let + ((rec (gitea/repo-get forge owner name))) + (if (nil? rec) nil {:clone (str "/" owner "/" name "/info/refs") :name (str owner "/" name) :id (gitea/actor-id forge (str "repo:" owner "/" name)) :attributedTo (gitea/actor-id forge (str "user:" owner)) :type "Repository" :summary (get rec :description) :inbox "/api/ap/inbox"})))) + +; AP-shaped envelope for a feed activity +(define gitea/ap-activity (fn (forge a) {:published (get a :at) :actor (gitea/actor-id forge (str "user:" (get a :actor))) :object (get a :object) :verb (get a :verb)})) + +(define + gitea/ap-outbox + (fn + (forge user n) + (map + (fn (a) (gitea/ap-activity forge a)) + (gitea/user-timeline forge nil user n)))) + +; ── federated activity log (inbound, with provenance) ──────────────── + +(define gitea/fed-stream-name "gitea/fed-activity") + +(define + gitea/fed-log + (fn + (forge) + (map + persist/event-data + (persist/read (gitea/forge-db forge) gitea/fed-stream-name)))) + +(define + gitea/fed-log-append! + (fn + (forge origin a) + (persist/append + (gitea/forge-db forge) + gitea/fed-stream-name + (or (get a :verb) "activity") + (or (get a :at) 0) + {:activity a :origin origin}))) + +; local + foreign activities, newest first, foreign tagged :origin +(define + gitea/fed-timeline + (fn + (forge user n) + (feed/items + (feed/take + (feed/recent + (feed/stream + (concat + (feed/items + (feed/filter + (gitea/activity-stream forge) + (fn (a) (gitea/act-visible? forge user a)))) + (map + (fn (e) (assoc (get e :activity) :origin (get e :origin))) + (gitea/fed-log forge))))) + n)))) + +; ── inbound materialization ────────────────────────────────────────── + +; foreign authors become local proxy users "@" +(define + gitea/fed-user! + (fn + (forge name peer) + (let + ((proxy (str name "@" peer))) + (begin + (if + (gitea/owner-exists? forge proxy) + nil + (gitea/user-create! forge proxy)) + proxy)))) + +; a foreign activity's :actor may be "/user:" or a bare +; name — reduce it to the name +(define + gitea/fed-actor-name + (fn + (actor) + (let + ((i (index-of (or actor "") "user:"))) + (if (< i 0) (or actor "") (substr actor (+ i 5)))))) + +; apply one trusted activity: log it, and materialize the verbs a forge +; can host locally. => {:accepted true ...} | {:error ...} +(define + gitea/fed-receive! + (fn + (forge peer a) + (if + (not (gitea/trusted? forge peer)) + {:error "untrusted-peer"} + (let + ((verb (get a :verb)) + (actor (gitea/fed-actor-name (get a :actor))) + (node (gitea/parse-numbered-node (or (get a :object) ""))) + (detail (or (get a :detail) {}))) + (begin + (gitea/fed-log-append! forge peer a) + (cond + ((= verb "open-issue") + (let + ((rp (gitea/split-full (substr (or (gitea/act-repo a) "repo:/") 5)))) + (let + ((res (gitea/issue-create! forge (get rp :owner) (get rp :name) (gitea/fed-user! forge actor peer) (or (get detail :title) "(federated issue)") (or (get detail :body) "") {:created-at (or (get a :at) 0)}))) + (if (get res :error) res {:materialized "issue" :accepted true :number (get res :number)})))) + ((= verb "comment") + (if + (nil? node) + {:error "bad-object"} + (let + ((res (gitea/issue-comment! forge (get node :owner) (get node :name) (get node :n) (gitea/fed-user! forge actor peer) (or (get detail :body) "") {:at (or (get a :at) 0)}))) + (if (get res :error) res {:materialized "comment" :accepted true})))) + ((= verb "open-pr") + (let + ((rp (gitea/split-full (substr (or (gitea/act-repo a) "repo:/") 5)))) + (let + ((res (gitea/pr-create! forge (get rp :owner) (get rp :name) (gitea/fed-user! forge actor peer) (or (get detail :title) "(federated pr)") (get detail :source) (get detail :target) (or (get detail :body) "") {:created-at (or (get a :at) 0)}))) + (if (get res :error) res {:materialized "pr" :accepted true :number (get res :number)})))) + (else {:materialized "none" :accepted true}))))))) + +; ── outbound delivery ──────────────────────────────────────────────── + +(define gitea/fed-cursor-key "gitea/fed-cursor") + +; push public-repo activities after the cursor to every trusted peer's +; inbox. => {:delivered n :peers (ids)} +(define + gitea/fed-deliver! + (fn + (forge) + (let + ((db (gitea/forge-db forge))) + (let + ((cursor (persist/kv-get-or db gitea/fed-cursor-key 0)) + (peers (gitea/trusted-peers forge)) + (me (or (gitea/instance-id forge) "local"))) + (let + ((events (persist/read-from db gitea/activity-stream-name (+ cursor 1)))) + (let + ((public (filter (fn (e) (gitea/act-visible? forge nil (persist/event-data e))) events))) + (begin + (for-each + (fn + (e) + (for-each + (fn + (pid) + (let + ((peer (gitea/peer-get forge pid))) + (if + (nil? peer) + nil + ((get peer :app) + (dream-request + "POST" + "/api/ap/inbox" + (if + (nil? (get peer :token)) + {} + {:authorization (str "Bearer " (get peer :token))}) + (dream-json-encode {:activity (persist/event-data e) :peer me})))))) + peers)) + public) + (if + (empty? events) + nil + (persist/kv-put + db + gitea/fed-cursor-key + (reduce (fn (acc e) (persist/event-seq e)) cursor events))) + {:delivered (len public) :peers peers}))))))) + +; ── cross-instance repo follow (mirrors) ───────────────────────────── + +(define + gitea/mirror-key + (fn (owner name) (str "gitea/mirror/" owner "/" name))) + +(define + gitea/peer-remote + (fn + (forge peer-id owner name) + (let + ((peer (gitea/peer-get forge peer-id))) + (if + (nil? peer) + nil + (gitea/remote (get peer :app) owner name (get peer :token)))))) + +; clone a trusted peer's repo as owner/name and remember the source +(define + gitea/mirror! + (fn + (forge owner name peer-id remote-owner remote-name) + (cond + ((not (gitea/trusted? forge peer-id)) {:error "untrusted-peer"}) + ((nil? (gitea/peer-get forge peer-id)) {:error "no-such-peer"}) + (else + (let + ((remote (gitea/peer-remote forge peer-id remote-owner remote-name))) + (let + ((res (gitea/clone! forge owner name remote {}))) + (if + (or (get res :error) (get res :conflict)) + res + (begin + (persist/kv-put + (gitea/forge-db forge) + (gitea/mirror-key owner name) + {:remote-owner remote-owner :peer peer-id :remote-name remote-name}) + res)))))))) + +(define + gitea/mirror-of + (fn + (forge owner name) + (persist/kv-get (gitea/forge-db forge) (gitea/mirror-key owner name)))) + +(define + gitea/mirrors + (fn (forge) (gitea/names-under forge "gitea/mirror/"))) + +; re-fetch from the mirror source; trust is re-checked every sync +(define + gitea/mirror-sync! + (fn + (forge owner name) + (let + ((m (gitea/mirror-of forge owner name))) + (cond + ((nil? m) {:error "not-a-mirror"}) + ((not (gitea/trusted? forge (get m :peer))) {:error "untrusted-peer"}) + ((nil? (gitea/peer-get forge (get m :peer))) {:error "no-such-peer"}) + (else + (gitea/fetch! + (gitea/peer-remote + forge + (get m :peer) + (get m :remote-owner) + (get m :remote-name)) + (gitea/repo-git forge owner name))))))) + +; ── web ────────────────────────────────────────────────────────────── + +(define + gitea/w-ap-user + (fn + (forge req) + (let + ((doc (gitea/ap-user forge (dream-param req "user")))) + (if (nil? doc) (dream-not-found) (dream-json-value doc))))) + +(define + gitea/w-ap-repo + (fn + (forge req) + (let + ((owner (dream-param req "owner")) (name (dream-param req "name"))) + (if + (not (gitea/w-readable? forge req owner name)) + (dream-not-found) + (dream-json-value (gitea/ap-repo forge owner name)))))) + +(define + gitea/w-ap-outbox + (fn + (forge req) + (let + ((user (dream-param req "user"))) + (if + (not (gitea/owner-exists? forge user)) + (dream-not-found) + (dream-json-value (gitea/ap-outbox forge user 50)))))) + +(define + gitea/w-ap-inbox + (fn + (forge req) + (let + ((body (dream-json-body req))) + (let + ((peer (get body :peer))) + (cond + ((nil? peer) (gitea/w-json-status 400 {:error "missing-peer"})) + ((not (gitea/trusted? forge peer)) (gitea/w-forbidden)) + (else + (let + ((res (gitea/fed-receive! forge peer (or (get body :activity) {})))) + (if + (get res :error) + (gitea/w-json-status 400 {:error (get res :error)}) + (dream-json-value res))))))))) + +(define + gitea/w-fed-timeline + (fn + (forge req) + (dream-json-value + (gitea/fed-timeline forge (gitea/w-user forge req) 50)))) + +(define + gitea/fed-routes + (fn + (forge) + (list + (dream-get + "/api/ap/users/:user" + (fn (req) (gitea/w-ap-user forge req))) + (dream-get + "/api/ap/users/:user/outbox" + (fn (req) (gitea/w-ap-outbox forge req))) + (dream-get + "/api/ap/repos/:owner/:name" + (fn (req) (gitea/w-ap-repo forge req))) + (dream-post "/api/ap/inbox" (fn (req) (gitea/w-ap-inbox forge req))) + (dream-get + "/api/fed/timeline" + (fn (req) (gitea/w-fed-timeline forge req)))))) + +(set! gitea/route-packs (append gitea/route-packs (list gitea/fed-routes))) diff --git a/lib/gitea/scoreboard.json b/lib/gitea/scoreboard.json index b389e05e..b949e667 100644 --- a/lib/gitea/scoreboard.json +++ b/lib/gitea/scoreboard.json @@ -5,9 +5,11 @@ "wire": {"pass": 78, "fail": 0}, "issues": {"pass": 88, "fail": 0}, "pr": {"pass": 100, "fail": 0}, - "activity": {"pass": 60, "fail": 0} + "activity": {"pass": 60, "fail": 0}, + "search": {"pass": 35, "fail": 0}, + "fed": {"pass": 60, "fail": 0} }, - "total_pass": 520, + "total_pass": 615, "total_fail": 0, - "total": 520 + "total": 615 } diff --git a/lib/gitea/scoreboard.md b/lib/gitea/scoreboard.md index 1f7196ab..7da60b68 100644 --- a/lib/gitea/scoreboard.md +++ b/lib/gitea/scoreboard.md @@ -10,4 +10,6 @@ _Generated by `lib/gitea/conformance.sh`_ | issues | 88 | 0 | 88 | | pr | 100 | 0 | 100 | | activity | 60 | 0 | 60 | -| **Total** | **520** | **0** | **520** | +| search | 35 | 0 | 35 | +| fed | 60 | 0 | 60 | +| **Total** | **615** | **0** | **615** | diff --git a/lib/gitea/tests/fed.sx b/lib/gitea/tests/fed.sx new file mode 100644 index 00000000..f92d1023 --- /dev/null +++ b/lib/gitea/tests/fed.sx @@ -0,0 +1,371 @@ +; lib/gitea/tests/fed.sx — Phase 8: ForgeFed. Two in-memory forges: +; AP actor docs + outbox, trust-gated inbox with provenance log and +; materialized federated issues/comments/PRs (proxy users), cursor-based +; outbound delivery, cross-instance repo mirrors, federated timeline. + +(st-bootstrap-classes!) +(content/bootstrap!) +(content-bootstrap-markdown!) +(content-bootstrap-table!) + +(define gitea-fed-pass 0) +(define gitea-fed-fail 0) +(define gitea-fed-fails (list)) + +(define + gitea-fed-test + (fn + (name actual expected) + (if + (= actual expected) + (set! gitea-fed-pass (+ gitea-fed-pass 1)) + (begin + (set! gitea-fed-fail (+ gitea-fed-fail 1)) + (set! gitea-fed-fails (append gitea-fed-fails (list {:name name :expected (inspect expected) :actual (inspect actual)}))))))) + +; ── forge A ────────────────────────────────────────────────────────── + +(define gf-dbA (persist/mem-backend)) +(define gf-A (gitea/forge gf-dbA)) +(gitea/instance! gf-A "forge-a") +(gitea/user-create! gf-A "alice") +(gitea/org-create! gf-A "acme") +(gitea/repo-create! gf-A "alice" "lib" {:created-at 1}) +(gitea/repo-create! gf-A "alice" "hid" {:created-at 2 :visibility "private"}) + +(define gf-gA (gitea/repo-git gf-A "alice" "lib")) +(git/add! gf-gA "README.md" "the lib\n") +(git/commit! gf-gA {:message "c1" :time 3 :author "alice"}) + +(gitea/issue-create! + gf-A + "alice" + "lib" + "alice" + "Public issue" + "hello" + {:created-at 4}) +(gitea/issue-create! + gf-A + "alice" + "hid" + "alice" + "Hidden issue" + "shh" + {:created-at 5}) + +; ── forge B ────────────────────────────────────────────────────────── + +(define gf-dbB (persist/mem-backend)) +(define gf-B (gitea/forge gf-dbB)) +(gitea/instance! gf-B "forge-b") +(gitea/user-create! gf-B "bob") +(gitea/repo-create! gf-B "bob" "hub" {:created-at 10}) + +(define gf-gB (gitea/repo-git gf-B "bob" "hub")) +(git/add! gf-gB "main.txt" "hub main\n") +(git/commit! gf-gB {:message "h1" :time 11 :author "bob"}) +(git/branch! gf-gB "feat") +(git/checkout! gf-gB "feat") +(git/add! gf-gB "feat.txt" "hub feat\n") +(git/commit! gf-gB {:message "h2" :time 12 :author "bob"}) +(git/checkout! gf-gB "main") + +(define gf-appA (gitea/app gf-A)) +(define gf-appB (gitea/app gf-B)) +(gitea/peer-register! gf-B "forge-a" gf-appA nil) +(gitea/peer-register! gf-A "forge-b" gf-appB nil) + +; ── identity + actor documents ─────────────────────────────────────── + +(gitea-fed-test "instance id" (gitea/instance-id gf-A) "forge-a") +(gitea-fed-test + "actor id" + (gitea/actor-id gf-A "user:alice") + "forge-a/user:alice") + +(gitea-fed-test + "ap user type" + (get (gitea/ap-user gf-A "alice") :type) + "Person") +(gitea-fed-test + "ap user id" + (get (gitea/ap-user gf-A "alice") :id) + "forge-a/user:alice") +(gitea-fed-test + "ap org type" + (get (gitea/ap-user gf-A "acme") :type) + "Group") +(gitea-fed-test "ap user missing" (gitea/ap-user gf-A "zeb") nil) + +(gitea-fed-test + "ap repo type" + (get (gitea/ap-repo gf-A "alice" "lib") :type) + "Repository") +(gitea-fed-test + "ap repo attribution" + (get (gitea/ap-repo gf-A "alice" "lib") :attributedTo) + "forge-a/user:alice") +(gitea-fed-test + "ap repo clone endpoint" + (get (gitea/ap-repo gf-A "alice" "lib") :clone) + "/alice/lib/info/refs") + +(gitea-fed-test + "outbox is ap-shaped" + (get (first (gitea/ap-outbox gf-A "alice" 10)) :actor) + "forge-a/user:alice") +(gitea-fed-test + "outbox hides private repos" + (len (gitea/ap-outbox gf-A "alice" 10)) + 2) + +; ── trust ──────────────────────────────────────────────────────────── + +(gitea-fed-test "untrusted by default" (gitea/trusted? gf-B "forge-a") false) +(gitea-fed-test + "inbox rejects untrusted" + (get (gitea/fed-receive! gf-B "forge-a" {:verb "open-issue"}) :error) + "untrusted-peer") +(gitea-fed-test + "rejected activity not logged" + (len (gitea/fed-log gf-B)) + 0) + +(gitea/trust! gf-B "forge-a") +(gitea-fed-test "trusted after trust!" (gitea/trusted? gf-B "forge-a") true) +(gitea-fed-test "trusted peers" (gitea/trusted-peers gf-B) (list "forge-a")) + +; ── inbound materialization ────────────────────────────────────────── + +(define gf-r1 (gitea/fed-receive! gf-B "forge-a" {:actor "forge-a/user:alice" :detail {:title "Fed issue" :body "opened from forge-a"} :object "issue:bob/hub#0" :at 50 :tags (list "repo:bob/hub") :verb "open-issue"})) + +(gitea-fed-test "fed issue accepted" (get gf-r1 :materialized) "issue") +(gitea-fed-test "fed issue number" (get gf-r1 :number) 1) +(gitea-fed-test + "proxy user created" + (gitea/owner-exists? gf-B "alice@forge-a") + true) +(gitea-fed-test + "fed issue author" + (get (gitea/issue-get gf-B "bob" "hub" 1) :author) + "alice@forge-a") +(gitea-fed-test + "fed issue title" + (get (gitea/issue-get gf-B "bob" "hub" 1) :title) + "Fed issue") +(gitea-fed-test + "fed log provenance" + (get (first (gitea/fed-log gf-B)) :origin) + "forge-a") + +(define gf-owners-before (len (gitea/owners gf-B))) +(define gf-r2 (gitea/fed-receive! gf-B "forge-a" {:actor "forge-a/user:alice" :detail {:body "following up"} :object "issue:bob/hub#1" :at 51 :tags (list "repo:bob/hub") :verb "comment"})) + +(gitea-fed-test "fed comment accepted" (get gf-r2 :materialized) "comment") +(gitea-fed-test + "fed comment recorded" + (len (get (gitea/issue-get gf-B "bob" "hub" 1) :comments)) + 1) +(gitea-fed-test + "proxy user reused" + (len (gitea/owners gf-B)) + gf-owners-before) + +(define gf-r3 (gitea/fed-receive! gf-B "forge-a" {:actor "forge-a/user:alice" :detail {:source "feat" :title "Fed PR" :body "take my branch" :target "main"} :object "pr:bob/hub#0" :at 52 :tags (list "repo:bob/hub") :verb "open-pr"})) + +(gitea-fed-test "fed pr accepted" (get gf-r3 :materialized) "pr") +(gitea-fed-test + "fed pr author" + (get (gitea/pr-get gf-B "bob" "hub" (get gf-r3 :number)) :author) + "alice@forge-a") +(gitea-fed-test + "fed pr branches" + (get (gitea/pr-get gf-B "bob" "hub" (get gf-r3 :number)) :source) + "feat") + +(gitea-fed-test + "unknown verb still logged" + (get (gitea/fed-receive! gf-B "forge-a" {:actor "forge-a/user:alice" :object "repo:bob/hub" :at 53 :tags (list "repo:bob/hub") :verb "star"}) :materialized) + "none") +(gitea-fed-test + "comment with bad object" + (get (gitea/fed-receive! gf-B "forge-a" {:actor "forge-a/user:alice" :object "nonsense" :at 54 :verb "comment"}) :error) + "bad-object") +(gitea-fed-test "fed log grows" (len (gitea/fed-log gf-B)) 5) + +; ── inbox over the wire ────────────────────────────────────────────── + +(define + gf-postB + (fn + (body) + (gf-appB (dream-request "POST" "/api/ap/inbox" {} body)))) + +(gitea-fed-test + "web inbox missing peer 400" + (dream-status (gf-postB (dream-json-encode {:activity {}}))) + 400) +(gitea-fed-test + "web inbox untrusted 403" + (dream-status (gf-postB (dream-json-encode {:activity {} :peer "forge-x"}))) + 403) +(gitea-fed-test + "web inbox trusted 200" + (dream-status (gf-postB (dream-json-encode {:activity {:actor "forge-a/user:alice" :detail {:body "via web"} :object "issue:bob/hub#1" :at 55 :tags (list "repo:bob/hub") :verb "comment"} :peer "forge-a"}))) + 200) +(gitea-fed-test + "web inbox materialized" + (len (get (gitea/issue-get gf-B "bob" "hub" 1) :comments)) + 2) + +; ── mirrors (cross-instance repo follow) ───────────────────────────── + +(define gf-m1 (gitea/mirror! gf-B "bob" "libmirror" "forge-a" "alice" "lib")) +(gitea-fed-test "mirror clones" (get gf-m1 :owner) "bob") +(gitea-fed-test + "mirror branch matches upstream" + (git/branch-get (gitea/repo-git gf-B "bob" "libmirror") "main") + (git/branch-get gf-gA "main")) +(gitea-fed-test + "mirror recorded" + (get (gitea/mirror-of gf-B "bob" "libmirror") :peer) + "forge-a") +(gitea-fed-test "mirrors list" (gitea/mirrors gf-B) (list "bob/libmirror")) + +(git/checkout! gf-gA "main") +(git/add! gf-gA "more.txt" "more\n") +(define gf-c2 (git/commit! gf-gA {:message "c2" :time 6 :author "alice"})) + +(gitea-fed-test + "mirror-sync pulls new commits" + (get (gitea/mirror-sync! gf-B "bob" "libmirror") :stored) + 3) +(gitea-fed-test + "mirror advanced" + (git/branch-get (gitea/repo-git gf-B "bob" "libmirror") "main") + gf-c2) + +(gitea/untrust! gf-B "forge-a") +(gitea-fed-test + "sync of untrusted peer refused" + (get (gitea/mirror-sync! gf-B "bob" "libmirror") :error) + "untrusted-peer") +(gitea-fed-test + "mirror of untrusted peer refused" + (get (gitea/mirror! gf-B "bob" "another" "forge-a" "alice" "lib") :error) + "untrusted-peer") +(gitea/trust! gf-B "forge-a") +(gitea-fed-test + "sync ok after re-trust" + (get (gitea/mirror-sync! gf-B "bob" "libmirror") :stored) + 0) +(gitea-fed-test + "non-mirror sync refused" + (get (gitea/mirror-sync! gf-B "bob" "hub") :error) + "not-a-mirror") + +; ── outbound delivery ──────────────────────────────────────────────── + +(gitea/trust! gf-A "forge-b") +(define gf-d1 (gitea/fed-deliver! gf-A)) + +; A's public activity so far: create-repo lib, open-issue lib#1, +; comment... none; private create/issue excluded +(gitea-fed-test + "deliver pushes public only" + (get gf-d1 :delivered) + 2) +(gitea-fed-test + "deliver reaches trusted peers" + (get gf-d1 :peers) + (list "forge-b")) +(gitea-fed-test + "peer logged deliveries" + (len (gitea/fed-log gf-B)) + 8) +(gitea-fed-test + "delivered origin" + (get + (first + (filter + (fn (e) (= (get (get e :activity) :verb) "create-repo")) + (gitea/fed-log gf-B))) + :origin) + "forge-a") + +(gitea-fed-test + "second deliver is a no-op" + (get (gitea/fed-deliver! gf-A) :delivered) + 0) + +(gitea/issue-comment! + gf-A + "alice" + "lib" + 1 + "alice" + "one more" + {:at 7}) +(gitea-fed-test + "incremental deliver" + (get (gitea/fed-deliver! gf-A) :delivered) + 1) + +; ── federated timeline ─────────────────────────────────────────────── + +(define gf-tl (gitea/fed-timeline gf-B nil 100)) +(gitea-fed-test + "fed timeline mixes local and foreign" + (> + (len (filter (fn (a) (= (get a :origin) "forge-a")) gf-tl)) + 0) + true) +(gitea-fed-test + "local activities carry no origin" + (> + (len + (filter + (fn + (a) + (and (nil? (get a :origin)) (= (get a :verb) "create-repo"))) + gf-tl)) + 0) + true) + +; ── actor docs over the wire ───────────────────────────────────────── + +(define + gf-getA + (fn (target) (gf-appA (dream-request "GET" target {} "")))) + +(gitea-fed-test + "web ap user 200" + (dream-status (gf-getA "/api/ap/users/alice")) + 200) +(gitea-fed-test + "web ap user type" + (get + (dream-json-parse (dream-resp-body (gf-getA "/api/ap/users/alice"))) + :type) + "Person") +(gitea-fed-test + "web ap user missing 404" + (dream-status (gf-getA "/api/ap/users/zeb")) + 404) +(gitea-fed-test + "web ap repo 200" + (dream-status (gf-getA "/api/ap/repos/alice/lib")) + 200) +(gitea-fed-test + "web ap private repo hidden" + (dream-status (gf-getA "/api/ap/repos/alice/hid")) + 404) +(gitea-fed-test + "web outbox 200" + (dream-status (gf-getA "/api/ap/users/alice/outbox")) + 200) +(gitea-fed-test + "web fed timeline 200" + (dream-status (gf-getA "/api/fed/timeline")) + 200)