Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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 }