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.
Build a landing page and simplify the forge chrome
074ae1605e177e99533af02d8c69ccd1a6a947bb · Keenan Nemetz · 14d ago · parent 62576fa8
Build a landing page and simplify the forge chrome The instance root is now a landing page rather than the full activity feed: forge identity, project cards with top language, last commit, issue/change counts and latest release, a short activity rail, and a first-run empty state. The full feed moves to /activity with type filters and pagination. Feeds now cover issues, changes, comments, and releases only; commits and CI runs already have their own pages. Configuration gains instance_name and instance_tagline. Navigation: the crowded global bar becomes Runners, Activity, search, and an account menu holding Admin, Status, and Log out. Repository pages show the name and description above the tabs at the home-page header scale, while tab-page titles stay small. Top-level pages keep the larger header. Drop the workflow run chip, assigned-issues strip, repository size stat, and footer Status link.
server/web/activity.go +44−51
| @@ -1,10 +1,11 @@ | |||
| 1 | 1 | package web | |
| 2 | 2 | ||
| 3 | // The repository activity feed. It is derived, never stored: commits come from | ||
| 4 | // git, issues and changes from the metadata branch, releases from tags, and runs | ||
| 5 | // from the queue. That means there is no notification table or unread state to | ||
| 6 | // keep in sync, and a private repository's feed follows the same read rule as | ||
| 7 | // every other page. | ||
| 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. | ||
| 8 | 9 | ||
| 9 | 10 | import ( | |
| 10 | 11 | "net/http" | |
| @@ -13,6 +14,7 @@ import ( | |||
| 13 | 14 | "strings" | |
| 14 | 15 | "time" | |
| 15 | 16 | ||
| 17 | "github.com/rodent-software/vole/core/git" | ||
| 16 | 18 | "github.com/rodent-software/vole/forge/repo" | |
| 17 | 19 | "github.com/rodent-software/vole/metadata" | |
| 18 | 20 | ) | |
| @@ -20,16 +22,14 @@ import ( | |||
| 20 | 22 | const activityLimit = 100 | |
| 21 | 23 | ||
| 22 | 24 | type activityEvent struct { | |
| 23 | Kind string | ||
| 24 | Icon string | ||
| 25 | Time time.Time | ||
| 26 | Actor string | ||
| 27 | ActorName string | ||
| 28 | Title string | ||
| 29 | Detail string | ||
| 30 | URL string | ||
| 31 | Badge string | ||
| 32 | BadgeClass string | ||
| 25 | Kind string | ||
| 26 | Icon string | ||
| 27 | Time time.Time | ||
| 28 | Actor string | ||
| 29 | ActorName string | ||
| 30 | Title string | ||
| 31 | Detail string | ||
| 32 | URL string | ||
| 33 | 33 | // Assignees is kept on issue events so a filter can narrow the feed to | |
| 34 | 34 | // "things assigned to me" without changing the event source. | |
| 35 | 35 | Assignees []string | |
| @@ -46,6 +46,13 @@ type activityActor struct { | |||
| 46 | 46 | type activityFeed struct { | |
| 47 | 47 | Events []activityEvent | |
| 48 | 48 | Assignees []string | |
| 49 | // Summary fields describe the repository itself rather than the feed. The | ||
| 50 | // landing page builds its cards from the same read, so listing a repository | ||
| 51 | // costs one pass instead of one for the card and another for the events. | ||
| 52 | LastCommit *git.Commit | ||
| 53 | LastTag *git.Tag | ||
| 54 | OpenIssues int | ||
| 55 | OpenChanges int | ||
| 49 | 56 | } | |
| 50 | 57 | ||
| 51 | 58 | type activityView struct { | |
| @@ -61,16 +68,12 @@ type activityView struct { | |||
| 61 | 68 | ||
| 62 | 69 | func normalizeActivityKind(kind string) string { | |
| 63 | 70 | switch strings.ToLower(strings.TrimSpace(kind)) { | |
| 64 | case "commit", "commits": | ||
| 65 | return "commit" | ||
| 66 | 71 | case "issue", "issues": | |
| 67 | 72 | return "issue" | |
| 68 | 73 | case "change", "changes": | |
| 69 | 74 | return "change" | |
| 70 | 75 | case "release", "releases": | |
| 71 | 76 | return "release" | |
| 72 | case "run", "runs": | ||
| 73 | return "run" | ||
| 74 | 77 | } | |
| 75 | 78 | return "" | |
| 76 | 79 | } | |
| @@ -78,6 +81,9 @@ func normalizeActivityKind(kind string) string { | |||
| 78 | 81 | func (w *Web) repoActivity(m *repo.Meta, kind, actor, assignee string) activityFeed { | |
| 79 | 82 | g := m.Git() | |
| 80 | 83 | var events []activityEvent | |
| 84 | var lastCommit *git.Commit | ||
| 85 | var lastTag *git.Tag | ||
| 86 | openIssues, openChanges := 0, 0 | ||
| 81 | 87 | ||
| 82 | 88 | var stateChanges []metadata.StateChange | |
| 83 | 89 | if kind == "" || kind == "issue" || kind == "change" { | |
| @@ -86,17 +92,12 @@ func (w *Web) repoActivity(m *repo.Meta, kind, actor, assignee string) activityF | |||
| 86 | 92 | issuesByID := map[string]*metadata.Issue{} | |
| 87 | 93 | requestsByID := map[string]*metadata.Request{} | |
| 88 | 94 | ||
| 89 | if kind == "" || kind == "commit" { | ||
| 95 | // The landing page card still needs the head commit; commits themselves are | ||
| 96 | // not feed events, so only the newest one is read. | ||
| 97 | if kind == "" { | ||
| 90 | 98 | if commit, _ := g.RevParse(m.DefaultBranch); commit != "" { | |
| 91 | if commits, err := g.Log(commit, 20); err == nil { | ||
| 92 | for _, c := range commits { | ||
| 93 | events = append(events, activityEvent{ | ||
| 94 | Kind: "commit", Icon: "git-commit-horizontal", | ||
| 95 | Time: time.Unix(c.When, 0).UTC(), Actor: c.Author, ActorName: c.Author, | ||
| 96 | Title: c.Subject, Detail: "committed", | ||
| 97 | URL: "/r/" + m.Name + "/commit/" + c.SHA, | ||
| 98 | }) | ||
| 99 | } | ||
| 99 | if commits, err := g.Log(commit, 1); err == nil && len(commits) > 0 { | ||
| 100 | lastCommit = &commits[0] | ||
| 100 | 101 | } | |
| 101 | 102 | } | |
| 102 | 103 | } | |
| @@ -104,6 +105,9 @@ func (w *Web) repoActivity(m *repo.Meta, kind, actor, assignee string) activityF | |||
| 104 | 105 | if kind == "" || kind == "issue" { | |
| 105 | 106 | if issues, err := metadata.LoadIssues(g, metadata.Ref); err == nil { | |
| 106 | 107 | for _, iss := range issues { | |
| 108 | if iss.Open() { | ||
| 109 | openIssues++ | ||
| 110 | } | ||
| 107 | 111 | issuesByID[iss.ID] = iss | |
| 108 | 112 | actor := w.who(iss.Author) | |
| 109 | 113 | events = append(events, activityEvent{ | |
| @@ -128,6 +132,9 @@ func (w *Web) repoActivity(m *repo.Meta, kind, actor, assignee string) activityF | |||
| 128 | 132 | if kind == "" || kind == "change" { | |
| 129 | 133 | if requests, err := metadata.LoadRequests(g, metadata.Ref); err == nil { | |
| 130 | 134 | for _, req := range requests { | |
| 135 | if req.Open() { | ||
| 136 | openChanges++ | ||
| 137 | } | ||
| 131 | 138 | requestsByID[req.ID] = req | |
| 132 | 139 | events = append(events, activityEvent{ | |
| 133 | 140 | Kind: "change", Icon: "git-pull-request", Time: req.Created, | |
| @@ -190,6 +197,9 @@ func (w *Web) repoActivity(m *repo.Meta, kind, actor, assignee string) activityF | |||
| 190 | 197 | ||
| 191 | 198 | if kind == "" || kind == "release" { | |
| 192 | 199 | if tags, err := g.Tags(); err == nil { | |
| 200 | if len(tags) > 0 { | ||
| 201 | lastTag = &tags[0] | ||
| 202 | } | ||
| 193 | 203 | for _, t := range tags { | |
| 194 | 204 | events = append(events, activityEvent{ | |
| 195 | 205 | Kind: "release", Icon: "tag", Time: time.Unix(t.When, 0).UTC(), | |
| @@ -200,27 +210,6 @@ func (w *Web) repoActivity(m *repo.Meta, kind, actor, assignee string) activityF | |||
| 200 | 210 | } | |
| 201 | 211 | } | |
| 202 | 212 | ||
| 203 | if (kind == "" || kind == "run") && w.Runs != nil { | ||
| 204 | if runs, err := w.Runs.Recent(m.Name, 20); err == nil { | ||
| 205 | for _, run := range runs { | ||
| 206 | at := run.Finished | ||
| 207 | if at.IsZero() { | ||
| 208 | at = run.Created | ||
| 209 | } | ||
| 210 | ref := run.Branch | ||
| 211 | if run.Tag != "" { | ||
| 212 | ref = "tag " + run.Tag | ||
| 213 | } | ||
| 214 | events = append(events, activityEvent{ | ||
| 215 | Kind: "run", Icon: "play", Time: at, | ||
| 216 | Title: run.Job, Detail: run.Workflow + " · " + ref, | ||
| 217 | URL: "/r/" + m.Name + "/runs/" + run.ID, | ||
| 218 | Badge: run.Status, BadgeClass: run.Status, | ||
| 219 | }) | ||
| 220 | } | ||
| 221 | } | ||
| 222 | } | ||
| 223 | |||
| 224 | 213 | sort.SliceStable(events, func(i, j int) bool { | |
| 225 | 214 | return events[i].Time.After(events[j].Time) | |
| 226 | 215 | }) | |
| @@ -241,7 +230,11 @@ func (w *Web) repoActivity(m *repo.Meta, kind, actor, assignee string) activityF | |||
| 241 | 230 | if len(events) > activityLimit { | |
| 242 | 231 | events = events[:activityLimit] | |
| 243 | 232 | } | |
| 244 | return activityFeed{Events: events, Assignees: assignees} | ||
| 233 | return activityFeed{ | ||
| 234 | Events: events, Assignees: assignees, | ||
| 235 | LastCommit: lastCommit, LastTag: lastTag, | ||
| 236 | OpenIssues: openIssues, OpenChanges: openChanges, | ||
| 237 | } | ||
| 245 | 238 | } | |
| 246 | 239 | ||
| 247 | 240 | func activityAssignees(events []activityEvent) []string { | |