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) | |