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