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