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.

Blame · server/web/activity.go

Line attribution on main · 515 line(s)

f09ef39e
Keenan Nemetz
15d ago
1 package web
2
074ae160
Keenan Nemetz
14d ago
3 // The repository activity feed. It is derived, never stored: issues, changes,
4 // comments, and state changes come from the metadata branch; releases come from
5 // tags. Commits and CI runs are deliberately not events: the feed is for things
6 // people said and shipped. That means there is no notification table or unread
7 // state to keep in sync, and a private repository's feed follows the same read
8 // rule as every other page.
f09ef39e
Keenan Nemetz
15d ago
9
10 import (
11 "net/http"
c8cdb6ac
Keenan Nemetz
9d ago
12 "net/url"
8e93a8f0
Keenan Nemetz
15d ago
13 "slices"
f09ef39e
Keenan Nemetz
15d ago
14 "sort"
c8cdb6ac
Keenan Nemetz
9d ago
15 "strconv"
f09ef39e
Keenan Nemetz
15d ago
16 "strings"
17 "time"
18
d37d301f
Keenan Nemetz
15h ago
19 "rodent.software/vole/core/git"
20 "rodent.software/vole/forge/index"
21 "rodent.software/vole/forge/repo"
22 "rodent.software/vole/metadata"
f09ef39e
Keenan Nemetz
15d ago
23 )
24
c8cdb6ac
Keenan Nemetz
9d ago
25 // activityPage is how many events one page of the repository feed shows. The
26 // whole feed is paginated, so this is a reading size rather than a bound on how
27 // far back someone can look.
28 const activityPage = 50
f09ef39e
Keenan Nemetz
15d ago
29
30 type activityEvent struct {
074ae160
Keenan Nemetz
14d ago
31 Kind string
32 Icon string
33 Time time.Time
34 Actor string
35 ActorName string
36 Title string
37 Detail string
38 URL string
8e93a8f0
Keenan Nemetz
15d ago
39 // Assignees is kept on issue events so a filter can narrow the feed to
f09ef39e
Keenan Nemetz
15d ago
40 // "things assigned to me" without changing the event source.
41 Assignees []string
b355db72
Keenan Nemetz
15d ago
42 // Repo is set only by the instance-wide feed, so an event row can name the
43 // repository it came from without every repository page repeating itself.
44 Repo string
f09ef39e
Keenan Nemetz
15d ago
45 }
46
8e93a8f0
Keenan Nemetz
15d ago
47 type activityActor struct {
48 Name string
49 Label string
50 }
51
52 type activityFeed struct {
53 Events []activityEvent
54 Assignees []string
074ae160
Keenan Nemetz
14d ago
55 // Summary fields describe the repository itself rather than the feed. The
56 // landing page builds its cards from the same read, so listing a repository
57 // costs one pass instead of one for the card and another for the events.
58 LastCommit *git.Commit
59 LastTag *git.Tag
60 OpenIssues int
61 OpenChanges int
8e93a8f0
Keenan Nemetz
15d ago
62 }
63
64 type activityView struct {
65 Page
66 Name string
67 Kind string
68 Actor string
69 Assignee string
70 Actors []activityActor
71 Assignees []string
72 Events []activityEvent
c8cdb6ac
Keenan Nemetz
9d ago
73 Pager pager
8e93a8f0
Keenan Nemetz
15d ago
74 }
75
f09ef39e
Keenan Nemetz
15d ago
76 func normalizeActivityKind(kind string) string {
77 switch strings.ToLower(strings.TrimSpace(kind)) {
78 case "issue", "issues":
79 return "issue"
80 case "change", "changes":
81 return "change"
82 case "release", "releases":
83 return "release"
84 }
85 return ""
86 }
87
c8cdb6ac
Keenan Nemetz
9d ago
88 func (w *Web) repoActivity(memo *requestMemo, m *repo.Meta, kind, actor, assignee string, limit int) activityFeed {
f09ef39e
Keenan Nemetz
15d ago
89 g := m.Git()
90 var events []activityEvent
074ae160
Keenan Nemetz
14d ago
91 var lastCommit *git.Commit
92 var lastTag *git.Tag
93 openIssues, openChanges := 0, 0
f09ef39e
Keenan Nemetz
15d ago
94
8e93a8f0
Keenan Nemetz
15d ago
95 var stateChanges []metadata.StateChange
96 if kind == "" || kind == "issue" || kind == "change" {
4d938fe8
Keenan Nemetz
9d ago
97 stateChanges = w.stateChanges(memo, m)
8e93a8f0
Keenan Nemetz
15d ago
98 }
14b5c648
Keenan Nemetz
9d ago
99 issuesByID := map[string]*activityIssue{}
100 requestsByID := map[string]*activityRequest{}
8e93a8f0
Keenan Nemetz
15d ago
101
074ae160
Keenan Nemetz
14d ago
102 // The landing page card still needs the head commit; commits themselves are
103 // not feed events, so only the newest one is read.
104 if kind == "" {
f09ef39e
Keenan Nemetz
15d ago
105 if commit, _ := g.RevParse(m.DefaultBranch); commit != "" {
074ae160
Keenan Nemetz
14d ago
106 if commits, err := g.Log(commit, 1); err == nil && len(commits) > 0 {
107 lastCommit = &commits[0]
f09ef39e
Keenan Nemetz
15d ago
108 }
109 }
110 }
111
112 if kind == "" || kind == "issue" {
14b5c648
Keenan Nemetz
9d ago
113 issues := w.activityIssues(memo, m)
114 for i := range issues {
115 iss := &issues[i]
116 if iss.Open {
117 openIssues++
118 }
119 issuesByID[iss.ID] = iss
120 events = append(events, activityEvent{
121 Kind: "issue", Icon: "circle-dot", Time: iss.Created,
122 Actor: w.who(iss.Author), ActorName: iss.Author, Title: iss.Title,
ed03b3d1
Keenan Nemetz
2h ago
123 Detail: "opened issue", URL: "/" + m.Name + "/issues/" + iss.ID,
14b5c648
Keenan Nemetz
9d ago
124 Assignees: iss.Assignees,
125 })
126 for _, c := range iss.Comments {
f09ef39e
Keenan Nemetz
15d ago
127 events = append(events, activityEvent{
14b5c648
Keenan Nemetz
9d ago
128 Kind: "issue", Icon: "message-square", Time: c.Created,
129 Actor: w.who(c.Author), ActorName: c.Author, Title: iss.Title,
130 Detail: "commented on issue",
ed03b3d1
Keenan Nemetz
2h ago
131 URL: "/" + m.Name + "/issues/" + iss.ID + "#c-" + c.ID,
f09ef39e
Keenan Nemetz
15d ago
132 Assignees: iss.Assignees,
133 })
134 }
135 }
136 }
137
138 if kind == "" || kind == "change" {
14b5c648
Keenan Nemetz
9d ago
139 requests := w.activityRequests(memo, m)
140 for i := range requests {
141 req := &requests[i]
142 if req.Open {
143 openChanges++
144 }
145 requestsByID[req.ID] = req
146 events = append(events, activityEvent{
147 Kind: "change", Icon: "git-pull-request", Time: req.Created,
148 Actor: w.who(req.Author), ActorName: req.Author, Title: req.Title,
ed03b3d1
Keenan Nemetz
2h ago
149 Detail: "opened change", URL: "/" + m.Name + "/changes/" + req.ID,
14b5c648
Keenan Nemetz
9d ago
150 })
151 for _, c := range req.Comments {
f09ef39e
Keenan Nemetz
15d ago
152 events = append(events, activityEvent{
14b5c648
Keenan Nemetz
9d ago
153 Kind: "change", Icon: "message-square", Time: c.Created,
154 Actor: w.who(c.Author), ActorName: c.Author, Title: req.Title,
155 Detail: "commented on change",
ed03b3d1
Keenan Nemetz
2h ago
156 URL: "/" + m.Name + "/changes/" + req.ID + "#c-" + c.ID,
14b5c648
Keenan Nemetz
9d ago
157 })
158 }
159 for _, a := range req.Approvals {
160 events = append(events, activityEvent{
161 Kind: "change", Icon: "check", Time: a.Created,
162 Actor: w.who(a.Principal), ActorName: a.Principal, Title: req.Title,
163 Detail: "approved change",
ed03b3d1
Keenan Nemetz
2h ago
164 URL: "/" + m.Name + "/changes/" + req.ID,
f09ef39e
Keenan Nemetz
15d ago
165 })
166 }
167 }
168 }
169
8e93a8f0
Keenan Nemetz
15d ago
170 // State changes come from the metadata log rather than the current files:
171 // a reopened issue is two events, and the file only knows the latest one.
172 for _, sc := range stateChanges {
173 switch sc.Kind {
174 case "issue":
175 if kind != "" && kind != "issue" {
176 continue
177 }
178 iss := issuesByID[sc.ID]
179 if iss == nil {
180 continue
181 }
182 events = append(events, activityEvent{
183 Kind: "issue", Icon: "circle-dot", Time: sc.Time,
184 Actor: w.who(sc.Actor), ActorName: sc.Actor, Title: iss.Title,
ed03b3d1
Keenan Nemetz
2h ago
185 Detail: issueStateDetail(sc.State), URL: "/" + m.Name + "/issues/" + sc.ID,
8e93a8f0
Keenan Nemetz
15d ago
186 Assignees: iss.Assignees,
187 })
188 case "request":
189 if kind != "" && kind != "change" {
190 continue
191 }
192 req := requestsByID[sc.ID]
193 if req == nil {
194 continue
195 }
196 events = append(events, activityEvent{
197 Kind: "change", Icon: "git-pull-request", Time: sc.Time,
198 Actor: w.who(sc.Actor), ActorName: sc.Actor, Title: req.Title,
ed03b3d1
Keenan Nemetz
2h ago
199 Detail: requestStateDetail(sc.State), URL: "/" + m.Name + "/changes/" + sc.ID,
8e93a8f0
Keenan Nemetz
15d ago
200 })
201 }
202 }
203
f09ef39e
Keenan Nemetz
15d ago
204 if kind == "" || kind == "release" {
205 if tags, err := g.Tags(); err == nil {
074ae160
Keenan Nemetz
14d ago
206 if len(tags) > 0 {
207 lastTag = &tags[0]
208 }
f09ef39e
Keenan Nemetz
15d ago
209 for _, t := range tags {
210 events = append(events, activityEvent{
211 Kind: "release", Icon: "tag", Time: time.Unix(t.When, 0).UTC(),
a6b42433
Keenan Nemetz
7d ago
212 Actor: t.Tagger, Title: t.Name, Detail: "released",
ed03b3d1
Keenan Nemetz
2h ago
213 URL: "/" + m.Name + "/releases/" + t.Name,
f09ef39e
Keenan Nemetz
15d ago
214 })
215 }
216 }
217 }
218
219 sort.SliceStable(events, func(i, j int) bool {
220 return events[i].Time.After(events[j].Time)
221 })
8e93a8f0
Keenan Nemetz
15d ago
222 assignees := activityAssignees(events)
223 if actor != "" || assignee != "" {
224 filtered := events[:0]
225 for _, e := range events {
226 if actor != "" && e.ActorName != actor {
227 continue
228 }
229 if assignee != "" && !slices.Contains(e.Assignees, assignee) {
230 continue
231 }
232 filtered = append(filtered, e)
233 }
234 events = filtered
235 }
c8cdb6ac
Keenan Nemetz
9d ago
236 // The activity pages want the whole feed (they paginate it); the home page
237 // asks for what fits in its rail.
238 if limit > 0 && len(events) > limit {
239 events = events[:limit]
f09ef39e
Keenan Nemetz
15d ago
240 }
074ae160
Keenan Nemetz
14d ago
241 return activityFeed{
242 Events: events, Assignees: assignees,
243 LastCommit: lastCommit, LastTag: lastTag,
244 OpenIssues: openIssues, OpenChanges: openChanges,
245 }
8e93a8f0
Keenan Nemetz
15d ago
246 }
247
14b5c648
Keenan Nemetz
9d ago
248 // activityIssue, activityRequest, activityComment and activityApproval are the
249 // fields the feed needs, whichever store it reads them from: the index for a
250 // read request, git for a server without one.
251 type activityIssue struct {
252 ID, Title, Author string
253 Created time.Time
254 Open bool
255 Assignees []string
256 Comments []activityComment
257 }
258
259 type activityRequest struct {
260 ID, Title, Author string
261 Created time.Time
262 Open bool
263 Comments []activityComment
264 Approvals []activityApproval
265 }
266
267 type activityComment struct {
268 ID, Author string
269 Created time.Time
270 }
271
272 type activityApproval struct {
273 Principal string
274 Created time.Time
275 }
276
277 // activityIssues reads every issue and its comments, from the index when it is
278 // current and from git otherwise. The order is the id order git's tree listing
279 // gives, so equal timestamps sort the same either way.
280 func (w *Web) activityIssues(memo *requestMemo, m *repo.Meta) []activityIssue {
281 if w.Index != nil {
282 if err := w.ensureIndex(memo, m); err == nil {
283 rows, err := w.Index.Issues(m.ID, index.Filter{})
284 if err == nil {
c8cdb6ac
Keenan Nemetz
9d ago
285 comments, err := w.Index.CommentSummaries(m.ID, index.KindIssueComment)
14b5c648
Keenan Nemetz
9d ago
286 if err == nil {
287 byParent := map[string][]activityComment{}
288 for _, c := range comments {
289 byParent[c.Parent] = append(byParent[c.Parent], activityComment{ID: c.ID, Author: c.Author, Created: c.Created})
290 }
291 out := make([]activityIssue, 0, len(rows))
292 for _, row := range rows {
293 out = append(out, activityIssue{
294 ID: row.ID, Title: row.Title, Author: row.Author, Created: row.Created,
295 Open: row.State == "open", Assignees: row.Assignees, Comments: byParent[row.ID],
296 })
297 }
298 sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
299 return out
300 }
301 }
302 }
303 }
304 issues, err := metadata.LoadIssues(m.Git(), metadata.Ref)
305 if err != nil {
306 return nil
307 }
308 out := make([]activityIssue, 0, len(issues))
309 for _, iss := range issues {
310 item := activityIssue{
311 ID: iss.ID, Title: iss.Title, Author: iss.Author, Created: iss.Created,
312 Open: iss.Open(), Assignees: iss.Assignees,
313 }
314 for _, c := range iss.Comments {
315 item.Comments = append(item.Comments, activityComment{ID: c.ID, Author: c.Author, Created: c.Created})
316 }
317 out = append(out, item)
318 }
319 return out
320 }
321
322 // activityRequests is the same for change requests, including the approvals that
323 // are their own events.
324 func (w *Web) activityRequests(memo *requestMemo, m *repo.Meta) []activityRequest {
325 if w.Index != nil {
326 if err := w.ensureIndex(memo, m); err == nil {
327 rows, err := w.Index.Requests(m.ID, index.RequestFilter{})
328 if err == nil {
c8cdb6ac
Keenan Nemetz
9d ago
329 comments, err := w.Index.CommentSummaries(m.ID, index.KindRequestComment)
14b5c648
Keenan Nemetz
9d ago
330 if err == nil {
331 approvals, err := w.Index.AllApprovals(m.ID)
332 if err == nil {
333 byParent := map[string][]activityComment{}
334 for _, c := range comments {
335 byParent[c.Parent] = append(byParent[c.Parent], activityComment{ID: c.ID, Author: c.Author, Created: c.Created})
336 }
337 out := make([]activityRequest, 0, len(rows))
338 for _, row := range rows {
339 item := activityRequest{
340 ID: row.ID, Title: row.Title, Author: row.Author,
341 Created: row.Created, Open: row.State == "open",
342 Comments: byParent[row.ID],
343 }
344 for _, a := range approvals[row.ID] {
345 item.Approvals = append(item.Approvals, activityApproval{Principal: a.Principal, Created: a.Created})
346 }
347 out = append(out, item)
348 }
349 sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
350 return out
351 }
352 }
353 }
354 }
355 }
356 list, err := w.requestsAt(memo, m)
357 if err != nil {
358 return nil
359 }
360 out := make([]activityRequest, 0, len(list))
361 for _, req := range list {
362 item := activityRequest{
363 ID: req.ID, Title: req.Title, Author: req.Author, Created: req.Created,
364 Open: req.Open(),
365 }
366 for _, c := range req.Comments {
367 item.Comments = append(item.Comments, activityComment{ID: c.ID, Author: c.Author, Created: c.Created})
368 }
369 for _, a := range req.Approvals {
370 item.Approvals = append(item.Approvals, activityApproval{Principal: a.Principal, Created: a.Created})
371 }
372 out = append(out, item)
373 }
374 return out
375 }
376
73a732b1
Keenan Nemetz
9d ago
377 // repoActivityURL builds a page link for a repository's feed, keeping the
378 // filters so paging never widens what the reader asked for.
379 func repoActivityURL(name, kind, actor, assignee string) func(int) string {
380 return func(n int) string {
381 v := url.Values{}
382 if kind != "" {
383 v.Set("kind", kind)
384 }
385 if actor != "" {
386 v.Set("actor", actor)
387 }
388 if assignee != "" {
389 v.Set("assignee", assignee)
390 }
391 if n > 1 {
392 v.Set("page", strconv.Itoa(n))
393 }
394 if q := v.Encode(); q != "" {
ed03b3d1
Keenan Nemetz
2h ago
395 return "/" + name + "/activity?" + q
73a732b1
Keenan Nemetz
9d ago
396 }
ed03b3d1
Keenan Nemetz
2h ago
397 return "/" + name + "/activity"
73a732b1
Keenan Nemetz
9d ago
398 }
399 }
400
4d938fe8
Keenan Nemetz
9d ago
401 // activityHistory is how far back the feed looks for state changes. The feed
402 // itself keeps fewer events; this is the window a closed issue has to be seen in.
403 const activityHistory = 200
404
405 // stateChanges reads closed, reopened and merged events from the index for a read
406 // request. A Web without an index, or a failed read, falls back to the metadata
407 // log, which is where the events come from either way.
408 func (w *Web) stateChanges(memo *requestMemo, m *repo.Meta) []metadata.StateChange {
409 if w.Index != nil {
410 if err := w.ensureIndex(memo, m); err == nil {
411 if list, err := w.Index.StateChanges(m.ID, activityHistory); err == nil {
412 return list
413 }
414 }
415 }
416 list, _ := metadata.LoadStateChanges(m.Git(), activityHistory)
417 return list
418 }
419
8e93a8f0
Keenan Nemetz
15d ago
420 func activityAssignees(events []activityEvent) []string {
421 seen := map[string]bool{}
422 var out []string
423 for _, e := range events {
424 for _, a := range e.Assignees {
425 if a != "" && !seen[a] {
426 seen[a] = true
427 out = append(out, a)
428 }
429 }
430 }
431 sort.Strings(out)
432 return out
433 }
434
435 func issueStateDetail(state string) string {
436 if state == "closed" {
437 return "closed issue"
438 }
439 return "reopened issue"
440 }
441
442 func requestStateDetail(state string) string {
443 switch state {
444 case "closed":
b06184d1
Keenan Nemetz
11d ago
445 return "closed change"
8e93a8f0
Keenan Nemetz
15d ago
446 case "merged":
b06184d1
Keenan Nemetz
11d ago
447 return "merged change"
8e93a8f0
Keenan Nemetz
15d ago
448 }
b06184d1
Keenan Nemetz
11d ago
449 return "reopened change"
8e93a8f0
Keenan Nemetz
15d ago
450 }
451
452 func (w *Web) activityActors() []activityActor {
453 if w.Ident == nil {
454 return nil
455 }
456 var out []activityActor
457 for _, acct := range w.Ident.All() {
458 if !acct.Active() {
459 continue
460 }
62576fa8
Keenan Nemetz
14d ago
461 out = append(out, activityActor{Name: acct.Name, Label: acct.Name})
8e93a8f0
Keenan Nemetz
15d ago
462 }
463 return out
f09ef39e
Keenan Nemetz
15d ago
464 }
465
466 // Activity serves the repository activity tab. It is separate from Overview so
ab326d3a
Keenan Nemetz
14d ago
467 // the repository summary and the chronological feed can both exist without either
f09ef39e
Keenan Nemetz
15d ago
468 // becoming a compromise.
469 func (w *Web) Activity(rw http.ResponseWriter, r *http.Request) {
470 m, ok := w.open(rw, r)
471 if !ok {
472 return
473 }
474 kind := normalizeActivityKind(r.URL.Query().Get("kind"))
8e93a8f0
Keenan Nemetz
15d ago
475 actor := strings.TrimSpace(r.URL.Query().Get("actor"))
476 assignee := strings.TrimSpace(r.URL.Query().Get("assignee"))
c8cdb6ac
Keenan Nemetz
9d ago
477 number, _ := strconv.Atoi(r.URL.Query().Get("page"))
73a732b1
Keenan Nemetz
9d ago
478 urlFor := repoActivityURL(m.Name, kind, actor, assignee)
479
480 var events []activityEvent
481 var pages pager
482 var assignees []string
483 if w.Index != nil {
484 if err := w.ensureIndex(memoOf(r), m); err != nil {
485 w.serverError(rw, r, "cannot read activity: "+err.Error())
486 return
c8cdb6ac
Keenan Nemetz
9d ago
487 }
73a732b1
Keenan Nemetz
9d ago
488 var err error
489 events, pages, err = w.activityPage(
bca9ec24
Keenan Nemetz
9d ago
490 []string{m.ID}, map[string]string{m.ID: m.Name},
73a732b1
Keenan Nemetz
9d ago
491 kind, actor, assignee, false, number, activityPage, urlFor, "Activity pages")
492 if err != nil {
493 w.serverError(rw, r, "cannot read activity: "+err.Error())
494 return
c8cdb6ac
Keenan Nemetz
9d ago
495 }
73a732b1
Keenan Nemetz
9d ago
496 assignees, _ = w.Index.Assignees(m.ID)
497 } else {
498 // A Web without an index (a test, or a CLI-shaped Web) builds the feed
499 // in memory, as every page used to.
500 feed := w.repoActivity(memoOf(r), m, kind, actor, assignee, 0)
501 events, pages = paginate(feed.Events, number, activityPage, urlFor, "Activity pages")
502 assignees = feed.Assignees
503 }
c8cdb6ac
Keenan Nemetz
9d ago
504 pages.Target = "#activity-feed"
8e93a8f0
Keenan Nemetz
15d ago
505 view := activityView{
f09ef39e
Keenan Nemetz
15d ago
506 Page: w.page(r, m.Name+" · activity"), Name: m.Name, Kind: kind,
8e93a8f0
Keenan Nemetz
15d ago
507 Actor: actor, Assignee: assignee,
73a732b1
Keenan Nemetz
9d ago
508 Actors: w.activityActors(), Assignees: assignees, Events: events, Pager: pages,
f09ef39e
Keenan Nemetz
15d ago
509 }
510 if htmxRequest(r) {
511 w.render(rw, "activity_feed", view, 0)
512 return
513 }
514 w.render(rw, "activity", view, 0)
515 }