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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 23m ago 4b8c9e1d
history blame raw 15.1 KiB
1package web
2
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.
9
10import (
11 "net/http"
12 "net/url"
13 "slices"
14 "sort"
15 "strconv"
16 "strings"
17 "time"
18
19 "rodent.software/vole/core/git"
20 "rodent.software/vole/forge/index"
21 "rodent.software/vole/forge/repo"
22 "rodent.software/vole/metadata"
23)
24
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.
28const activityPage = 50
29
30type activityEvent struct {
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
39 // Assignees is kept on issue events so a filter can narrow the feed to
40 // "things assigned to me" without changing the event source.
41 Assignees []string
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
45}
46
47type activityActor struct {
48 Name string
49 Label string
50}
51
52type activityFeed struct {
53 Events []activityEvent
54 Assignees []string
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
62}
63
64type 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
73 Pager pager
74}
75
76func 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
88func (w *Web) repoActivity(memo *requestMemo, m *repo.Meta, kind, actor, assignee string, limit int) activityFeed {
89 g := m.Git()
90 var events []activityEvent
91 var lastCommit *git.Commit
92 var lastTag *git.Tag
93 openIssues, openChanges := 0, 0
94
95 var stateChanges []metadata.StateChange
96 if kind == "" || kind == "issue" || kind == "change" {
97 stateChanges = w.stateChanges(memo, m)
98 }
99 issuesByID := map[string]*activityIssue{}
100 requestsByID := map[string]*activityRequest{}
101
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 == "" {
105 if commit, _ := g.RevParse(m.DefaultBranch); commit != "" {
106 if commits, err := g.Log(commit, 1); err == nil && len(commits) > 0 {
107 lastCommit = &commits[0]
108 }
109 }
110 }
111
112 if kind == "" || kind == "issue" {
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,
123 Detail: "opened issue", URL: "/" + m.Name + "/issues/" + iss.ID,
124 Assignees: iss.Assignees,
125 })
126 for _, c := range iss.Comments {
127 events = append(events, activityEvent{
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",
131 URL: "/" + m.Name + "/issues/" + iss.ID + "#c-" + c.ID,
132 Assignees: iss.Assignees,
133 })
134 }
135 }
136 }
137
138 if kind == "" || kind == "change" {
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,
149 Detail: "opened change", URL: "/" + m.Name + "/changes/" + req.ID,
150 })
151 for _, c := range req.Comments {
152 events = append(events, activityEvent{
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",
156 URL: "/" + m.Name + "/changes/" + req.ID + "#c-" + c.ID,
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",
164 URL: "/" + m.Name + "/changes/" + req.ID,
165 })
166 }
167 }
168 }
169
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,
185 Detail: issueStateDetail(sc.State), URL: "/" + m.Name + "/issues/" + sc.ID,
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,
199 Detail: requestStateDetail(sc.State), URL: "/" + m.Name + "/changes/" + sc.ID,
200 })
201 }
202 }
203
204 if kind == "" || kind == "release" {
205 if tags, err := g.Tags(); err == nil {
206 if len(tags) > 0 {
207 lastTag = &tags[0]
208 }
209 for _, t := range tags {
210 events = append(events, activityEvent{
211 Kind: "release", Icon: "tag", Time: time.Unix(t.When, 0).UTC(),
212 Actor: t.Tagger, Title: t.Name, Detail: "released",
213 URL: "/" + m.Name + "/releases/" + t.Name,
214 })
215 }
216 }
217 }
218
219 sort.SliceStable(events, func(i, j int) bool {
220 return events[i].Time.After(events[j].Time)
221 })
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 }
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]
240 }
241 return activityFeed{
242 Events: events, Assignees: assignees,
243 LastCommit: lastCommit, LastTag: lastTag,
244 OpenIssues: openIssues, OpenChanges: openChanges,
245 }
246}
247
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.
251type activityIssue struct {
252 ID, Title, Author string
253 Created time.Time
254 Open bool
255 Assignees []string
256 Comments []activityComment
257}
258
259type activityRequest struct {
260 ID, Title, Author string
261 Created time.Time
262 Open bool
263 Comments []activityComment
264 Approvals []activityApproval
265}
266
267type activityComment struct {
268 ID, Author string
269 Created time.Time
270}
271
272type 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.
280func (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 {
285 comments, err := w.Index.CommentSummaries(m.ID, index.KindIssueComment)
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.
324func (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 {
329 comments, err := w.Index.CommentSummaries(m.ID, index.KindRequestComment)
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
377// repoActivityURL builds a page link for a repository's feed, keeping the
378// filters so paging never widens what the reader asked for.
379func 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 != "" {
395 return "/" + name + "/activity?" + q
396 }
397 return "/" + name + "/activity"
398 }
399}
400
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.
403const 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.
408func (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
420func 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
435func issueStateDetail(state string) string {
436 if state == "closed" {
437 return "closed issue"
438 }
439 return "reopened issue"
440}
441
442func requestStateDetail(state string) string {
443 switch state {
444 case "closed":
445 return "closed change"
446 case "merged":
447 return "merged change"
448 }
449 return "reopened change"
450}
451
452func (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 }
461 out = append(out, activityActor{Name: acct.Name, Label: acct.Name})
462 }
463 return out
464}
465
466// Activity serves the repository activity tab. It is separate from Overview so
467// the repository summary and the chronological feed can both exist without either
468// becoming a compromise.
469func (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"))
475 actor := strings.TrimSpace(r.URL.Query().Get("actor"))
476 assignee := strings.TrimSpace(r.URL.Query().Get("assignee"))
477 number, _ := strconv.Atoi(r.URL.Query().Get("page"))
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
487 }
488 var err error
489 events, pages, err = w.activityPage(
490 []string{m.ID}, map[string]string{m.ID: m.Name},
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
495 }
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 }
504 pages.Target = "#activity-feed"
505 view := activityView{
506 Page: w.page(r, m.Name+" · activity"), Name: m.Name, Kind: kind,
507 Actor: actor, Assignee: assignee,
508 Actors: w.activityActors(), Assignees: assignees, Events: events, Pager: pages,
509 }
510 if htmxRequest(r) {
511 w.render(rw, "activity_feed", view, 0)
512 return
513 }
514 w.render(rw, "activity", view, 0)
515}
516