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/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

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

Render issues, changes and the activity feed from the index

14b5c648a04090d0e718f5920f6abfc2d14c7274 · Keenan Nemetz · 10d ago · parent 9d8dd1bc

Render issues, changes and the activity feed from the index

Stage 3: the pages that still read the metadata branch now read the rows
the request already refreshed.

- The issue page reads its issue, comments, labels, assignees and number
  from the index. Its one remaining git read is the backlink search
  (`log --grep`), which is a content search, not a record lookup.
- A change page reads its one request from the index for a GET. A write
  request still reads git: it has no memo because it may have just
  changed the record, and it must not be answered by the version it
  replaced.
- The activity feed builds its issue, change, comment and approval events
  from the index. It keeps the metadata log for state changes (a reopened
  issue is two events and the file only knows the last), the latest
  commit, and tags: the remaining git calls are content, not duplicates.

Git calls per page on the fixture (40 files, 30 issues, 30 changes):

  issue view     7 -> 3
  change view   12 -> 9
  activity      whole-tree reads -> one log, one commit, one tag listing

The change page's remaining calls are the diff and the merge check; the
issue page's are the backlink search.

server/web/activity.go +175−46

@@ -15,6 +15,7 @@ import (
15 15 "time"
16 16
17 17 "github.com/rodent-software/vole/core/git"
18 "github.com/rodent-software/vole/forge/index"
18 19 "github.com/rodent-software/vole/forge/repo"
19 20 "github.com/rodent-software/vole/metadata"
20 21 )
@@ -89,8 +90,8 @@ func (w *Web) repoActivity(memo *requestMemo, m *repo.Meta, kind, actor, assigne
89 90 if kind == "" || kind == "issue" || kind == "change" {
90 91 stateChanges, _ = metadata.LoadStateChanges(g, 200)
91 92 }
92 issuesByID := map[string]*metadata.Issue{}
93 requestsByID := map[string]*metadata.Request{}
93 issuesByID := map[string]*activityIssue{}
94 requestsByID := map[string]*activityRequest{}
94 95
95 96 // The landing page card still needs the head commit; commits themselves are
96 97 // not feed events, so only the newest one is read.
@@ -103,60 +104,59 @@ func (w *Web) repoActivity(memo *requestMemo, m *repo.Meta, kind, actor, assigne
103 104 }
104 105
105 106 if kind == "" || kind == "issue" {
106 if issues, err := metadata.LoadIssues(g, metadata.Ref); err == nil {
107 for _, iss := range issues {
108 if iss.Open() {
109 openIssues++
110 }
111 issuesByID[iss.ID] = iss
112 actor := w.who(iss.Author)
107 issues := w.activityIssues(memo, m)
108 for i := range issues {
109 iss := &issues[i]
110 if iss.Open {
111 openIssues++
112 }
113 issuesByID[iss.ID] = iss
114 events = append(events, activityEvent{
115 Kind: "issue", Icon: "circle-dot", Time: iss.Created,
116 Actor: w.who(iss.Author), ActorName: iss.Author, Title: iss.Title,
117 Detail: "opened issue", URL: "/r/" + m.Name + "/issues/" + iss.ID,
118 Assignees: iss.Assignees,
119 })
120 for _, c := range iss.Comments {
113 121 events = append(events, activityEvent{
114 Kind: "issue", Icon: "circle-dot", Time: iss.Created,
115 Actor: actor, ActorName: iss.Author, Title: iss.Title,
116 Detail: "opened issue", URL: "/r/" + m.Name + "/issues/" + iss.ID,
122 Kind: "issue", Icon: "message-square", Time: c.Created,
123 Actor: w.who(c.Author), ActorName: c.Author, Title: iss.Title,
124 Detail: "commented on issue",
125 URL: "/r/" + m.Name + "/issues/" + iss.ID + "#c-" + c.ID,
117 126 Assignees: iss.Assignees,
118 127 })
119 for _, c := range iss.Comments {
120 events = append(events, activityEvent{
121 Kind: "issue", Icon: "message-square", Time: c.Created,
122 Actor: w.who(c.Author), ActorName: c.Author, Title: iss.Title,
123 Detail: "commented on issue",
124 URL: "/r/" + m.Name + "/issues/" + iss.ID + "#c-" + c.ID,
125 Assignees: iss.Assignees,
126 })
127 }
128 128 }
129 129 }
130 130 }
131 131
132 132 if kind == "" || kind == "change" {
133 if requests, err := w.requestsAt(memo, m); err == nil {
134 for _, req := range requests {
135 if req.Open() {
136 openChanges++
137 }
138 requestsByID[req.ID] = req
133 requests := w.activityRequests(memo, m)
134 for i := range requests {
135 req := &requests[i]
136 if req.Open {
137 openChanges++
138 }
139 requestsByID[req.ID] = req
140 events = append(events, activityEvent{
141 Kind: "change", Icon: "git-pull-request", Time: req.Created,
142 Actor: w.who(req.Author), ActorName: req.Author, Title: req.Title,
143 Detail: "opened change", URL: "/r/" + m.Name + "/requests/" + req.ID,
144 })
145 for _, c := range req.Comments {
139 146 events = append(events, activityEvent{
140 Kind: "change", Icon: "git-pull-request", Time: req.Created,
141 Actor: w.who(req.Author), ActorName: req.Author, Title: req.Title,
142 Detail: "opened change", URL: "/r/" + m.Name + "/requests/" + req.ID,
147 Kind: "change", Icon: "message-square", Time: c.Created,
148 Actor: w.who(c.Author), ActorName: c.Author, Title: req.Title,
149 Detail: "commented on change",
150 URL: "/r/" + m.Name + "/requests/" + req.ID + "#c-" + c.ID,
151 })
152 }
153 for _, a := range req.Approvals {
154 events = append(events, activityEvent{
155 Kind: "change", Icon: "check", Time: a.Created,
156 Actor: w.who(a.Principal), ActorName: a.Principal, Title: req.Title,
157 Detail: "approved change",
158 URL: "/r/" + m.Name + "/requests/" + req.ID,
143 159 })
144 for _, c := range req.Comments {
145 events = append(events, activityEvent{
146 Kind: "change", Icon: "message-square", Time: c.Created,
147 Actor: w.who(c.Author), ActorName: c.Author, Title: req.Title,
148 Detail: "commented on change",
149 URL: "/r/" + m.Name + "/requests/" + req.ID + "#c-" + c.ID,
150 })
151 }
152 for _, a := range req.Approvals {
153 events = append(events, activityEvent{
154 Kind: "change", Icon: "check", Time: a.Created,
155 Actor: w.who(a.Principal), ActorName: a.Principal, Title: req.Title,
156 Detail: "approved change",
157 URL: "/r/" + m.Name + "/requests/" + req.ID,
158 })
159 }
160 160 }
161 161 }
162 162 }
@@ -237,6 +237,135 @@ func (w *Web) repoActivity(memo *requestMemo, m *repo.Meta, kind, actor, assigne
237 237 }
238 238 }
239 239
240 // activityIssue, activityRequest, activityComment and activityApproval are the
241 // fields the feed needs, whichever store it reads them from: the index for a
242 // read request, git for a server without one.
243 type activityIssue struct {
244 ID, Title, Author string
245 Created time.Time
246 Open bool
247 Assignees []string
248 Comments []activityComment
249 }
250
251 type activityRequest struct {
252 ID, Title, Author string
253 Created time.Time
254 Open bool
255 Comments []activityComment
256 Approvals []activityApproval
257 }
258
259 type activityComment struct {
260 ID, Author string
261 Created time.Time
262 }
263
264 type activityApproval struct {
265 Principal string
266 Created time.Time
267 }
268
269 // activityIssues reads every issue and its comments, from the index when it is
270 // current and from git otherwise. The order is the id order git's tree listing
271 // gives, so equal timestamps sort the same either way.
272 func (w *Web) activityIssues(memo *requestMemo, m *repo.Meta) []activityIssue {
273 if w.Index != nil {
274 if err := w.ensureIndex(memo, m); err == nil {
275 rows, err := w.Index.Issues(m.ID, index.Filter{})
276 if err == nil {
277 comments, err := w.Index.CommentsForRepo(m.ID, index.KindIssueComment)
278 if err == nil {
279 byParent := map[string][]activityComment{}
280 for _, c := range comments {
281 byParent[c.Parent] = append(byParent[c.Parent], activityComment{ID: c.ID, Author: c.Author, Created: c.Created})
282 }
283 out := make([]activityIssue, 0, len(rows))
284 for _, row := range rows {
285 out = append(out, activityIssue{
286 ID: row.ID, Title: row.Title, Author: row.Author, Created: row.Created,
287 Open: row.State == "open", Assignees: row.Assignees, Comments: byParent[row.ID],
288 })
289 }
290 sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
291 return out
292 }
293 }
294 }
295 }
296 issues, err := metadata.LoadIssues(m.Git(), metadata.Ref)
297 if err != nil {
298 return nil
299 }
300 out := make([]activityIssue, 0, len(issues))
301 for _, iss := range issues {
302 item := activityIssue{
303 ID: iss.ID, Title: iss.Title, Author: iss.Author, Created: iss.Created,
304 Open: iss.Open(), Assignees: iss.Assignees,
305 }
306 for _, c := range iss.Comments {
307 item.Comments = append(item.Comments, activityComment{ID: c.ID, Author: c.Author, Created: c.Created})
308 }
309 out = append(out, item)
310 }
311 return out
312 }
313
314 // activityRequests is the same for change requests, including the approvals that
315 // are their own events.
316 func (w *Web) activityRequests(memo *requestMemo, m *repo.Meta) []activityRequest {
317 if w.Index != nil {
318 if err := w.ensureIndex(memo, m); err == nil {
319 rows, err := w.Index.Requests(m.ID, index.RequestFilter{})
320 if err == nil {
321 comments, err := w.Index.CommentsForRepo(m.ID, index.KindRequestComment)
322 if err == nil {
323 approvals, err := w.Index.AllApprovals(m.ID)
324 if err == nil {
325 byParent := map[string][]activityComment{}
326 for _, c := range comments {
327 byParent[c.Parent] = append(byParent[c.Parent], activityComment{ID: c.ID, Author: c.Author, Created: c.Created})
328 }
329 out := make([]activityRequest, 0, len(rows))
330 for _, row := range rows {
331 item := activityRequest{
332 ID: row.ID, Title: row.Title, Author: row.Author,
333 Created: row.Created, Open: row.State == "open",
334 Comments: byParent[row.ID],
335 }
336 for _, a := range approvals[row.ID] {
337 item.Approvals = append(item.Approvals, activityApproval{Principal: a.Principal, Created: a.Created})
338 }
339 out = append(out, item)
340 }
341 sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
342 return out
343 }
344 }
345 }
346 }
347 }
348 list, err := w.requestsAt(memo, m)
349 if err != nil {
350 return nil
351 }
352 out := make([]activityRequest, 0, len(list))
353 for _, req := range list {
354 item := activityRequest{
355 ID: req.ID, Title: req.Title, Author: req.Author, Created: req.Created,
356 Open: req.Open(),
357 }
358 for _, c := range req.Comments {
359 item.Comments = append(item.Comments, activityComment{ID: c.ID, Author: c.Author, Created: c.Created})
360 }
361 for _, a := range req.Approvals {
362 item.Approvals = append(item.Approvals, activityApproval{Principal: a.Principal, Created: a.Created})
363 }
364 out = append(out, item)
365 }
366 return out
367 }
368
240 369 func activityAssignees(events []activityEvent) []string {
241 370 seen := map[string]bool{}
242 371 var out []string