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/web/activity.go +136−12

@@ -8,6 +8,7 @@ package web
8 8
9 9 import (
10 10 "net/http"
11 "slices"
11 12 "sort"
12 13 "strings"
13 14 "time"
@@ -29,11 +30,32 @@ type activityEvent struct {
29 30 URL string
30 31 Badge string
31 32 BadgeClass string
32 // Assignees is kept on issue events so a later filter can narrow the feed to
33 // Assignees is kept on issue events so a filter can narrow the feed to
33 34 // "things assigned to me" without changing the event source.
34 35 Assignees []string
35 36 }
36 37
38 type activityActor struct {
39 Name string
40 Label string
41 }
42
43 type activityFeed struct {
44 Events []activityEvent
45 Assignees []string
46 }
47
48 type activityView struct {
49 Page
50 Name string
51 Kind string
52 Actor string
53 Assignee string
54 Actors []activityActor
55 Assignees []string
56 Events []activityEvent
57 }
58
37 59 func normalizeActivityKind(kind string) string {
38 60 switch strings.ToLower(strings.TrimSpace(kind)) {
39 61 case "commit", "commits":
@@ -50,17 +72,24 @@ func normalizeActivityKind(kind string) string {
50 72 return ""
51 73 }
52 74
53 func (w *Web) repoActivity(m *repo.Meta, kind string) []activityEvent {
75 func (w *Web) repoActivity(m *repo.Meta, kind, actor, assignee string) activityFeed {
54 76 g := m.Git()
55 77 var events []activityEvent
56 78
79 var stateChanges []metadata.StateChange
80 if kind == "" || kind == "issue" || kind == "change" {
81 stateChanges, _ = metadata.LoadStateChanges(g, 200)
82 }
83 issuesByID := map[string]*metadata.Issue{}
84 requestsByID := map[string]*metadata.Request{}
85
57 86 if kind == "" || kind == "commit" {
58 87 if commit, _ := g.RevParse(m.DefaultBranch); commit != "" {
59 88 if commits, err := g.Log(commit, 20); err == nil {
60 89 for _, c := range commits {
61 90 events = append(events, activityEvent{
62 91 Kind: "commit", Icon: "git-commit-horizontal",
63 Time: time.Unix(c.When, 0).UTC(), Actor: c.Author,
92 Time: time.Unix(c.When, 0).UTC(), Actor: c.Author, ActorName: c.Author,
64 93 Title: c.Subject, Detail: "committed",
65 94 URL: "/r/" + m.Name + "/commit/" + c.SHA,
66 95 })
@@ -72,6 +101,7 @@ func (w *Web) repoActivity(m *repo.Meta, kind string) []activityEvent {
72 101 if kind == "" || kind == "issue" {
73 102 if issues, err := metadata.LoadIssues(g, metadata.Ref); err == nil {
74 103 for _, iss := range issues {
104 issuesByID[iss.ID] = iss
75 105 actor := w.who(iss.Author)
76 106 events = append(events, activityEvent{
77 107 Kind: "issue", Icon: "circle-dot", Time: iss.Created,
@@ -95,6 +125,7 @@ func (w *Web) repoActivity(m *repo.Meta, kind string) []activityEvent {
95 125 if kind == "" || kind == "change" {
96 126 if requests, err := metadata.LoadRequests(g, metadata.Ref); err == nil {
97 127 for _, req := range requests {
128 requestsByID[req.ID] = req
98 129 events = append(events, activityEvent{
99 130 Kind: "change", Icon: "git-pull-request", Time: req.Created,
100 131 Actor: w.who(req.Author), ActorName: req.Author, Title: req.Title,
@@ -125,12 +156,46 @@ func (w *Web) repoActivity(m *repo.Meta, kind string) []activityEvent {
125 156 }
126 157 }
127 158
159 // State changes come from the metadata log rather than the current files:
160 // a reopened issue is two events, and the file only knows the latest one.
161 for _, sc := range stateChanges {
162 switch sc.Kind {
163 case "issue":
164 if kind != "" && kind != "issue" {
165 continue
166 }
167 iss := issuesByID[sc.ID]
168 if iss == nil {
169 continue
170 }
171 events = append(events, activityEvent{
172 Kind: "issue", Icon: "circle-dot", Time: sc.Time,
173 Actor: w.who(sc.Actor), ActorName: sc.Actor, Title: iss.Title,
174 Detail: issueStateDetail(sc.State), URL: "/r/" + m.Name + "/issues/" + sc.ID,
175 Assignees: iss.Assignees,
176 })
177 case "request":
178 if kind != "" && kind != "change" {
179 continue
180 }
181 req := requestsByID[sc.ID]
182 if req == nil {
183 continue
184 }
185 events = append(events, activityEvent{
186 Kind: "change", Icon: "git-pull-request", Time: sc.Time,
187 Actor: w.who(sc.Actor), ActorName: sc.Actor, Title: req.Title,
188 Detail: requestStateDetail(sc.State), URL: "/r/" + m.Name + "/requests/" + sc.ID,
189 })
190 }
191 }
192
128 193 if kind == "" || kind == "release" {
129 194 if tags, err := g.Tags(); err == nil {
130 195 for _, t := range tags {
131 196 events = append(events, activityEvent{
132 197 Kind: "release", Icon: "tag", Time: time.Unix(t.When, 0).UTC(),
133 Actor: t.Tagger, Title: t.Name, Detail: "released",
198 Actor: t.Tagger, ActorName: t.Tagger, Title: t.Name, Detail: "released",
134 199 URL: "/r/" + m.Name + "/releases/" + t.Name,
135 200 })
136 201 }
@@ -161,10 +226,70 @@ func (w *Web) repoActivity(m *repo.Meta, kind string) []activityEvent {
161 226 sort.SliceStable(events, func(i, j int) bool {
162 227 return events[i].Time.After(events[j].Time)
163 228 })
229 assignees := activityAssignees(events)
230 if actor != "" || assignee != "" {
231 filtered := events[:0]
232 for _, e := range events {
233 if actor != "" && e.ActorName != actor {
234 continue
235 }
236 if assignee != "" && !slices.Contains(e.Assignees, assignee) {
237 continue
238 }
239 filtered = append(filtered, e)
240 }
241 events = filtered
242 }
164 243 if len(events) > activityLimit {
165 244 events = events[:activityLimit]
166 245 }
167 return events
246 return activityFeed{Events: events, Assignees: assignees}
247 }
248
249 func activityAssignees(events []activityEvent) []string {
250 seen := map[string]bool{}
251 var out []string
252 for _, e := range events {
253 for _, a := range e.Assignees {
254 if a != "" && !seen[a] {
255 seen[a] = true
256 out = append(out, a)
257 }
258 }
259 }
260 sort.Strings(out)
261 return out
262 }
263
264 func issueStateDetail(state string) string {
265 if state == "closed" {
266 return "closed issue"
267 }
268 return "reopened issue"
269 }
270
271 func requestStateDetail(state string) string {
272 switch state {
273 case "closed":
274 return "closed change"
275 case "merged":
276 return "merged change"
277 }
278 return "reopened change"
279 }
280
281 func (w *Web) activityActors() []activityActor {
282 if w.Ident == nil {
283 return nil
284 }
285 var out []activityActor
286 for _, acct := range w.Ident.All() {
287 if !acct.Active() {
288 continue
289 }
290 out = append(out, activityActor{Name: acct.Name, Label: acct.Label()})
291 }
292 return out
168 293 }
169 294
170 295 // Activity serves the repository activity tab. It is separate from Overview so
@@ -176,14 +301,13 @@ func (w *Web) Activity(rw http.ResponseWriter, r *http.Request) {
176 301 return
177 302 }
178 303 kind := normalizeActivityKind(r.URL.Query().Get("kind"))
179 view := struct {
180 Page
181 Name string
182 Kind string
183 Events []activityEvent
184 }{
304 actor := strings.TrimSpace(r.URL.Query().Get("actor"))
305 assignee := strings.TrimSpace(r.URL.Query().Get("assignee"))
306 feed := w.repoActivity(m, kind, actor, assignee)
307 view := activityView{
185 308 Page: w.page(r, m.Name+" · activity"), Name: m.Name, Kind: kind,
186 Events: w.repoActivity(m, kind),
309 Actor: actor, Assignee: assignee,
310 Actors: w.activityActors(), Assignees: feed.Assignees, Events: feed.Events,
187 311 }
188 312 if htmxRequest(r) {
189 313 w.render(rw, "activity_feed", view, 0)