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.
| 1 | package 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 | |
| 10 | import ( |
| 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. |
| 28 | const activityPage = 50 |
| 29 | |
| 30 | type 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 | |
| 47 | type activityActor struct { |
| 48 | Name string |
| 49 | Label string |
| 50 | } |
| 51 | |
| 52 | type 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 | |
| 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 |
| 73 | Pager pager |
| 74 | } |
| 75 | |
| 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 | |
| 88 | func (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. |
| 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 { |
| 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. |
| 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 { |
| 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. |
| 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 != "" { |
| 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. |
| 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 | |
| 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": |
| 445 | return "closed change" |
| 446 | case "merged": |
| 447 | return "merged change" |
| 448 | } |
| 449 | return "reopened change" |
| 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 | } |
| 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. |
| 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")) |
| 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 |