Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Show state changes and actor/assignee filters in activity

8e93a8f0e42847734facadaa8a56f857ed1a1565 · Keenan Nemetz · 15d ago · parent 2973f727

Show state changes and actor/assignee filters in activity

Issue state changes now write a canonical "issue: <id> <state>" subject, the same shape requests already used, so the feed can read closed, reopened, and merged events from the metadata log without diffing historical files. The CLI and the web share metadata.SetIssueState.

The repository feed filters by type, actor, and assignee. Filters survive type changes and HTMX swaps, and are applied before the 100-event limit so an older matching event is not hidden by newer unrelated ones.

server/activity_test.go +63

@@ -0,0 +1,63 @@
1 package server_test
2
3 import (
4 "net/url"
5 "strings"
6 "testing"
7 )
8
9 func TestActivityShowsStateChangesAndFilters(t *testing.T) {
10 t.Parallel()
11 f := browserFixture(t)
12 f.publish(f.repo)
13 f.addWriter(t)
14
15 work := workRepo(t, map[string]string{"README.md": "# activity\n"})
16 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
17 t.Fatalf("main push: %s", errs)
18 }
19
20 const closedID = "01M5JR2JK29KKMAA1T9MDNTPF1"
21 const assignedID = "01M5JR2JK29KKMAA1T9MDNTPF2"
22 files := issueFiles(closedID, "Closed then reopened", "body\n")
23 files["issues/"+assignedID+"/issue.md"] = issueFile(
24 "Assigned issue", "keenan", "open", "2026-10-22T21:01:00Z", []string{"bug"}, "assigned body\n", "wilbur",
25 )
26 issues := f.issuesRepo(t, files)
27 if _, errs, err := f.git(issues, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
28 t.Fatalf("issues push: %s", errs)
29 }
30
31 sess, cookie := f.browserSession(t, f.ownerID)
32 for _, state := range []string{"closed", "open"} {
33 resp, _ := f.browserPost(t, "/r/"+f.repo+"/issues/"+closedID+"/state", cookie, url.Values{
34 "csrf": {sess.CSRF}, "state": {state},
35 })
36 if resp.StatusCode != 303 {
37 t.Fatalf("set state %s = %d", state, resp.StatusCode)
38 }
39 }
40
41 _, body := f.get("/r/" + f.repo + "/activity?kind=issues")
42 for _, want := range []string{"closed issue", "reopened issue", "Closed then reopened", "Assigned issue"} {
43 if !strings.Contains(body, want) {
44 t.Fatalf("activity does not show %q:\n%s", want, body)
45 }
46 }
47
48 _, body = f.get("/r/" + f.repo + "/activity?kind=issues&actor=keenan")
49 if !strings.Contains(body, "closed issue") || !strings.Contains(body, "Assigned issue") {
50 t.Fatalf("actor filter dropped keenan's events:\n%s", body)
51 }
52 if _, body := f.get("/r/" + f.repo + "/activity?kind=issues&actor=wilbur"); strings.Contains(body, "Closed then reopened") {
53 t.Fatalf("actor filter kept another account's events:\n%s", body)
54 }
55
56 _, body = f.get("/r/" + f.repo + "/activity?kind=issues&assignee=wilbur")
57 if !strings.Contains(body, "Assigned issue") {
58 t.Fatalf("assignee filter dropped the assigned issue:\n%s", body)
59 }
60 if strings.Contains(body, "Closed then reopened") {
61 t.Fatalf("assignee filter kept an unassigned issue:\n%s", body)
62 }
63 }