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>
433 lines
11 KiB
Plaintext
433 lines
11 KiB
Plaintext
; 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)
|