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.
Build out the forge web UI
f09ef39efe44de693748053a7b596ef328fcf06b · Keenan Nemetz · 15d ago · parent 70b70264
Build out the forge web UI Turn the web surface into a compact, server-rendered forge with HTMX as the progressive dynamic layer. - Split templates and assets into embedded files; add a tokenised dark theme, Lucide icons, and vendored HTMX. - Add the repository Overview page and a separate chronological Activity feed with type filters and HTMX fragment updates. - Finish the issue and change workflows: create pages, comments, close/reopen, labels and assignees, approval, discussion, line-anchored review comments, and HTMX list filters. - Add syntax-highlighted blobs, contributor/statistics derivation, branch-aware code browsing, and a repository Runs list. - Add a loopback-only development login for local write testing, with explicit configuration and listener checks. - Extend tests for the new pages, fragments, labels, assignees, anchors, and development login.
server/web/activity.go +193
| @@ -0,0 +1,193 @@ | |||
| 1 | package web | ||
| 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. | ||
| 8 | |||
| 9 | import ( | ||
| 10 | "net/http" | ||
| 11 | "sort" | ||
| 12 | "strings" | ||
| 13 | "time" | ||
| 14 | |||
| 15 | "github.com/rodent-software/vole/forge/repo" | ||
| 16 | "github.com/rodent-software/vole/metadata" | ||
| 17 | ) | ||
| 18 | |||
| 19 | const activityLimit = 100 | ||
| 20 | |||
| 21 | type activityEvent struct { | ||
| 22 | Kind string | ||
| 23 | Icon string | ||
| 24 | Time time.Time | ||
| 25 | Actor string | ||
| 26 | ActorName string | ||
| 27 | Title string | ||
| 28 | Detail string | ||
| 29 | URL string | ||
| 30 | Badge string | ||
| 31 | BadgeClass string | ||
| 32 | // Assignees is kept on issue events so a later filter can narrow the feed to | ||
| 33 | // "things assigned to me" without changing the event source. | ||
| 34 | Assignees []string | ||
| 35 | } | ||
| 36 | |||
| 37 | func normalizeActivityKind(kind string) string { | ||
| 38 | switch strings.ToLower(strings.TrimSpace(kind)) { | ||
| 39 | case "commit", "commits": | ||
| 40 | return "commit" | ||
| 41 | case "issue", "issues": | ||
| 42 | return "issue" | ||
| 43 | case "change", "changes": | ||
| 44 | return "change" | ||
| 45 | case "release", "releases": | ||
| 46 | return "release" | ||
| 47 | case "run", "runs": | ||
| 48 | return "run" | ||
| 49 | } | ||
| 50 | return "" | ||
| 51 | } | ||
| 52 | |||
| 53 | func (w *Web) repoActivity(m *repo.Meta, kind string) []activityEvent { | ||
| 54 | g := m.Git() | ||
| 55 | var events []activityEvent | ||
| 56 | |||
| 57 | if kind == "" || kind == "commit" { | ||
| 58 | if commit, _ := g.RevParse(m.DefaultBranch); commit != "" { | ||
| 59 | if commits, err := g.Log(commit, 20); err == nil { | ||
| 60 | for _, c := range commits { | ||
| 61 | events = append(events, activityEvent{ | ||
| 62 | Kind: "commit", Icon: "git-commit-horizontal", | ||
| 63 | Time: time.Unix(c.When, 0).UTC(), Actor: c.Author, | ||
| 64 | Title: c.Subject, Detail: "committed", | ||
| 65 | URL: "/r/" + m.Name + "/commit/" + c.SHA, | ||
| 66 | }) | ||
| 67 | } | ||
| 68 | } | ||
| 69 | } | ||
| 70 | } | ||
| 71 | |||
| 72 | if kind == "" || kind == "issue" { | ||
| 73 | if issues, err := metadata.LoadIssues(g, metadata.Ref); err == nil { | ||
| 74 | for _, iss := range issues { | ||
| 75 | actor := w.who(iss.Author) | ||
| 76 | events = append(events, activityEvent{ | ||
| 77 | Kind: "issue", Icon: "circle-dot", Time: iss.Created, | ||
| 78 | Actor: actor, ActorName: iss.Author, Title: iss.Title, | ||
| 79 | Detail: "opened issue", URL: "/r/" + m.Name + "/issues/" + iss.ID, | ||
| 80 | Assignees: iss.Assignees, | ||
| 81 | }) | ||
| 82 | for _, c := range iss.Comments { | ||
| 83 | events = append(events, activityEvent{ | ||
| 84 | Kind: "issue", Icon: "message-square", Time: c.Created, | ||
| 85 | Actor: w.who(c.Author), ActorName: c.Author, Title: iss.Title, | ||
| 86 | Detail: "commented on issue", | ||
| 87 | URL: "/r/" + m.Name + "/issues/" + iss.ID + "#c-" + c.ID, | ||
| 88 | Assignees: iss.Assignees, | ||
| 89 | }) | ||
| 90 | } | ||
| 91 | } | ||
| 92 | } | ||
| 93 | } | ||
| 94 | |||
| 95 | if kind == "" || kind == "change" { | ||
| 96 | if requests, err := metadata.LoadRequests(g, metadata.Ref); err == nil { | ||
| 97 | for _, req := range requests { | ||
| 98 | events = append(events, activityEvent{ | ||
| 99 | Kind: "change", Icon: "git-pull-request", Time: req.Created, | ||
| 100 | Actor: w.who(req.Author), ActorName: req.Author, Title: req.Title, | ||
| 101 | Detail: "opened change", URL: "/r/" + m.Name + "/requests/" + req.ID, | ||
| 102 | }) | ||
| 103 | for _, c := range req.Comments { | ||
| 104 | events = append(events, activityEvent{ | ||
| 105 | Kind: "change", Icon: "message-square", Time: c.Created, | ||
| 106 | Actor: w.who(c.Author), ActorName: c.Author, Title: req.Title, | ||
| 107 | Detail: "commented on change", | ||
| 108 | URL: "/r/" + m.Name + "/requests/" + req.ID + "#c-" + c.ID, | ||
| 109 | }) | ||
| 110 | } | ||
| 111 | for _, a := range req.Approvals { | ||
| 112 | name, account := "", "" | ||
| 113 | if w.Ident != nil { | ||
| 114 | name = w.Ident.Label(a.Principal) | ||
| 115 | account = w.Ident.Name(a.Principal) | ||
| 116 | } | ||
| 117 | events = append(events, activityEvent{ | ||
| 118 | Kind: "change", Icon: "check", Time: a.Created, | ||
| 119 | Actor: name, ActorName: account, Title: req.Title, | ||
| 120 | Detail: "approved", | ||
| 121 | URL: "/r/" + m.Name + "/requests/" + req.ID, | ||
| 122 | }) | ||
| 123 | } | ||
| 124 | } | ||
| 125 | } | ||
| 126 | } | ||
| 127 | |||
| 128 | if kind == "" || kind == "release" { | ||
| 129 | if tags, err := g.Tags(); err == nil { | ||
| 130 | for _, t := range tags { | ||
| 131 | events = append(events, activityEvent{ | ||
| 132 | Kind: "release", Icon: "tag", Time: time.Unix(t.When, 0).UTC(), | ||
| 133 | Actor: t.Tagger, Title: t.Name, Detail: "released", | ||
| 134 | URL: "/r/" + m.Name + "/releases/" + t.Name, | ||
| 135 | }) | ||
| 136 | } | ||
| 137 | } | ||
| 138 | } | ||
| 139 | |||
| 140 | if (kind == "" || kind == "run") && w.Runs != nil { | ||
| 141 | if runs, err := w.Runs.Recent(m.Name, 20); err == nil { | ||
| 142 | for _, run := range runs { | ||
| 143 | at := run.Finished | ||
| 144 | if at.IsZero() { | ||
| 145 | at = run.Created | ||
| 146 | } | ||
| 147 | ref := run.Branch | ||
| 148 | if run.Tag != "" { | ||
| 149 | ref = "tag " + run.Tag | ||
| 150 | } | ||
| 151 | events = append(events, activityEvent{ | ||
| 152 | Kind: "run", Icon: "play", Time: at, | ||
| 153 | Title: run.Job, Detail: run.Workflow + " · " + ref, | ||
| 154 | URL: "/r/" + m.Name + "/runs/" + run.ID, | ||
| 155 | Badge: run.Status, BadgeClass: run.Status, | ||
| 156 | }) | ||
| 157 | } | ||
| 158 | } | ||
| 159 | } | ||
| 160 | |||
| 161 | sort.SliceStable(events, func(i, j int) bool { | ||
| 162 | return events[i].Time.After(events[j].Time) | ||
| 163 | }) | ||
| 164 | if len(events) > activityLimit { | ||
| 165 | events = events[:activityLimit] | ||
| 166 | } | ||
| 167 | return events | ||
| 168 | } | ||
| 169 | |||
| 170 | // Activity serves the repository activity tab. It is separate from Overview so | ||
| 171 | // the project summary and the chronological feed can both exist without either | ||
| 172 | // becoming a compromise. | ||
| 173 | func (w *Web) Activity(rw http.ResponseWriter, r *http.Request) { | ||
| 174 | m, ok := w.open(rw, r) | ||
| 175 | if !ok { | ||
| 176 | return | ||
| 177 | } | ||
| 178 | kind := normalizeActivityKind(r.URL.Query().Get("kind")) | ||
| 179 | view := struct { | ||
| 180 | Page | ||
| 181 | Name string | ||
| 182 | Kind string | ||
| 183 | Events []activityEvent | ||
| 184 | }{ | ||
| 185 | Page: w.page(r, m.Name+" · activity"), Name: m.Name, Kind: kind, | ||
| 186 | Events: w.repoActivity(m, kind), | ||
| 187 | } | ||
| 188 | if htmxRequest(r) { | ||
| 189 | w.render(rw, "activity_feed", view, 0) | ||
| 190 | return | ||
| 191 | } | ||
| 192 | w.render(rw, "activity", view, 0) | ||
| 193 | } | ||