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/templates.go +24−898

@@ -1,901 +1,27 @@
1 1 package web
2 2
3 const templates = `
4 {{define "header"}}<!doctype html>
5 <html lang="en">
6 <head>
7 <meta charset="utf-8">
8 <meta name="viewport" content="width=device-width,initial-scale=1">
9 <meta name="csrf" content="{{.CSRF}}">
10 <title>{{.Title}} · Vole</title>
11 <style>
12 :root{
13 --bg:#0f1115; --fg:#e6e6e6; --muted:#8b93a1; --line:#242a35;
14 --accent:#6ea8fe; --card:#161a21; --bad:#f85149;
3 // Templates and static assets are embedded so an instance is still one file.
4 // Page templates are split by surface under templates/; shared chrome and the
5 // diff component live in layout.html.
6
7 import (
8 "embed"
9 "io/fs"
10 "net/http"
11 )
12
13 //go:embed templates/*.html
14 var templateFS embed.FS
15
16 //go:embed static/*
17 var staticFS embed.FS
18
19 // Static serves the embedded stylesheet, favicon, and small scripts. Assets are
20 // compiled into the binary, so there is no directory to deploy beside it.
21 func Static() http.Handler {
22 sub, err := fs.Sub(staticFS, "static")
23 if err != nil {
24 panic(err)
25 }
26 return http.StripPrefix("/static/", http.FileServerFS(sub))
15 27 }
16 *{box-sizing:border-box}
17 body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
18 a{color:var(--accent);text-decoration:none}
19 a:hover{text-decoration:underline}
20 header{display:flex;align-items:center;gap:1rem;padding:.9rem 1.25rem;border-bottom:1px solid var(--line);background:var(--card)}
21 .brand{font-weight:700;letter-spacing:.02em;color:var(--fg)}
22 header nav{margin-left:auto;color:var(--muted);font-size:.85rem}
23 main{max-width:62rem;margin:0 auto;padding:1.5rem 1.25rem 4rem}
24 h1{font-size:1.45rem;margin:.2rem 0 1rem}
25 h2{font-size:1.05rem;margin:1.75rem 0 .6rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em}
26 .muted{color:var(--muted)}
27 table{width:100%;border-collapse:collapse;font-size:.93rem}
28 th,td{text-align:left;padding:.45rem .5rem;border-bottom:1px solid var(--line);vertical-align:top}
29 th{color:var(--muted);font-weight:500;font-size:.82rem;text-transform:uppercase;letter-spacing:.04em}
30 code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
31 code{background:var(--card);border:1px solid var(--line);border-radius:4px;padding:.1rem .35rem;font-size:.88em}
32 pre{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:1rem;overflow:auto;font-size:.86rem;line-height:1.5}
33 select,input[type=text],input[type=password],textarea{background:var(--card);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:.45rem .55rem;font:inherit;font-size:.9rem}
34 input[type=text],input[type=password],textarea{width:100%;max-width:32rem}
35 textarea{min-height:8rem;resize:vertical}
36 label{display:block;margin:.6rem 0;color:var(--muted);font-size:.9rem}
37 button{background:var(--accent);color:#08101d;border:0;border-radius:6px;padding:.45rem .8rem;font:inherit;font-weight:600;cursor:pointer}
38 button:disabled{opacity:.5;cursor:default}
39 button.link{border:0;background:none;color:var(--accent);padding:0;font:inherit;font-weight:400;cursor:pointer}
40 button.link:hover{text-decoration:underline}
41 form.inline{display:inline}
42 .auth{max-width:36rem}
43 .error{color:var(--bad)}
44 .bad{color:var(--bad)}
45 .card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:1rem;margin:1rem 0}
46 .row{display:flex;gap:1.5rem;flex-wrap:wrap}
47 .row>div{flex:1 1 18rem}
48 .crumbs{color:var(--muted);font-size:.9rem;margin-bottom:.75rem}
49 .dir{color:var(--accent)}
50 .sha{font-family:ui-monospace,monospace;font-size:.85rem;color:var(--muted)}
51 .label{display:inline-block;margin-left:.4rem;padding:.05rem .4rem;border:1px solid var(--line);border-radius:10px;font-size:.75rem;color:var(--muted)}
52 .md{line-height:1.6}
53 .md h1,.md h2,.md h3{text-transform:none;letter-spacing:0;color:var(--fg);margin:1.1rem 0 .5rem}
54 .md h1{font-size:1.25rem}.md h2{font-size:1.1rem}.md h3{font-size:1rem}
55 .md p{margin:.6rem 0}
56 .md code{background:var(--card)}
57 .md pre{background:var(--card);border:1px solid var(--line)}
58 .md blockquote{margin:.6rem 0;padding-left:.8rem;border-left:3px solid var(--line);color:var(--muted)}
59 .md ul,.md ol{padding-left:1.4rem}
60 .md table{width:auto}
61 .diff{border:1px solid var(--line);border-radius:8px;margin:1rem 0;overflow:hidden}
62 .diff h3{margin:0;padding:.55rem .8rem;background:var(--card);border-bottom:1px solid var(--line);font-size:.9rem;font-weight:600}
63 .diff table{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.82rem;border-collapse:collapse}
64 .diff td{border:0;padding:0 .5rem;white-space:pre-wrap;word-break:break-word;vertical-align:top}
65 .diff td.ln{width:3.5rem;text-align:right;color:var(--muted);user-select:none;border-right:1px solid var(--line)}
66 .diff td.code{padding-left:.75rem}
67 .diff tr.add{background:rgba(46,160,67,.12)}
68 .diff tr.add td.ln{color:#3fb950}
69 .diff tr.del{background:rgba(248,81,73,.10)}
70 .diff tr.del td.ln{color:var(--bad)}
71 .conflict{color:var(--bad)}
72 .diff tr.hunk td{color:var(--muted);background:var(--card);padding:.25rem .5rem;font-size:.78rem}
73 .diff tr.note td{background:rgba(110,168,254,.07);padding:.15rem .5rem .3rem;font-size:.78rem}
74 .diff tr.note a.label{color:var(--accent);border-color:var(--accent)}
75 .diff .binary{padding:.7rem .8rem;color:var(--muted);font-size:.85rem}
76 </style>
77 </head>
78 <body>
79 <header>
80 <a class="brand" href="/">vole</a>
81 <nav>
82 {{if .LoggedIn}}<span class="muted">{{.User}}</span> · <a href="/account">account</a> ·
83 <form class="inline" method="post" action="/logout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="link" type="submit">log out</button></form>
84 {{else}}<a href="/login">log in</a>{{end}}
85 · <a href="/runners">runners</a> · push over ssh
86 </nav>
87 </header>
88 <main>
89 {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
90 <script src="/auth.js" defer></script>
91 {{end}}
92
93 {{define "footer"}}
94 </main>
95 </body>
96 </html>
97 {{end}}
98
99 {{define "index"}}
100 {{template "header" .}}
101 <h1>Repositories</h1>
102 {{if .Repos}}
103 <table>
104 <tr><th>Name</th><th>Description</th><th>Created</th></tr>
105 {{range .Repos}}
106 <tr>
107 <td><a href="/r/{{.Name}}">{{.Name}}</a></td>
108 <td class="muted">{{.Description}}</td>
109 <td class="muted">{{since .Created.Unix}}</td>
110 </tr>
111 {{end}}
112 </table>
113 {{else}}
114 <p class="muted">No public repositories.</p>
115 {{end}}
116 {{template "footer" .}}
117 {{end}}
118
119 {{define "repo-ci"}}
120 {{if .Runs}}
121 <h2>CI</h2>
122 <table>
123 <tr><th>Job</th><th>Commit</th><th>Status</th><th>Attempts</th><th>Age</th></tr>
124 {{range .Runs}}
125 <tr>
126 <td><a href="/r/{{$.Name}}/runs/{{.ID}}">{{.Job}}</a> <span class="muted">{{.Workflow}}</span></td>
127 <td class="sha">{{short .SHA}}</td>
128 <td class="muted">{{.Status}}{{if eq .Status "failed"}} (exit {{.ExitCode}}){{end}}</td>
129 <td class="muted">{{.Attempts}}</td>
130 <td class="muted">{{since .Created.Unix}}</td>
131 {{if or .LogOID .Log}}<td><a href="/r/{{$.Name}}/runs/{{.ID}}/log">log</a></td>{{else}}<td></td>{{end}}
132 </tr>
133 {{end}}
134 </table>
135 {{end}}
136 {{end}}
137
138 {{define "repo"}}
139 {{template "header" .}}
140 <h1>{{.Name}}</h1>
141 {{if .Description}}<p class="muted">{{.Description}}</p>{{end}}
142 {{if .Site}}<p class="muted">Site: <a href="{{.Site}}">{{.Site}}</a></p>{{end}}
143 {{if .Readme}}<div class="md readme">{{.Readme}}</div>{{end}}
144
145 <div class="row">
146 <div>
147 <h2>Clone</h2>
148 <p><code>{{.CloneURL}}</code></p>
149 <p class="muted">Writing needs SSH and a registered key:</p>
150 <p><code>git clone {{.SSHURL}}</code></p>
151 </div>
152 <div>
153 <h2>Branch</h2>
154 <form method="get" action="/r/{{.Name}}">
155 <select name="ref" onchange="this.form.submit()">
156 {{range .Branches}}<option value="{{.}}"{{if eq . $.Ref}} selected{{end}}>{{.}}</option>{{end}}
157 </select>
158 </form>
159 <p class="muted">{{.Count}} commit(s) · head <span class="sha">{{short .Commit}}</span></p>
160 </div>
161 </div>
162
163 <p><a href="/r/{{.Name}}/issues">Issues</a> <span class="muted">({{.IssueCount}})</span></p>
164 <p><a href="/r/{{.Name}}/requests">Change requests</a></p>
165 <p><a href="/r/{{.Name}}/releases">Releases</a> <span class="muted">({{.ReleaseCount}})</span></p>
166 {{if .LockCount}}<p><a href="/r/{{.Name}}/locks">Locks</a> <span class="muted">({{.LockCount}} held)</span></p>{{end}}
167
168 <h2>Files</h2>
169 {{if .Entries}}
170 <table>
171 {{range .Entries}}
172 <tr>
173 {{if eq .Type "tree"}}
174 <td><a class="dir" href="/r/{{$.Name}}/tree/{{esc $.Ref}}/{{.Name}}">{{.Name}}/</a></td>
175 {{else}}
176 <td><a href="/r/{{$.Name}}/blob/{{esc $.Ref}}/{{.Name}}">{{.Name}}</a></td>
177 {{end}}
178 <td class="muted">{{.Mode}}</td>
179 </tr>
180 {{end}}
181 </table>
182 {{else}}
183 <p class="muted">This repository is empty.</p>
184 {{end}}
185
186 <h2>Recent commits</h2>
187 {{template "commitlist" .}}
188
189 {{if .ContributorBranches}}
190 <h2>Contributor branches</h2>
191 <p class="muted">CI does not run for these branches until a maintainer allows the current commit.</p>
192 <table>
193 <tr><th>Branch</th><th>CI</th></tr>
194 {{range .ContributorBranches}}
195 <tr>
196 <td><code>{{.}}</code></td>
197 <td>
198 {{if $.CanAllowCI}}
199 <form method="post" action="/r/{{$.Name}}/ci/allow">
200 <input type="hidden" name="csrf" value="{{$.CSRF}}">
201 <input type="hidden" name="ref" value="refs/heads/{{.}}">
202 <button type="submit">Allow CI for this tip</button>
203 </form>
204 {{else}}<span class="muted">waiting for a maintainer to allow it</span>{{end}}
205 </td>
206 </tr>
207 {{end}}
208 </table>
209 {{end}}
210
211 {{template "repo-ci" .}}
212 {{template "footer" .}}
213 {{end}}
214
215 {{define "commitlist"}}
216 {{if .Commits}}
217 <table>
218 {{range .Commits}}
219 <tr>
220 <td><a class="sha" href="/r/{{$.Name}}/commit/{{.SHA}}">{{short .SHA}}</a></td>
221 <td><a href="/r/{{$.Name}}/commit/{{.SHA}}">{{.Subject}}</a></td>
222 <td class="muted">{{.Author}}</td>
223 <td class="muted">{{since .When}}</td>
224 </tr>
225 {{end}}
226 </table>
227 {{else}}
228 <p class="muted">No commits yet.</p>
229 {{end}}
230 {{end}}
231
232 {{define "tree"}}
233 {{template "header" .}}
234 <h1><a href="/r/{{.Name}}">{{.Name}}</a></h1>
235 <p class="crumbs">
236 <a href="/r/{{.Name}}/tree/{{esc .Ref}}">{{.Ref}}</a>
237 {{range .Crumbs}} / <span>{{.Name}}</span>{{end}}
238 </p>
239 <table>
240 {{range .Entries}}
241 <tr>
242 {{if eq .Type "tree"}}
243 <td><a class="dir" href="/r/{{$.Name}}/tree/{{esc $.Ref}}/{{.Name}}">{{.Name}}/</a></td>
244 {{else}}
245 <td><a href="/r/{{$.Name}}/blob/{{esc $.Ref}}/{{.Name}}">{{.Name}}</a></td>
246 {{end}}
247 <td class="muted">{{.Mode}}</td>
248 </tr>
249 {{end}}
250 </table>
251 {{template "footer" .}}
252 {{end}}
253
254 {{define "diff"}}
255 <p class="muted">{{len .Files}} file(s) changed{{if .Added}} · <span class="adds">+{{.Added}}</span>{{end}}{{if .Removed}} · <span class="dels">−{{.Removed}}</span>{{end}}{{if .Truncated}} · showing the first 512 KiB{{end}}</p>
256 {{range .Files}}
257 <div class="diff">
258 <h3>{{if and .OldPath (ne .OldPath .NewPath)}}{{.OldPath}} → {{end}}{{.Label}}{{if .Binary}} <span class="muted">binary</span>{{end}}</h3>
259 {{if .Binary}}
260 <p class="binary">Binary file: no line diff to show. Use <code>git show</code> to inspect it.</p>
261 {{end}}
262 {{if .Hunks}}
263 <table>
264 {{range .Hunks}}
265 <tr class="hunk"><td colspan="3">{{.Header}}</td></tr>
266 {{range .Lines}}
267 <tr class="{{.Kind}}">
268 <td class="ln">{{if .OldNo}}{{.OldNo}}{{end}}</td>
269 <td class="ln">{{if .NewNo}}{{.NewNo}}{{end}}</td>
270 <td class="code">{{.Text}}</td>
271 </tr>
272 {{if .Comments}}<tr class="note"><td colspan="3">{{range .Comments}}<a class="label" href="#c-{{.ID}}">{{who .Author}} commented</a>{{end}}</td></tr>{{end}}
273 {{end}}
274 {{end}}
275 </table>
276 {{end}}
277 </div>
278 {{end}}
279 {{end}}
280
281 {{define "commit"}}
282 {{template "header" .}}
283 <h1>{{.Commit.Subject}}</h1>
284 <p class="muted"><span class="sha">{{.Commit.SHA}}</span> · {{.Commit.Author}} · {{since .Commit.When}}</p>
285 {{if .Body}}<pre>{{.Body}}</pre>{{end}}
286 {{template "diff" .}}
287 {{template "footer" .}}
288 {{end}}
289
290 {{define "blob"}}
291 {{template "header" .}}
292 <h1><a href="/r/{{.Name}}">{{.Name}}</a></h1>
293 <p class="crumbs">
294 <a href="/r/{{.Name}}/blob/{{esc .Ref}}">{{.Path}}</a> ·
295 <a href="/r/{{.Name}}/raw/{{esc .Ref}}/{{.Path}}">raw</a> ·
296 <span class="muted">{{.Size}} bytes</span>
297 </p>
298 {{if .Binary}}
299 <p class="muted">Binary file, {{.Size}} bytes. <a href="/r/{{.Name}}/raw/{{esc .Ref}}/{{.Path}}">Download it</a>.</p>
300 {{else}}
301 {{if .Truncated}}<p class="muted">Showing the first 1 MiB.</p>{{end}}
302 {{if .Markdown}}<div class="md">{{.Markdown}}</div>
303 {{else}}<pre>{{.Content}}</pre>{{end}}
304 {{end}}
305 {{template "footer" .}}
306 {{end}}
307
308 {{define "issues"}}
309 {{template "header" .}}
310 <h1><a href="/r/{{.Name}}">{{.Name}}</a> · issues</h1>
311 <form method="get" action="/r/{{.Name}}/issues" class="filters">
312 <input name="q" type="text" placeholder="search title and body" value="{{.Filter.Query}}">
313 <select name="state">
314 <option value="">all states</option>
315 <option value="open"{{if eq .Filter.State "open"}} selected{{end}}>open</option>
316 <option value="closed"{{if eq .Filter.State "closed"}} selected{{end}}>closed</option>
317 </select>
318 <input type="hidden" name="label" value="{{.Filter.Label}}">
319 <input type="hidden" name="assignee" value="{{.Filter.Assignee}}">
320 <button type="submit">Filter</button>
321 {{if .Filtered}}<a href="/r/{{.Name}}/issues">clear</a>{{end}}
322 </form>
323 <p class="muted">{{.Open}} open · {{.Total}} total</p>
324 {{if .CanDiscuss}}
325 <form method="post" action="/r/{{.Name}}/issues" class="card">
326 <input type="hidden" name="csrf" value="{{.CSRF}}">
327 <label>Title <input type="text" name="title" required maxlength="200"></label>
328 <label>Description <textarea name="body" placeholder="Markdown is welcome."></textarea></label>
329 <button type="submit">Open issue</button>
330 </form>
331 {{end}}
332 {{if .Labels}}
333 <p class="muted">Labels:
334 {{range .Labels}}<a class="label" href="/r/{{$.Name}}/issues?label={{.}}">{{.}}</a>{{end}}
335 </p>
336 {{end}}
337 {{if .Assignees}}
338 <p class="muted">Assignees:
339 {{range .Assignees}}<a class="label" href="/r/{{$.Name}}/issues?assignee={{.}}">@{{.}}</a>{{end}}
340 </p>
341 {{end}}
342 {{if .Issues}}
343 <table>
344 <tr><th>ID</th><th>Title</th><th>State</th><th>Author</th><th>Replies</th><th>Age</th></tr>
345 {{range .Issues}}
346 <tr>
347 <td class="sha">{{short .ID}}</td>
348 <td><a href="/r/{{$.Name}}/issues/{{.ID}}">{{.Title}}</a>{{range .Labels}}<span class="label">{{.}}</span>{{end}}{{range .Assignees}} <a class="label" href="/r/{{$.Name}}/issues?assignee={{.}}">@{{.}}</a>{{end}}</td>
349 <td class="muted">{{.State}}</td>
350 <td class="muted">{{who .Author}}</td>
351 <td class="muted">{{.Comments}}</td>
352 <td class="muted">{{since .Created.Unix}}</td>
353 </tr>
354 {{end}}
355 </table>
356 {{else if .Filtered}}
357 <p class="muted">No issues match that filter.</p>
358 {{else}}
359 <p class="muted">No issues yet. From a clone of this repository:
360 <code>vole issue new --title "..." -m "..."</code></p>
361 <p class="muted">An issue is also just a file on the <code>vole</code> branch:
362 <code>issues/&lt;ulid&gt;/issue.md</code> — a frontmatter block with the title, state and
363 labels, then the body as markdown, which you can write with any editor.</p>
364 {{end}}
365 {{template "footer" .}}
366 {{end}}
367
368 {{define "issue"}}
369 {{template "header" .}}
370 <h1>{{.Issue.Title}}</h1>
371 <p class="crumbs">
372 <span class="sha">{{.Issue.ID}}</span> · {{.Issue.State}} · opened by {{who .Issue.Author}} {{since .Issue.Created.Unix}}
373 {{if .Labels}}<span class="label">{{.Labels}}</span>{{end}}
374 {{range .Issue.Assignees}}<a class="label" href="/r/{{$.Name}}/issues?assignee={{.}}">@{{.}}</a>{{end}}
375 </p>
376 <article class="md">{{.Description}}</article>
377 <h2>{{len .Comments}} comment(s)</h2>
378 {{range .Comments}}
379 <div class="card" id="c-{{.ID}}">
380 <p class="muted"><span class="sha">{{short .ID}}</span> · {{who .Author}} · {{since .Created.Unix}}
381 {{if .Anchor}}· on <code>{{.Anchor.Path}}:{{.Anchor.Line}}</code> ({{.Anchor.Side}} side)
382 {{if not .Current}}· <span class="conflict">the file has changed since this comment</span>{{end}}{{end}}
383 </p>
384 <article class="md">{{.Body}}</article>
385 </div>
386 {{else}}
387 <p class="muted">No comments yet. From a clone of this repository:
388 <code>vole issue comment {{.Issue.ID}} -m "..."</code></p>
389 {{end}}
390 {{if .CanDiscuss}}
391 <form method="post" action="/r/{{.Name}}/issues/{{.Issue.ID}}/comments" class="card">
392 <input type="hidden" name="csrf" value="{{.CSRF}}">
393 <label>Comment <textarea name="body" required></textarea></label>
394 <button type="submit">Comment</button>
395 </form>
396 {{end}}
397 {{if .Backlinks}}
398 <h2>Referenced in commits</h2>
399 <table>
400 {{range .Backlinks}}
401 <tr>
402 <td><a class="sha" href="/r/{{$.Name}}/commit/{{.SHA}}">{{short .SHA}}</a></td>
403 <td><a href="/r/{{$.Name}}/commit/{{.SHA}}">{{.Subject}}</a></td>
404 <td class="muted">{{.Author}}</td>
405 <td class="muted">{{since .When}}</td>
406 </tr>
407 {{end}}
408 </table>
409 {{end}}
410 {{template "footer" .}}
411 {{end}}
412
413 {{define "commits"}}
414 {{template "header" .}}
415 <h1><a href="/r/{{.Name}}">{{.Name}}</a> · history</h1>
416 <p class="crumbs"><span class="muted">{{.Ref}}</span></p>
417 {{template "commitlist" .}}
418 {{template "footer" .}}
419 {{end}}
420
421
422 {{define "requests"}}
423 {{template "header" .}}
424 <h1><a href="/r/{{.Name}}">{{.Name}}</a> · change requests</h1>
425 <p class="muted">{{.Open}} open · {{.Total}} total</p>
426 {{if .CanOpen}}
427 <form method="post" action="/r/{{.Name}}/requests" class="card">
428 <input type="hidden" name="csrf" value="{{.CSRF}}">
429 <label>Title <input type="text" name="title" required maxlength="200"></label>
430 <div class="row">
431 <div><label>Base <select name="base">{{range .BaseBranches}}<option value="{{.}}"{{if eq . $.Base}} selected{{end}}>{{.}}</option>{{end}}</select></label></div>
432 <div><label>Head <select name="head">{{range .HeadBranches}}<option value="{{.}}">{{.}}</option>{{end}}</select></label></div>
433 </div>
434 <label>Description <textarea name="body" placeholder="Why should this land?"></textarea></label>
435 <button type="submit">Open change request</button>
436 {{if not .HeadBranches}}<p class="muted">Push a branch first. Contributors can push only under <code>refs/heads/contrib/&lt;account-name&gt;/</code>.</p>{{end}}
437 </form>
438 {{end}}
439 {{if .Rows}}
440 <table>
441 <tr><th>ID</th><th>Title</th><th>Base</th><th>Head</th><th>Approvals</th><th>State</th><th>Age</th></tr>
442 {{range .Rows}}
443 <tr>
444 <td class="sha">{{short .ID}}</td>
445 <td><a href="/r/{{$.Name}}/requests/{{.ID}}">{{.Title}}</a></td>
446 <td class="muted">{{.Base}}</td>
447 <td class="sha">{{short .Head}}</td>
448 <td class="muted">{{len .Approvals}}</td>
449 <td class="muted">{{.State}}</td>
450 <td class="muted">{{since .Created.Unix}}</td>
451 </tr>
452 {{end}}
453 </table>
454 {{else}}
455 <p class="muted">No change requests. From a clone of this repository:
456 <code>vole change new --title "..." --base main</code></p>
457 <p class="muted">A request is also just a directory on the <code>vole</code> branch:
458 <code>requests/&lt;ulid&gt;/request.md</code> naming the <code>base</code> branch and the <code>head</code>
459 commit to land, plus <code>approvals/&lt;account-id&gt;.md</code> from each approver.</p>
460 {{end}}
461 {{template "footer" .}}
462 {{end}}
463
464 {{define "request"}}
465 {{template "header" .}}
466 <h1>{{.Request.Title}}</h1>
467 <p class="muted">
468 {{.Request.State}} · <span class="sha">{{short .Request.ID}}</span> ·
469 by {{who .Request.Author}} · {{since .Request.Created.Unix}}
470 </p>
471 <p>
472 <strong>{{.Request.Base}}</strong> ← <span class="sha">{{short .Request.Head}}</span>
473 {{if .Request.Head}}<a href="/r/{{.Name}}/commits/{{.Request.Base}}">history</a>{{end}}
474 </p>
475 {{if .Description}}<div class="markdown">{{.Description}}</div>{{end}}
476
477 <h2>Discussion</h2>
478 {{range .Comments}}
479 <div class="card" id="c-{{.ID}}">
480 <p class="muted"><span class="sha">{{short .ID}}</span> · {{who .Author}} · {{since .Created.Unix}}
481 {{if .Anchor}}· on <code>{{.Anchor.Path}}:{{.Anchor.Line}}</code> ({{.Anchor.Side}} side)
482 {{if not .Current}}· <span class="conflict">the file has changed since this comment</span>{{end}}{{end}}
483 </p>
484 <article class="md">{{.Body}}</article>
485 </div>
486 {{else}}
487 <p class="muted">No discussion yet. From a clone of this repository:
488 <code>vole change comment {{.Request.ID}} -m "..."</code></p>
489 {{end}}
490
491 <h2>Approvals</h2>
492 {{if .CanApprove}}
493 <form method="post" action="/r/{{.Name}}/requests/{{.Request.ID}}/approve" class="card">
494 <input type="hidden" name="csrf" value="{{.CSRF}}">
495 <p class="muted">Approving names the current head <span class="sha">{{short .Request.Head}}</span>; a new head starts over.</p>
496 <button type="submit">Approve this head</button>
497 </form>
498 {{end}}
499 {{if .Required}}<p class="muted">{{.Current}} of {{.Required}} required approval(s).</p>
500 {{else}}<p class="muted">This repository requires no approvals: only an admin may push.</p>
501 {{end}}
502 {{if .Approvals}}
503 <table>
504 <tr><th>Approver</th><th>Head approved</th><th>Base</th><th>Status</th><th>When</th></tr>
505 {{range .Approvals}}
506 <tr>
507 <td>{{.Name}}</td>
508 <td class="sha">{{short .Head}}</td>
509 <td class="muted">{{.Base}}</td>
510 <td class="muted">{{if .Current}}current{{else}}stale (head or base has changed){{end}}</td>
511 <td class="muted">{{since .Created.Unix}}</td>
512 </tr>
513 {{end}}
514 </table>
515 {{else}}
516 <p class="muted">No approvals yet. An approval names one commit, so pushing a new head starts over.</p>
517 {{end}}
518
519 <h2>Checks</h2>
520 {{if .Checks}}
521 <table>
522 <tr><th>Job</th><th>Status</th></tr>
523 {{range .Checks}}
524 <tr>
525 <td><a href="/r/{{$.Name}}/runs/{{.ID}}">{{.Job}}</a> <span class="muted">{{.Workflow}}</span></td>
526 <td class="muted">{{.Status}}{{if eq .Status "failed"}} (exit {{.Exit}}){{end}}</td>
527 </tr>
528 {{end}}
529 </table>
530 {{else}}
531 <p class="muted">No CI run for this commit yet.</p>
532 {{end}}
533
534 <h2>What would land</h2>
535 {{if .MergeChecked}}{{if .Conflicts}}
536 <p class="conflict">This branch no longer merges into {{.Request.Base}} cleanly:
537 {{range .Conflicts}}<code>{{.}}</code> {{end}}— rebase or merge {{.Request.Base}} before landing it.</p>
538 {{else}}
539 <p class="muted">Merges into {{.Request.Base}} cleanly.</p>
540 {{end}}{{end}}
541 {{if .AlreadyMerged}}
542 <p class="muted">Already merged into {{.Request.Base}}.</p>
543 {{else if .Files}}
544 {{template "diff" .}}
545 {{else}}
546 <p class="muted">No changes, or the head is not reachable from this repository yet.</p>
547 {{end}}
548 {{template "footer" .}}
549 {{end}}
550
551 {{define "locks"}}
552 {{template "header" .}}
553 <h1><a href="/r/{{.Name}}">{{.Name}}</a> · locks</h1>
554 <p class="muted">A lock is a claim on one path, held until its holder releases it. It
555 exists for files git cannot merge: while it is held, a push that changes that path is
556 refused, so two people cannot overwrite each other's work on the same binary.</p>
557 <p class="muted">Take and release them with <code>git lfs lock &lt;path&gt;</code> and
558 <code>git lfs unlock &lt;path&gt;</code>, or by id with <code>git lfs unlock --id</code>.</p>
559 {{if .Problem}}<p class="muted">{{.Problem}}</p>{{end}}
560 {{if .Locks}}
561 <table>
562 <tr><th>Path</th><th>Held by</th><th>Since</th></tr>
563 {{range .Locks}}
564 <tr>
565 <td><code>{{.Path}}</code></td>
566 <td>{{.Owner}}{{if .Ref}} <span class="muted">on {{.Ref}}</span>{{end}}</td>
567 <td class="muted">{{since .Since.Unix}}</td>
568 </tr>
569 {{end}}
570 </table>
571 {{else}}
572 <p class="muted">No path is locked. Nothing is reserving a file, so anybody with write
573 access can push a change to anything.</p>
574 {{end}}
575 {{template "footer" .}}
576 {{end}}
577
578 {{define "releases"}}
579 {{template "header" .}}
580 <h1><a href="/r/{{.Name}}">{{.Name}}</a> · releases</h1>
581 {{if .Tags}}
582 <table>
583 <tr><th>Tag</th><th>Notes</th><th>Tagger</th><th>Age</th><th>Source</th></tr>
584 {{range .Tags}}
585 <tr>
586 <td><a href="/r/{{$.Name}}/releases/{{.Name}}">{{.Name}}</a>{{if not .Annotated}} <span class="label">lightweight</span>{{end}}</td>
587 <td class="muted">{{.Subject}}</td>
588 <td class="muted">{{if .Tagger}}{{.Tagger}}{{else}}—{{end}}</td>
589 <td class="muted">{{since .When}}</td>
590 <td><a href="/r/{{$.Name}}/releases/{{.Name}}/archive.tar">tar</a> · <a href="/r/{{$.Name}}/releases/{{.Name}}/archive.tar.gz">tar.gz</a></td>
591 </tr>
592 {{end}}
593 </table>
594 {{else}}
595 <p class="muted">No releases yet. A release is an annotated tag, so this is the whole flow:</p>
596 <pre>git tag -a v1.0.0 -F notes.md
597 git push --follow-tags</pre>
598 {{end}}
599 {{if .Lightweight}}
600 <p class="muted">{{.Lightweight}} lightweight tag(s) are listed but carry no notes: <code>git tag -a</code>
601 records a tagger and a message, which is what makes a tag a release.</p>
602 {{end}}
603 {{template "footer" .}}
604 {{end}}
605
606 {{define "release"}}
607 {{template "header" .}}
608 <h1><a href="/r/{{.Name}}">{{.Name}}</a> · {{.Tag.Name}}</h1>
609 <p class="muted">
610 {{if .Tag.Annotated}}annotated tag{{else}}lightweight tag{{end}}
611 {{if .Tag.Tagger}}by {{.Tag.Tagger}}{{end}}
612 · {{since .Tag.When}}
613 · commit <a class="sha" href="/r/{{.Name}}/commit/{{.Tag.Commit}}">{{short .Tag.Commit}}</a>
614 </p>
615
616 {{if .Notes}}
617 <div class="md">{{.Notes}}</div>
618 {{else if not .Tag.Annotated}}
619 <p class="muted">A lightweight tag has no notes. Annotate it (<code>git tag -a</code>) to give this
620 release a description; the tarball below works either way.</p>
621 {{end}}
622
623 <div class="card">
624 <h2>Artifacts</h2>
625 {{if .Artifacts}}
626 <table>
627 <tr><th>File</th><th>Size</th><th>SHA-256</th><th></th></tr>
628 {{range .Artifacts}}
629 <tr>
630 <td><a href="/r/{{$.Name}}/releases/{{$.Tag.Name}}/artifacts/{{.Name}}">{{.Name}}</a></td>
631 <td class="muted">{{.Size}}</td>
632 <td class="muted"><code>{{short .OID}}</code></td>
633 <td><a href="/r/{{$.Name}}/releases/{{$.Tag.Name}}/artifacts/{{.Name}}">download</a></td>
634 </tr>
635 {{end}}
636 </table>
637 <p class="muted">Bytes live once in the instance's content-addressed store, so the same
638 file on two releases costs one copy.</p>
639 {{else}}
640 <p class="muted">No artifacts attached. Push the tag first, then:</p>
641 <pre>vole release attach {{.Tag.Name}} dist/*</pre>
642 {{end}}
643 </div>
644
645 <div class="card">
646 <h2>Source</h2>
647 <p><a href="/r/{{.Name}}/releases/{{.Tag.Name}}/archive.tar">Download .tar</a>
648 · <a href="/r/{{.Name}}/releases/{{.Tag.Name}}/archive.tar.gz">Download .tar.gz</a></p>
649 <p class="muted">Generated from the tag when you ask for it, not stored — the same bytes on
650 every request, so a cached copy cannot go stale.</p>
651 {{if .LFSPointers}}<p class="muted">This repository tracks files with Git LFS: those files appear
652 in the tarball as pointer files, not their contents. Clone with <code>git lfs</code> for the
653 real bytes.</p>{{end}}
654 </div>
655
656 <h2>{{if .Prev}}{{.Count}} commit(s) since {{.Prev}}{{else}}{{.Count}} commit(s){{end}}</h2>
657 {{template "commitlist" .}}
658 {{if .Capped}}<p class="muted">Showing the first {{.CappedAt}}; the rest are in the history.</p>{{end}}
659 {{template "footer" .}}
660 {{end}}
661
662
663 {{define "login"}}
664 {{template "header" .}}
665 <div class="auth card">
666 <h1>Log in</h1>
667 {{if not .Enabled}}
668 <p class="muted">{{.Reason}}</p>
669 {{else}}
670 <form data-auth="login" data-next="{{.Next}}">
671 <label>Account name
672 <input type="text" name="username" autocomplete="username webauthn" required autofocus>
673 </label>
674 <button type="submit">Use a passkey</button>
675 <p class="error" data-auth-error></p>
676 </form>
677 {{if .Registration}}
678 <p class="muted">No account yet? <a href="/register">Create one</a>.</p>
679 {{else}}
680 <p class="muted">No account yet? Ask an administrator for a one-time enrollment link.</p>
681 {{end}}
682 {{end}}
683 </div>
684 {{template "footer" .}}
685 {{end}}
686
687 {{define "register"}}
688 {{template "header" .}}
689 <div class="auth card">
690 <h1>Create an account</h1>
691 <p class="muted">Pick a name and register a passkey. You can add an SSH key after signing in.</p>
692 <form data-auth="register">
693 <label>Account name
694 <input type="text" name="username" pattern="[a-z0-9][a-z0-9_-]{1,31}" maxlength="32" autocomplete="username webauthn" required autofocus>
695 </label>
696 <label>Display name <span class="muted">(optional, shown to other people)</span>
697 <input type="text" name="display" maxlength="64" autocomplete="name">
698 </label>
699 <button type="submit">Create account and passkey</button>
700 <p class="error" data-auth-error></p>
701 </form>
702 <p class="muted">Names are 2–32 characters: lowercase letters, digits, <code>-</code> and <code>_</code>.
703 The account name is permanent — it is the folder your branches live under and cannot be
704 changed later. The display name is a label and can be changed any time.</p>
705 </div>
706 {{template "footer" .}}
707 {{end}}
708
709 {{define "invite"}}
710 {{template "header" .}}
711 <div class="auth card">
712 {{if .Account}}
713 <h1>Join {{.Account.Name}}</h1>
714 {{if not .Enabled}}
715 <p class="muted">{{.Reason}}</p>
716 {{else if .Token}}
717 <p class="muted">Register this device as a passkey for <strong>{{.Account.Name}}</strong>. The link works once.</p>
718 <form data-auth="invite" data-token="{{.Token}}">
719 <button type="submit">Create a passkey</button>
720 <p class="error" data-auth-error></p>
721 </form>
722 {{else}}
723 <p class="error">This enrollment link is no longer valid.</p>
724 {{end}}
725 {{else}}
726 <h1>Enrollment link</h1>
727 <p class="error">This enrollment link is invalid or has expired. Ask an administrator for a new one.</p>
728 {{end}}
729 </div>
730 {{template "footer" .}}
731 {{end}}
732
733 {{define "account"}}
734 {{template "header" .}}
735 <h1>Account</h1>
736 <p><strong>{{.Account.Label}}</strong> <span class="muted">{{.Account.Name}} · {{.Account.ID}}</span></p>
737
738 <h2>Display name</h2>
739 <p class="muted">Shown next to your account on requests, issues and approvals. Your account
740 name <strong>{{.Account.Name}}</strong> is permanent: your branches live under
741 <code>refs/heads/contrib/{{.Account.Name}}/</code>.</p>
742 <form method="post" action="/account/display" class="card">
743 <input type="hidden" name="csrf" value="{{.CSRF}}">
744 <label>Display name
745 <input type="text" name="display" maxlength="64" value="{{.Account.Display}}" placeholder="{{.Account.Name}}">
746 </label>
747 <button type="submit">Save display name</button>
748 </form>
749
750 <h2>SSH keys</h2>
751 {{if .Account.Keys}}
752 <table>
753 <tr><th>Fingerprint</th><th>Type</th><th>Comment</th><th></th></tr>
754 {{range .Account.Keys}}
755 <tr>
756 <td class="sha">{{.Fingerprint}}</td>
757 <td class="muted">{{.Type}}</td>
758 <td class="muted">{{.Comment}}</td>
759 <td>
760 <form method="post" action="/account/keys/remove">
761 <input type="hidden" name="csrf" value="{{$.CSRF}}">
762 <input type="hidden" name="fingerprint" value="{{.Fingerprint}}">
763 <button class="link" type="submit">remove</button>
764 </form>
765 </td>
766 </tr>
767 {{end}}
768 </table>
769 {{else}}
770 <p class="muted">No SSH keys yet.</p>
771 {{end}}
772 <form method="post" action="/account/keys" class="card">
773 <input type="hidden" name="csrf" value="{{.CSRF}}">
774 <label>Public key <textarea name="key" rows="3" placeholder="ssh-ed25519 AAAA... you@example.com" required></textarea></label>
775 <button type="submit">Add SSH key</button>
776 </form>
777
778 <h2>Passkeys</h2>
779 {{if .Credentials}}
780 <table>
781 <tr><th>Credential</th><th>Label</th><th>Created</th><th>Last used</th></tr>
782 {{range .Credentials}}
783 <tr>
784 <td class="sha">{{credid .ID}}</td>
785 <td>{{if .Label}}{{.Label}}{{else}}passkey{{end}}</td>
786 <td class="muted">{{since .Created.Unix}}</td>
787 <td class="muted">{{if .LastUsed.IsZero}}never{{else}}{{since .LastUsed.Unix}}{{end}}</td>
788 </tr>
789 {{end}}
790 </table>
791 {{else}}
792 <p class="muted">No passkeys yet.</p>
793 {{end}}
794
795 {{if .Enabled}}
796 <h2>Add a passkey</h2>
797 <form data-auth="passkey">
798 <label>Label <input type="text" name="label" placeholder="laptop, phone, security key"></label>
799 <button type="submit">Register this device</button>
800 <p class="error" data-auth-error></p>
801 </form>
802 {{else}}
803 <p class="muted">{{.Reason}}</p>
804 {{end}}
805
806 <h2>Sessions</h2>
807 {{if .Sessions}}
808 <table>
809 <tr><th>Created</th><th>Last seen</th><th>Expires</th><th>Client</th></tr>
810 {{range .Sessions}}
811 <tr>
812 <td class="muted">{{since .Created.Unix}}</td>
813 <td class="muted">{{since .LastSeen.Unix}}</td>
814 <td class="muted">{{.Expires.Format "2006-01-02 15:04"}}</td>
815 <td class="muted">{{.UserAgent}}</td>
816 </tr>
817 {{end}}
818 </table>
819 {{else}}
820 <p class="muted">No sessions.</p>
821 {{end}}
822 {{template "footer" .}}
823 {{end}}
824
825 {{define "runners"}}
826 {{template "header" .}}
827 <h1>Runners</h1>
828 <p class="muted">A runner is an SSH connection that leases jobs. Nothing is registered: a
829 channel that is open and being refreshed is a runner, and it disappears when it closes.</p>
830 {{if .Sessions}}
831 <table>
832 <tr><th>Runner</th><th>Takes</th><th>State</th><th>Last seen</th></tr>
833 {{range .Sessions}}
834 <tr>
835 <td>{{.Account}}</td>
836 <td class="muted">{{if .Labels}}{{.Labels}}{{else}}<span class="muted">any job</span>{{end}}</td>
837 <td>{{if .Idle}}<span class="muted">waiting for work</span>{{else if .RunID}}<a href="/r/{{.Repo}}/runs/{{.RunID}}">{{.Job}}</a> on <a href="/r/{{.Repo}}">{{.Repo}}</a>{{else}}<span class="muted">busy</span>{{end}}</td>
838 <td class="muted">{{since .Seen.Unix}}</td>
839 </tr>
840 {{end}}
841 </table>
842 {{else}}
843 <p class="muted">No runner is connected: none has been seen in the last {{.Window}}. Start one
844 with <code>vole runner --remote ssh://git@&lt;host&gt;</code>. If a job asks for a machine with
845 <code>runs-on</code>, the runner has to carry that label with <code>--label</code>.</p>
846 {{end}}
847
848 <h2>Queue</h2>
849 {{if .Queue}}
850 <table>
851 <tr><th>Repository</th><th>Waiting</th><th>Running</th></tr>
852 {{range .Queue}}
853 <tr>
854 <td><a href="/r/{{.Name}}">{{.Name}}</a></td>
855 <td class="muted">{{.Queued}}</td>
856 <td class="muted">{{.Running}}{{if .Cap}}{{if ge .Running .Cap}} of {{.Cap}} <span class="muted">(at the cap)</span>{{else}} of {{.Cap}}{{end}}{{end}}</td>
857 </tr>
858 {{end}}
859 </table>
860 {{else}}
861 <p class="muted">Nothing is waiting and nothing is running.</p>
862 {{end}}
863 {{template "footer" .}}
864 {{end}}
865
866 {{define "run"}}
867 {{template "header" .}}
868 <h1><a href="/r/{{.Name}}">{{.Name}}</a> · run {{.Run.Job}}</h1>
869 <p class="muted">
870 {{.Run.Workflow}} · {{if .Run.Tag}}tag {{.Run.Tag}}{{else}}{{.Run.Branch}}{{end}} at <a class="sha" href="/r/{{.Name}}/commit/{{.Run.SHA}}">{{short .Run.SHA}}</a>
871 · {{.Run.Status}}{{if eq .Run.Status "failed"}} (exit {{.Run.ExitCode}}){{end}}
872 · attempt {{.Run.Attempts}}
873 · {{since .Run.Created.Unix}}
874 </p>
875 {{if .Run.OS}}<p class="muted">ran on {{.Run.OS}}/{{.Run.Arch}}{{if .Run.Image}} · in {{if .Run.ImageFile}}image-file <code>{{.Run.ImageFile}}</code> ({{short .Run.ImageSHA}}){{else}}<code>{{.Run.Image}}</code>{{end}} via {{.Run.Runtime}}{{end}}</p>{{end}}
876 {{if .Attestation}}<p class="muted">reported by <strong>{{.Signer}}</strong>{{if .Attestation.CommitID}} · <a href="/r/{{.Name}}/commit/{{.Attestation.CommitID}}">the commit that records it</a>{{end}} · the instance accepted this from that account's key at the push boundary, and logged it</p>{{end}}
877 {{if .Counts}}<p class="muted">counts as a check for this repository</p>{{else if .CountsWhy}}<p class="muted">does not count as a check: {{.CountsWhy}}</p>{{end}}
878 {{if .HasLog}}<p><a href="/r/{{.Name}}/runs/{{.Run.ID}}/log">log</a>{{if gt (len .Logs) 1}} · by attempt:
879 {{range .Logs}}<a href="/r/{{$.Name}}/runs/{{$.Run.ID}}/log?attempt={{.Attempt}}">{{.Attempt}}</a> {{end}}{{end}}</p>{{end}}
880
881 <h2>Artifacts</h2>
882 {{if .Artifacts}}
883 <table>
884 <tr><th>File</th><th>Size</th><th>SHA-256</th><th></th></tr>
885 {{range .Artifacts}}
886 <tr>
887 <td><a href="/r/{{$.Name}}/runs/{{$.Run.ID}}/artifacts/{{.Name}}">{{.Name}}</a></td>
888 <td class="muted">{{bytes .Size}}</td>
889 <td class="muted"><code>{{short .OID}}</code></td>
890 <td><a href="/r/{{$.Name}}/runs/{{$.Run.ID}}/artifacts/{{.Name}}">download</a></td>
891 </tr>
892 {{end}}
893 </table>
894 <p class="muted">Built from {{short .Run.SHA}} by this run. The bytes live once in the
895 instance's content-addressed store, and are swept only when nothing names them.</p>
896 {{else}}
897 <p class="muted">No artifacts published by this run.</p>
898 {{end}}
899 {{template "footer" .}}
900 {{end}}
901 `