Commit 072c123912

072c1239123a6fbe05e1ac2cb09fecd38234b6ef

parent: f0187c7c7e

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-17 16:38 UTC

web: settings as bounded rows with consequences and a saved flash

Ref #218
e2e/settingsweb_test.go +15 −6
@@ -39,15 +39,27 @@ func TestRepoSettingsWeb(t *testing.T) {
3939 return body
4040 }
4141
42 if body := post(url.Values{"field": {"description"}, "description": {"a thing"}}); !strings.Contains(body, `class="notice" role="status">Saved the description.`) {
43 t.Fatalf("no success flash after saving the description:\n%s", body)
44 }
45 if body := post(url.Values{"field": {"topics"}, "topics": {"cli, forge"}}); !strings.Contains(body, `value="cli, forge"`) {
46 t.Fatalf("topics field is not prefilled after save:\n%s", body)
47 }
48 if body := post(url.Values{"field": {"topics"}, "topics": {"forge"}}); strings.Contains(body, `>cli<`) || !strings.Contains(body, `value="forge"`) {
49 t.Fatalf("removing a topic through the field failed:\n%s", body)
50 }
51 if body := post(url.Values{"field": {"website"}, "website": {"javascript:alert(1)"}}); !strings.Contains(body, `class="error"`) || !strings.Contains(body, `value="javascript:alert(1)"`) {
52 t.Fatalf("error does not keep the submitted website:\n%s", body)
53 }
54
4255 post(url.Values{"field": {"description"}, "description": {"a fine tool"}})
4356 post(url.Values{"field": {"website"}, "website": {"https://tool.example"}})
44 post(url.Values{"field": {"topics"}, "add": {"cli forge"}})
4557 post(url.Values{"field": {"require-checks"}, "require-checks": {"on"}})
4658 post(url.Values{"field": {"require-approvals"}, "approvals": {"2"}})
4759 post(url.Values{"field": {"protect"}, "branch": {"main"}})
4860
4961 out, _, _ := inst.ssh(t, aliceKey, "", "repo", "show", "alice/app", "--json")
50 for _, want := range []string{"a fine tool", "https://tool.example", `"cli"`, `"forge"`} {
62 for _, want := range []string{"a fine tool", "https://tool.example", `"forge"`} {
5163 if !strings.Contains(out, want) {
5264 t.Fatalf("repo show missing %q:\n%s", want, out)
5365 }
@@ -83,12 +95,9 @@ func TestRepoSettingsWeb(t *testing.T) {
8395 t.Fatalf("still archived:\n%s", out)
8496 }
8597
86 // Unprotecting works, and a refusal surfaces the command's message.
98 // Unprotecting works.
8799 post(url.Values{"field": {"unprotect"}, "branch": {"main"}})
88100 if out, _, _ := inst.ssh(t, aliceKey, "", "repo", "settings", "show", "alice/app", "--json"); strings.Contains(out, `"protected_branches"`) {
89101 t.Fatalf("branch still protected:\n%s", out)
90102 }
91 if body := post(url.Values{"field": {"website"}, "website": {"javascript:alert(1)"}}); !strings.Contains(body, `class="error"`) {
92 t.Fatalf("bad website accepted:\n%s", body)
93 }
94103}
internal/httpd/settings.go +109 −9
@@ -3,6 +3,8 @@ package httpd
33import (
44 "fmt"
55 "net/http"
6 "net/url"
7 "slices"
68 "strings"
79
810 "gitbay.org/gitbay/internal/control"
@@ -23,9 +25,18 @@ type settingsPage struct {
2325 Deps control.DepsOut
2426 Runners []store.RepoRunner
2527 Notice string
28 Saved bool
29 Submitted map[string]string
2630}
2731
2832func (s *Server) settingsForm(w http.ResponseWriter, r *http.Request, u store.User) {
33 s.settingsFormWith(w, r, u, s.takeFlash(w, r), nil)
34}
35
36// settingsFormWith renders the page with the given notice. submitted is
37// nil on a plain GET; on a failed POST it carries the values the visitor
38// typed, so a rejected value is not silently dropped.
39func (s *Server) settingsFormWith(w http.ResponseWriter, r *http.Request, u store.User, notice string, submitted url.Values) {
2940 repo, ok := s.repoForUser(w, r, u, policyCanAdmin)
3041 if !ok {
3142 return
@@ -44,11 +55,21 @@ func (s *Server) settingsForm(w http.ResponseWriter, r *http.Request, u store.Us
4455 s.runControlInto(u, []string{"repo", "deps", "status", repo.Path()}, &deps)
4556 var runners []store.RepoRunner
4657 s.runControlInto(u, []string{"repo", "runner", "list", repo.Path()}, &runners)
58 var subm map[string]string
59 if submitted != nil {
60 subm = map[string]string{
61 "description": submitted.Get("description"),
62 "website": submitted.Get("website"),
63 "topics": submitted.Get("topics"),
64 }
65 }
4766 s.render(w, "settings.html", settingsPage{
4867 repoPage: p, Topics: topics, Branches: branches,
4968 DepsEnabled: deps.Enabled, Deps: deps,
50 Runners: runners,
51 Notice: s.takeFlash(w, r),
69 Runners: runners,
70 Notice: notice,
71 Saved: strings.HasPrefix(notice, "Saved "),
72 Submitted: subm,
5273 })
5374}
5475
@@ -63,9 +84,10 @@ func (s *Server) settingsRedirect(w http.ResponseWriter, r *http.Request, msg st
6384func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
6485 repo := r.PathValue("owner") + "/" + r.PathValue("repo")
6586 v := func(k string) string { return strings.TrimSpace(r.FormValue(k)) }
87 field := r.FormValue("field")
6688
6789 var argv []string
68 switch r.FormValue("field") {
90 switch field {
6991 case "description":
7092 argv = []string{"repo", "settings", "description", repo, v("description")}
7193 case "website":
@@ -109,12 +131,45 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.
109131 }
110132 argv = []string{"repo", verb, repo}
111133 case "topics":
112 if add := strings.Fields(v("add")); len(add) > 0 {
134 row, err := s.st.RepoByPath(repo)
135 if err != nil {
136 http.NotFound(w, r)
137 return
138 }
139 want := map[string]bool{}
140 var order []string
141 for _, t := range strings.Split(v("topics"), ",") {
142 if t = strings.ToLower(strings.TrimSpace(t)); t != "" && !want[t] {
143 want[t] = true
144 order = append(order, t)
145 }
146 }
147 have, err := s.st.ListTopics(row.ID)
148 if err != nil {
149 s.settingsRedirect(w, r, err.Error())
150 return
151 }
152 var add, remove []string
153 for _, t := range order {
154 if !slices.Contains(have, t) {
155 add = append(add, t)
156 }
157 }
158 for _, t := range have {
159 if !want[t] {
160 remove = append(remove, t)
161 }
162 }
163 if len(remove) > 0 {
164 if _, msg, ok := s.runControl(u, append([]string{"repo", "topics", "remove", repo}, remove...)); !ok {
165 s.settingsFormWith(w, r, u, msg, r.Form)
166 return
167 }
168 }
169 if len(add) > 0 {
113170 argv = append([]string{"repo", "topics", "add", repo}, add...)
114 } else if rm := strings.Fields(v("remove")); len(rm) > 0 {
115 argv = append([]string{"repo", "topics", "remove", repo}, rm...)
116171 } else {
117 s.settingsRedirect(w, r, "name at least one topic")
172 s.settingsRedirect(w, r, "Saved the topics.")
118173 return
119174 }
120175 case "runner-add":
@@ -138,9 +193,54 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.
138193
139194 _, msg, ok := s.runControl(u, argv)
140195 if ok {
141 msg = ""
196 s.settingsRedirect(w, r, "Saved the "+fieldLabel(field)+".")
197 return
198 }
199 s.settingsFormWith(w, r, u, msg, r.Form)
200}
201
202// fieldLabel names a settings field for the saved flash and, on
203// rejection, the error notice — lower case, matching the label beside
204// its control.
205func fieldLabel(field string) string {
206 switch field {
207 case "description":
208 return "description"
209 case "website":
210 return "website"
211 case "visibility":
212 return "visibility"
213 case "default-branch":
214 return "default branch"
215 case "git-daemon":
216 return "git:// serving"
217 case "require-checks":
218 return "required checks"
219 case "require-approvals":
220 return "approvals"
221 case "require-resolved":
222 return "review threads"
223 case "require-codeowners":
224 return "CODEOWNERS"
225 case "require-mr":
226 return "require-MR"
227 case "require-signed":
228 return "signed commits"
229 case "protect", "unprotect":
230 return "protected branch"
231 case "protect-tag", "unprotect-tag":
232 return "protected tag"
233 case "deps":
234 return "dependency scanning"
235 case "archive":
236 return "archive"
237 case "topics":
238 return "topics"
239 case "runner-add", "runner-remove":
240 return "runner"
241 default:
242 return field
142243 }
143 s.settingsRedirect(w, r, msg)
144244}
145245
146246// onOff normalises a checkbox to the on|off the commands take.
internal/web/templates/settings.html +80 −83
@@ -3,147 +3,141 @@
33{{define "content"}}
44{{$base := printf "/%s/%s/settings" .Repo.OwnerName .Repo.Name}}
55<h1>Settings</h1>
6{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
6{{if .Notice}}{{if .Saved}}<p class="notice" role="status">{{.Notice}}</p>{{else}}<p class="error" role="alert">{{.Notice}}</p>{{end}}{{end}}
7<nav class="sections" aria-label="Sections"><a href="#identity">Identity</a><a href="#access">Access</a><a href="#gates">Merge gates</a><a href="#branches">Protected branches</a><a href="#tags">Protected tags</a>{{if .DepsEnabled}}<a href="#deps">Dependencies</a>{{end}}<a href="#runners">Runners</a><a href="#lifecycle">Lifecycle</a></nav>
78
8<h2>Identity</h2>
9<section id="identity"><h2>Identity</h2>
910<form method="post" action="{{$base}}" class="setform">
1011 <input type="hidden" name="field" value="description">
11 <label for="description">Description</label>
12 <input type="text" id="description" name="description" value="{{.Desc}}" placeholder="one line, shown in listings">
13 <button type="submit" class="btn">Save description</button>
12 <div><label for="description">Description</label></div>
13 <div><input type="text" id="description" name="description" value="{{or (index .Submitted "description") .Desc}}" placeholder="one line, shown in listings"></div>
14 <div><button type="submit">Save</button></div>
1415</form>
1516<form method="post" action="{{$base}}" class="setform">
1617 <input type="hidden" name="field" value="website">
17 <label for="website">Website</label>
18 <input type="text" id="website" name="website" value="{{.Repo.Settings.Website}}" placeholder="https://example.org">
19 <button type="submit" class="btn">Save website</button>
18 <div><label for="website">Website</label></div>
19 <div><input type="text" id="website" name="website" value="{{or (index .Submitted "website") .Repo.Settings.Website}}" placeholder="https://example.org"></div>
20 <div><button type="submit" class="btn">Save</button></div>
2021</form>
21{{if .Branches}}<form method="post" action="{{$base}}" class="setform">
22 <input type="hidden" name="field" value="default-branch">
23 <label for="default-branch">Default branch</label>
24 <select id="default-branch" name="default-branch">
25 {{$cur := .Repo.DefaultBranch}}{{range .Branches}}<option value="{{.Name}}"{{if eq .Name $cur}} selected{{end}}>{{.Name}}</option>{{end}}
26 </select>
27 <button type="submit" class="btn">Save default branch</button>
28</form>
29{{end}}<form method="post" action="{{$base}}" class="setform stack">
22<form method="post" action="{{$base}}" class="setform">
3023 <input type="hidden" name="field" value="topics">
31 <label for="topics-add">Topics to add</label>
32 <input type="text" id="topics-add" name="add" placeholder="add, space-separated">
33 <label for="topics-remove">Topics to remove</label>
34 <input type="text" id="topics-remove" name="remove" placeholder="remove">
35 <button type="submit" class="btn">Apply</button>
24 <div><label for="topics">Topics</label><p class="hint">Comma separated, lower case.</p></div>
25 <div><input type="text" id="topics" name="topics" value="{{or (index .Submitted "topics") (join .Topics ", ")}}"></div>
26 <div><button type="submit" class="btn">Save</button></div>
3627</form>
37{{if .Topics}}<p class="meta">{{range .Topics}}<span class="chip topic">{{.}}</span> {{end}}</p>{{end}}
28{{if .Branches}}<form method="post" action="{{$base}}" class="setform">
29 <input type="hidden" name="field" value="default-branch">
30 <div><label for="default-branch">Default branch</label><p class="hint">What a clone checks out and what CI builds on trigger.</p></div>
31 <div><select id="default-branch" name="default-branch">{{$cur := .Repo.DefaultBranch}}{{range .Branches}}<option value="{{.Name}}"{{if eq .Name $cur}} selected{{end}}>{{.Name}}</option>{{end}}</select></div>
32 <div><button type="submit" class="btn">Save</button></div>
33</form>{{end}}
34</section>
3835
39<h2>Access</h2>
36<section id="access"><h2>Access</h2>
4037<form method="post" action="{{$base}}" class="setform">
4138 <input type="hidden" name="field" value="visibility">
42 <label for="visibility">Visibility</label>
43 <select id="visibility" name="visibility">
44 <option value="public"{{if eq .Repo.Visibility "public"}} selected{{end}}>Public</option>
45 <option value="private"{{if eq .Repo.Visibility "private"}} selected{{end}}>Private</option>
46 </select>
47 <button type="submit" class="btn">Save visibility</button>
39 <div><label>Visibility</label><p class="hint">Private repositories answer not found to everyone without access, including in search and on your profile.</p></div>
40 <div class="check">
41 <label><input type="radio" name="visibility" value="public"{{if eq .Repo.Visibility "public"}} checked{{end}}> Public</label>
42 <label><input type="radio" name="visibility" value="private"{{if eq .Repo.Visibility "private"}} checked{{end}}> Private</label>
43 </div>
44 <div><button type="submit" class="btn">Save</button></div>
4845</form>
4946<form method="post" action="{{$base}}" class="setform">
5047 <input type="hidden" name="field" value="git-daemon">
51 <label for="git-daemon">Serve over git://</label>
52 <input type="checkbox" id="git-daemon" name="git-daemon" value="on"{{if .Repo.Settings.GitDaemon}} checked{{end}}>
53 <button type="submit" class="btn">Save git://</button>
48 <div><label for="git-daemon">Serve over git://</label><p class="hint">Unauthenticated, unencrypted read access on port 9418. Public repositories only.</p></div>
49 <div class="check"><input type="checkbox" id="git-daemon" name="git-daemon" value="on"{{if .Repo.Settings.GitDaemon}} checked{{end}}></div>
50 <div><button type="submit" class="btn">Save</button></div>
5451</form>
52</section>
5553
56<h2>Merge gates</h2>
54<section id="gates"><h2>Merge gates</h2>
5755<p class="meta">Checked before a merge, in this order: checks, approvals, resolved threads, signatures.</p>
5856<form method="post" action="{{$base}}" class="setform">
5957 <input type="hidden" name="field" value="require-checks">
60 <label for="require-checks">Require green checks</label>
61 <input type="checkbox" id="require-checks" name="require-checks" value="on"{{if .Repo.Settings.RequireChecks}} checked{{end}}>
62 <button type="submit" class="btn">Save checks</button>
58 <div><label for="require-checks">Required checks</label><p class="hint">A request waits until every CI job that would report on its head has succeeded.</p></div>
59 <div class="check"><input type="checkbox" id="require-checks" name="require-checks" value="on"{{if .Repo.Settings.RequireChecks}} checked{{end}}></div>
60 <div><button type="submit" class="btn">Save</button></div>
6361</form>
6462<form method="post" action="{{$base}}" class="setform">
6563 <input type="hidden" name="field" value="require-approvals">
66 <label for="approvals">Required approvals</label>
67 <input type="number" id="approvals" name="approvals" min="0" max="10" value="{{.Repo.Settings.RequireApprovals}}">
68 <button type="submit" class="btn">Save approvals</button>
64 <div><label for="approvals">Approvals</label><p class="hint">Approvals from people with write access. Zero means none required.</p></div>
65 <div><input type="number" id="approvals" name="approvals" min="0" max="10" value="{{.Repo.Settings.RequireApprovals}}"></div>
66 <div><button type="submit" class="btn">Save</button></div>
6967</form>
7068<form method="post" action="{{$base}}" class="setform">
7169 <input type="hidden" name="field" value="require-resolved">
72 <label for="require-resolved">Require resolved threads</label>
73 <input type="checkbox" id="require-resolved" name="require-resolved" value="on"{{if .Repo.Settings.RequireResolved}} checked{{end}}>
74 <button type="submit" class="btn">Save threads</button>
70 <div><label for="require-resolved">Review threads</label><p class="hint">Every thread on the diff must be resolved before merging.</p></div>
71 <div class="check"><input type="checkbox" id="require-resolved" name="require-resolved" value="on"{{if .Repo.Settings.RequireResolved}} checked{{end}}></div>
72 <div><button type="submit" class="btn">Save</button></div>
7573</form>
7674<form method="post" action="{{$base}}" class="setform">
7775 <input type="hidden" name="field" value="require-codeowners">
78 <label for="require-codeowners">Require CODEOWNERS approval</label>
79 <input type="checkbox" id="require-codeowners" name="require-codeowners" value="on"{{if .Repo.Settings.RequireCodeowners}} checked{{end}}>
80 <button type="submit" class="btn">Save CODEOWNERS</button>
76 <div><label for="require-codeowners">CODEOWNERS</label><p class="hint">Owners of every touched path must approve.</p></div>
77 <div class="check"><input type="checkbox" id="require-codeowners" name="require-codeowners" value="on"{{if .Repo.Settings.RequireCodeowners}} checked{{end}}></div>
78 <div><button type="submit" class="btn">Save</button></div>
8179</form>
8280<form method="post" action="{{$base}}" class="setform">
8381 <input type="hidden" name="field" value="require-signed">
84 <label for="require-signed">Require signed commits</label>
85 <input type="checkbox" id="require-signed" name="require-signed" value="on"{{if .Repo.Settings.RequireSignedCommits}} checked{{end}}>
86 <button type="submit" class="btn">Save signing</button>
82 <div><label for="require-signed">Signed commits</label><p class="hint">Every commit in the request must carry a verified signature. Squash and merge strategies are refused, since both mint an unsigned commit.</p></div>
83 <div class="check"><input type="checkbox" id="require-signed" name="require-signed" value="on"{{if .Repo.Settings.RequireSignedCommits}} checked{{end}}></div>
84 <div><button type="submit" class="btn">Save</button></div>
8785</form>
86</section>
8887
89<h2>Protected branches</h2>
88<section id="branches"><h2>Protected branches</h2>
9089{{if .Repo.Settings.ProtectedBranches}}
9190<ul class="protlist">
92{{range .Repo.Settings.ProtectedBranches}}<li><code>{{.}}</code>
91{{$mr := .Repo.Settings.RequireMR}}{{range .Repo.Settings.ProtectedBranches}}<li><code>{{.}}</code>{{if $mr}} <span class="hint">Direct pushes refused, merge requests only.</span>{{end}}
9392 <form method="post" action="{{$base}}" class="inline">
9493 <input type="hidden" name="field" value="unprotect">
9594 <input type="hidden" name="branch" value="{{.}}">
96 <button type="submit" class="linklike">Unprotect</button>
95 <button type="submit" class="danger">Unprotect</button>
9796 </form></li>
9897{{end}}
9998</ul>
10099{{else}}<p class="meta">No protected branches. A protected branch refuses deletion and force-pushes.</p>{{end}}
101100<form method="post" action="{{$base}}" class="setform">
102101 <input type="hidden" name="field" value="protect">
103 <label for="branch">Protect a branch</label>
104 <select id="branch" name="branch">
105 {{range .Branches}}<option value="{{.Name}}">{{.Name}}</option>{{end}}
106 </select>
107 <button type="submit" class="btn">Protect</button>
102 <div><label for="branch">Protect a branch</label></div>
103 <div><select id="branch" name="branch">{{range .Branches}}<option value="{{.Name}}">{{.Name}}</option>{{end}}</select></div>
104 <div><button type="submit" class="btn">Protect</button></div>
108105</form>
109106<form method="post" action="{{$base}}" class="setform">
110107 <input type="hidden" name="field" value="require-mr">
111 <label for="require-mr">Merge requests only</label>
112 <input type="checkbox" id="require-mr" name="require-mr" value="on"{{if .Repo.Settings.RequireMR}} checked{{end}}>
113 <button type="submit" class="btn">Save merge-only</button>
108 <div><label for="require-mr">Require merge requests</label><p class="hint">Every protected branch changes through a merge request. A direct push is refused in pre-receive.</p></div>
109 <div class="check"><input type="checkbox" id="require-mr" name="require-mr" value="on"{{if .Repo.Settings.RequireMR}} checked{{end}}></div>
110 <div><button type="submit" class="btn">Save</button></div>
114111</form>
115<p class="meta">With merge requests only, a protected branch refuses every direct push once it exists; the merge gates above are then what a change has to pass.</p>
112</section>
116113
117<h2>Protected tags</h2>
114<section id="tags"><h2>Protected tags</h2>
118115{{if .Repo.Settings.ProtectedTags}}
119116<ul class="protlist">
120117{{range .Repo.Settings.ProtectedTags}}<li><code>{{.}}</code>
121118 <form method="post" action="{{$base}}" class="inline">
122119 <input type="hidden" name="field" value="unprotect-tag">
123120 <input type="hidden" name="glob" value="{{.}}">
124 <button type="submit" class="linklike">Unprotect</button>
121 <button type="submit" class="danger">Unprotect</button>
125122 </form></li>
126123{{end}}
127124</ul>
128125{{else}}<p class="meta">No protected tags. A tag matching a protected glob is created once and refuses moves and deletion. A tag a release is anchored to refuses both regardless.</p>{{end}}
129126<form method="post" action="{{$base}}" class="setform">
130127 <input type="hidden" name="field" value="protect-tag">
131 <label for="glob">Protect tags matching</label>
132 <input type="text" id="glob" name="glob" placeholder="v*">
133 <button type="submit" class="btn">Protect</button>
128 <div><label for="glob">Protect tags matching</label></div>
129 <div><input type="text" id="glob" name="glob" placeholder="v*"></div>
130 <div><button type="submit" class="btn">Protect</button></div>
134131</form>
132</section>
135133
136<h2>Dependencies</h2>
134<section id="deps"><h2>Dependencies</h2>
137135<form method="post" action="{{$base}}" class="setform">
138136 <input type="hidden" name="field" value="deps">
139 <label for="deps">Check for updates</label>
140 <input type="checkbox" id="deps" name="deps" value="on"{{if .DepsEnabled}} checked{{end}}>
141 <button type="submit" class="btn">Save dependency checks</button>
142</form>
143<p class="meta">Compares the manifests on <code>{{.Repo.DefaultBranch}}</code> against
144proxy.golang.org, npm, crates.io, and PyPI once a day, and tracks what is behind
145in an issue. Checking a private repository tells those registries what it
146depends on.</p>
137 <div><label for="deps">Check for updates</label><p class="hint">Compares the manifests on <code>{{.Repo.DefaultBranch}}</code> against proxy.golang.org, npm, crates.io, and PyPI once a day, and tracks what is behind in an issue. Checking a private repository tells those registries what it depends on.</p></div>
138 <div class="check"><input type="checkbox" id="deps" name="deps" value="on"{{if .DepsEnabled}} checked{{end}}></div>
139 <div><button type="submit" class="btn">Save</button></div>
140</form>
147141{{if .DepsEnabled}}
148142<p class="meta">Last checked {{if .Deps.LastCheck}}{{when .Deps.LastCheck}}{{else}}never{{end}}{{if .Deps.IssueNumber}} · tracked in <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Deps.IssueNumber}}">#{{.Deps.IssueNumber}}</a>{{end}}</p>
149143{{if .Deps.LastError}}<p class="error" role="alert">{{.Deps.LastError}}</p>{{end}}
@@ -157,34 +151,37 @@ depends on.</p>
157151{{end}}</table></div>
158152{{else}}<p class="none">Nothing behind{{if not .Deps.LastCheck}} — the first check has not run yet{{end}}.</p>{{end}}
159153{{end}}
154</section>
160155
161<h2>Runners</h2>
156<section id="runners"><h2>Runners</h2>
162157{{if .Runners}}
163158<ul class="protlist">
164159{{range .Runners}}<li><code>{{.Fingerprint}}</code> <span class="meta">{{.Username}}{{if .LastSeen}}, last poll {{when .LastSeen}}{{else}}, never polled{{end}}{{if .BuildNumber}}, running {{.BuildRepo}} #{{.BuildNumber}} {{.BuildJob}}{{end}}</span>
165160 <form method="post" action="{{$base}}" class="inline">
166161 <input type="hidden" name="field" value="runner-remove">
167162 <input type="hidden" name="fingerprint" value="{{.Fingerprint}}">
168 <button type="submit" class="linklike">Detach</button>
163 <button type="submit" class="danger">Detach</button>
169164 </form></li>
170165{{end}}
171166</ul>
172167{{else}}<p class="meta">No runners attached. Builds for this repository run on the runners attached here; a repository with none queues builds nothing claims.</p>{{end}}
173<form method="post" action="{{$base}}" class="setform">
168<form method="post" action="{{$base}}" class="setform stack">
174169 <input type="hidden" name="field" value="runner-add">
175170 <label for="runner-key">Attach a runner</label>
171 <p class="hint">Install <code>gitbay-runner</code>, run <code>gitbay-runner init</code>, and paste the key it prints. The runner builds your commits with the repository's secrets; merge requests from forks wait unless it runs with <code>-untrusted</code>.</p>
176172 <textarea id="runner-key" name="key" rows="3" placeholder="ssh-ed25519 AAAA… (from gitbay-runner init)"></textarea>
177173 <button type="submit" class="btn">Attach</button>
178174</form>
179<p class="meta">Install <code>gitbay-runner</code>, run <code>gitbay-runner init</code>, and paste the key it prints. The runner builds your commits with the repository's secrets; merge requests from forks wait unless it runs with <code>-untrusted</code>.</p>
175</section>
180176
181<h2>Lifecycle</h2>
177<section id="lifecycle"><h2>Lifecycle</h2>
182178<form method="post" action="{{$base}}" class="setform">
183179 <input type="hidden" name="field" value="archive">
184 <label for="archive">Archived (read-only)</label>
185 <input type="checkbox" id="archive" name="archive" value="on"{{if .Repo.Settings.Archived}} checked{{end}}>
186 <button type="submit" class="btn">Save archive</button>
180 <div><label for="archive">Archived</label><p class="hint">Read-only for everyone. Issues and requests close to new activity. Reversible.</p></div>
181 <div class="check"><input type="checkbox" id="archive" name="archive" value="on"{{if .Repo.Settings.Archived}} checked{{end}}></div>
182 <div>{{template "confirmfield" .Repo.Name}} <button type="submit" class="danger">Save</button></div>
187183</form>
188184<p class="meta">Deleting or transferring a repository is a CLI operation:
189185<code>gitbay repo delete {{.Repo.OwnerName}}/{{.Repo.Name}} --yes</code></p>
186</section>
190187{{end}}
internal/web/web.go +1
@@ -62,6 +62,7 @@ var fullVersion = sync.OnceValue(func() string {
6262var funcs = template.FuncMap{
6363 "gitbayVersion": func() string { return version() },
6464 "gitbayCommit": func() string { return fullVersion() },
65 "join": strings.Join,
6566 // paragraphs splits plain text on blank lines for safe rich display.
6667 "paragraphs": func(s string) []string {
6768 var out []string