sx-gitea Phase 6: activity — feed timelines, dashboard, durable notifications (TDD, 520/520)

lib/gitea/activity.sx: every forge action lands as a feed activity in an
append-only persist log stream. Instrumentation is done IN the runtime —
repo-create!/issue-create!/issue-comment!/pr-create!/pr-review!/pr-merge!
are redefined around their originals, so SX callers and web handlers emit
activity with zero call-site edits (failed mutations emit nothing).

Timelines are lib/feed (APL) queries: global/repo/user, newest-first,
visibility follows repo access (private-repo activity invisible to
non-readers). Follows (user: or repo: targets) drive a dashboard of
followed actors/repos minus one's own actions.

Notifications ride lib/events durable delivery: activities after a
cursor expand to (id recipient body) messages (comment -> author+
participants, review/merge -> PR author, open-issue -> assignees, never
the actor), ev/deliver-messages runs the at-least-once digest flow, and
delivered messages file into per-user kv inboxes; the cursor advance
makes reruns no-ops.

Web: /activity + /:owner/:name/activity pages, user-activity/dashboard/
follow/notifications/notify-run JSON API. gitea/all-routes now hoists
every /api/* route ahead of the wildcard /:owner/:name patterns so later
packs can add API endpoints without being shadowed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 14:25:10 +00:00
parent 24821e3f77
commit b4fbfa5603
6 changed files with 1033 additions and 13 deletions

432
lib/gitea/tests/activity.sx Normal file
View File

@@ -0,0 +1,432 @@
; lib/gitea/tests/activity.sx — Phase 6: instrumented activity log, feed
; timelines with visibility, follows + dashboard, durable notifications
; into per-user inboxes, and the activity web routes.
(st-bootstrap-classes!)
(content/bootstrap!)
(content-bootstrap-markdown!)
(content-bootstrap-table!)
(define gitea-act-pass 0)
(define gitea-act-fail 0)
(define gitea-act-fails (list))
(define
gitea-act-test
(fn
(name actual expected)
(if
(= actual expected)
(set! gitea-act-pass (+ gitea-act-pass 1))
(begin
(set! gitea-act-fail (+ gitea-act-fail 1))
(set! gitea-act-fails (append gitea-act-fails (list {:name name :expected (inspect expected) :actual (inspect actual)})))))))
; ── setup + instrumented history ─────────────────────────────────────
; activity ledger (seq/at/actor/verb):
; 1 @5 alice create-repo repo:alice/proj (public)
; 2 @6 alice create-repo repo:alice/sec (private)
; 3 @10 alice open-issue issue:alice/proj#1
; 4 @11 bob comment issue:alice/proj#1
; 5 @12 bob open-pr pr:alice/proj#2
; 6 @13 carol review pr:alice/proj#2
; 7 @14 alice merge-pr pr:alice/proj#2
; 8 @20 alice open-issue issue:alice/sec#1 (private)
; 9 @21 alice open-issue issue:alice/proj#3 (assignee bob)
(define ga-db (persist/mem-backend))
(define ga-forge (gitea/forge ga-db))
(gitea/user-create! ga-forge "alice")
(gitea/user-create! ga-forge "bob")
(gitea/user-create! ga-forge "carol")
(gitea/user-create! ga-forge "eve")
(gitea/token-create! ga-forge "alice" "tok-a")
(gitea/token-create! ga-forge "bob" "tok-b")
(gitea/token-create! ga-forge "carol" "tok-c")
(gitea/repo-create! ga-forge "alice" "proj" {:created-at 5})
(gitea/repo-create! ga-forge "alice" "sec" {:created-at 6 :visibility "private"})
(gitea/collab-add! ga-forge "alice" "sec" "bob" "read")
(define ga-g (gitea/repo-git ga-forge "alice" "proj"))
(git/add! ga-g "README.md" "base\n")
(git/commit! ga-g {:message "c1" :time 1 :author "alice"})
(git/branch! ga-g "feat")
(git/checkout! ga-g "feat")
(git/add! ga-g "f.txt" "feature\n")
(git/commit! ga-g {:message "c2" :time 2 :author "bob"})
(git/checkout! ga-g "main")
(gitea/issue-create!
ga-forge
"alice"
"proj"
"alice"
"First issue"
"body"
{:created-at 10})
(gitea/issue-comment!
ga-forge
"alice"
"proj"
1
"bob"
"a comment"
{:at 11})
(gitea/pr-create!
ga-forge
"alice"
"proj"
"bob"
"Feature"
"feat"
"main"
""
{:created-at 12})
(gitea/pr-review!
ga-forge
"alice"
"proj"
2
"carol"
"approve"
"lgtm"
{:at 13})
(gitea/pr-merge! ga-forge "alice" "proj" 2 "alice" {:time 14})
(gitea/issue-create!
ga-forge
"alice"
"sec"
"alice"
"Secret issue"
""
{:created-at 20})
(gitea/issue-create!
ga-forge
"alice"
"proj"
"alice"
"Assigned issue"
""
{:assignees (list "bob") :created-at 21})
(gitea-act-test "activity count" (gitea/activity-count ga-forge) 9)
; failed mutations emit nothing
(gitea/issue-create! ga-forge "alice" "none" "alice" "x" "" {})
(gitea/pr-review!
ga-forge
"alice"
"proj"
2
"bob"
"approve"
""
{})
(gitea-act-test
"errors emit no activity"
(gitea/activity-count ga-forge)
9)
; ── timelines ────────────────────────────────────────────────────────
(gitea-act-test
"anon timeline hides private"
(len (gitea/timeline ga-forge nil 50))
7)
(gitea-act-test
"owner timeline sees all"
(len (gitea/timeline ga-forge "alice" 50))
9)
(gitea-act-test
"timeline newest first"
(get (first (gitea/timeline ga-forge nil 50)) :verb)
"open-issue")
(gitea-act-test
"timeline take"
(len (gitea/timeline ga-forge "alice" 2))
2)
(gitea-act-test
"repo timeline proj"
(len (gitea/repo-timeline ga-forge "alice" "proj" 50))
7)
(gitea-act-test
"repo timeline sec"
(len (gitea/repo-timeline ga-forge "alice" "sec" 50))
2)
(gitea-act-test
"repo timeline order"
(get (first (gitea/repo-timeline ga-forge "alice" "proj" 50)) :at)
21)
(gitea-act-test
"user timeline bob"
(len (gitea/user-timeline ga-forge nil "bob" 50))
2)
(gitea-act-test
"user timeline bob order"
(get (first (gitea/user-timeline ga-forge nil "bob" 50)) :verb)
"open-pr")
(gitea-act-test
"user timeline alice anon"
(len (gitea/user-timeline ga-forge nil "alice" 50))
4)
(gitea-act-test
"user timeline alice as collab"
(len (gitea/user-timeline ga-forge "bob" "alice" 50))
6)
; ── follows + dashboard ──────────────────────────────────────────────
(gitea/follow! ga-forge "carol" "user:alice")
(gitea-act-test
"follows list"
(gitea/follows ga-forge "carol")
(list "user:alice"))
(gitea-act-test
"follow unknown follower"
(get (gitea/follow! ga-forge "zeb" "user:alice") :error)
"no-such-user")
(gitea-act-test
"follow unknown user target"
(get (gitea/follow! ga-forge "carol" "user:zeb") :error)
"no-such-target")
(gitea-act-test
"follow unknown repo target"
(get (gitea/follow! ga-forge "carol" "repo:alice/none") :error)
"no-such-target")
(gitea-act-test
"follow malformed target"
(get (gitea/follow! ga-forge "carol" "alice") :error)
"no-such-target")
(gitea-act-test
"dashboard follows a user"
(len (gitea/dashboard ga-forge "carol" 50))
4)
(gitea-act-test
"dashboard first actor"
(get (first (gitea/dashboard ga-forge "carol" 50)) :actor)
"alice")
(gitea/follow! ga-forge "bob" "repo:alice/proj")
(gitea-act-test
"dashboard follows a repo, excludes own actions"
(len (gitea/dashboard ga-forge "bob" 50))
5)
(gitea-act-test
"unfollow"
(gitea/unfollow! ga-forge "carol" "user:alice")
true)
(gitea-act-test
"dashboard after unfollow"
(len (gitea/dashboard ga-forge "carol" 50))
0)
(gitea-act-test
"unfollow twice"
(gitea/unfollow! ga-forge "carol" "user:alice")
false)
; ── notifications ────────────────────────────────────────────────────
(gitea-act-test
"recipients of a comment"
(gitea/notify-recipients
ga-forge
(feed/activity
"bob"
"comment"
"issue:alice/proj#1"
11
(list "repo:alice/proj")))
(list "alice"))
(gitea-act-test
"recipients of a review"
(gitea/notify-recipients
ga-forge
(feed/activity
"carol"
"review"
"pr:alice/proj#2"
13
(list "repo:alice/proj")))
(list "bob"))
(gitea-act-test
"recipients exclude the actor"
(gitea/notify-recipients
ga-forge
(feed/activity
"alice"
"comment"
"issue:alice/proj#1"
30
(list "repo:alice/proj")))
(list "bob"))
(define ga-pend (gitea/pending-notifications ga-forge))
(gitea-act-test
"pending message count"
(len (get ga-pend :messages))
4)
(gitea-act-test "pending last seq" (get ga-pend :last-seq) 9)
(define ga-out1 (gitea/notify! ga-forge))
(gitea-act-test
"notify delivers all"
(len (filter (fn (o) (= (first o) "delivered")) ga-out1))
4)
(gitea-act-test "inbox alice" (gitea/inbox-count ga-forge "alice") 1)
(gitea-act-test
"inbox alice body"
(get (first (gitea/inbox ga-forge "alice")) :body)
"bob comment issue:alice/proj#1")
(gitea-act-test "inbox bob" (gitea/inbox-count ga-forge "bob") 3)
(gitea-act-test "notify rerun is a no-op" (gitea/notify! ga-forge) (list))
(gitea-act-test
"inboxes stable after rerun"
(gitea/inbox-count ga-forge "bob")
3)
; a fresh comment (carol) notifies the author and the other commenter
(gitea/issue-comment!
ga-forge
"alice"
"proj"
1
"carol"
"me too"
{:at 30})
(define ga-out2 (gitea/notify! ga-forge))
(gitea-act-test "incremental delivery" (len ga-out2) 2)
(gitea-act-test
"inbox alice grows"
(gitea/inbox-count ga-forge "alice")
2)
(gitea-act-test
"inbox bob grows"
(gitea/inbox-count ga-forge "bob")
4)
; ── web ──────────────────────────────────────────────────────────────
(define ga-app (gitea/app ga-forge))
(define ga-hdr (fn (tok) (if (nil? tok) {} {:authorization (str "Bearer " tok)})))
(define
ga-get
(fn (target tok) (ga-app (dream-request "GET" target (ga-hdr tok) ""))))
(define
ga-post
(fn
(target tok body)
(ga-app (dream-request "POST" target (ga-hdr tok) body))))
(define
ga-del
(fn
(target tok)
(ga-app (dream-request "DELETE" target (ga-hdr tok) ""))))
(gitea-act-test
"activity page 200"
(dream-status (ga-get "/activity" nil))
200)
(gitea-act-test
"activity page shows merges"
(contains? (dream-resp-body (ga-get "/activity" nil)) "merge-pr")
true)
(gitea-act-test
"activity page hides private from anon"
(contains? (dream-resp-body (ga-get "/activity" nil)) "alice/sec")
false)
(gitea-act-test
"activity page shows private to owner"
(contains? (dream-resp-body (ga-get "/activity" "tok-a")) "alice/sec")
true)
(gitea-act-test
"repo activity page 200"
(dream-status (ga-get "/alice/proj/activity" nil))
200)
(gitea-act-test
"repo activity shows pr open"
(contains? (dream-resp-body (ga-get "/alice/proj/activity" nil)) "open-pr")
true)
(gitea-act-test
"private repo activity anon 404"
(dream-status (ga-get "/alice/sec/activity" nil))
404)
(gitea-act-test
"private repo activity collab 200"
(dream-status (ga-get "/alice/sec/activity" "tok-b"))
200)
(gitea-act-test
"api user activity len"
(len
(dream-json-parse
(dream-resp-body (ga-get "/api/users/bob/activity" nil))))
2)
(gitea-act-test
"api user activity unknown 404"
(dream-status (ga-get "/api/users/zeb/activity" nil))
404)
(gitea-act-test
"api dashboard anon 401"
(dream-status (ga-get "/api/dashboard" nil))
401)
(gitea-act-test
"api dashboard bob"
(len
(dream-json-parse (dream-resp-body (ga-get "/api/dashboard" "tok-b"))))
6)
(gitea-act-test
"api follow anon 401"
(dream-status (ga-post "/api/follow" nil (dream-json-encode {:target "user:bob"})))
401)
(gitea-act-test
"api follow 200"
(dream-status
(ga-post "/api/follow" "tok-c" (dream-json-encode {:target "user:bob"})))
200)
(gitea-act-test
"api follow recorded"
(gitea/follows ga-forge "carol")
(list "user:bob"))
(gitea-act-test
"api follow bad target 400"
(dream-status
(ga-post "/api/follow" "tok-c" (dream-json-encode {:target "nope"})))
400)
(gitea-act-test
"api unfollow 200"
(dream-status (ga-del "/api/follow/user:bob" "tok-c"))
200)
(gitea-act-test
"api unfollow missing 404"
(dream-status (ga-del "/api/follow/user:bob" "tok-c"))
404)
(gitea-act-test
"api notifications anon 401"
(dream-status (ga-get "/api/notifications" nil))
401)
(gitea-act-test
"api notifications bodies"
(contains?
(dream-json-parse
(dream-resp-body (ga-get "/api/notifications" "tok-a")))
"bob comment issue:alice/proj#1")
true)
(gitea-act-test
"api notify run anon 401"
(dream-status (ga-post "/api/notify/run" nil "{}"))
401)
(gitea-act-test
"api notify run 200"
(dream-status (ga-post "/api/notify/run" "tok-a" "{}"))
200)