Three web parity gaps: markup picker, profile form, build cancel !269

merged merged by cmc on 2026-09-05 19:14 UTC · krz/gitbay:web-parity-small into main

15 files changed, +469 −20

Layout: unified · split

e2e/buildcancelweb_test.go added +124
@@ -0,0 +1,124 @@
1package e2e
2
3import (
4 "net/url"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11// TestBuildCancelWeb covers cancelling a build from its page (#162). The
12// control is offered for anything the command accepts — queued or running —
13// and hidden once a build reaches a terminal state; the command decides for
14// real, so a stale or repeated post against a build that is no longer
15// cancellable shows the refusal rather than a broken page.
16func TestBuildCancelWeb(t *testing.T) {
17 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
18 aliceKey := inst.newKey(t, "alice")
19 inst.admin(t, "admin", "user", "create", "alice",
20 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
21
22 // ci is an ordinary account; its runner key is self-added with
23 // --scope runner, which confines it to the runner protocol and
24 // read-only git rather than reaching for admin.
25 ciKey := inst.newKey(t, "ci")
26 inst.admin(t, "admin", "user", "create", "ci", "--key", ciKey+".pub")
27 runnerKey := inst.newKey(t, "ci-runner")
28 pub, _ := os.ReadFile(runnerKey + ".pub")
29 if _, errOut, code := inst.ssh(t, ciKey, string(pub), "keys", "add", "--scope", "runner"); code != 0 {
30 t.Fatalf("keys add --scope runner: %s", errOut)
31 }
32 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
33 t.Fatalf("repo create: %s", errOut)
34 }
35 work := t.TempDir()
36 env := inst.gitEnv(aliceKey)
37 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
38 dir := filepath.Join(work, "w")
39 os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
40 os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte("jobs:\n unit:\n steps:\n - echo fine\n"), 0o644)
41 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
42 mustGit(t, dir, env, "add", ".")
43 mustGit(t, dir, env, "commit", "-q", "-m", "ci")
44 mustGit(t, dir, env, "push", "-q", "origin", "main")
45
46 alice := inst.login(t, aliceKey)
47 build1 := inst.base() + "/alice/app/builds/1"
48
49 // Build 1 is queued: the control is on the page, for someone with
50 // write access.
51 _, body := browserGet(t, alice, build1)
52 if !strings.Contains(body, `action="/alice/app/builds/1/cancel"`) {
53 t.Fatalf("no cancel control on a queued build:\n%s", body)
54 }
55 // A reader gets no control.
56 _, anon := browserGet(t, newBrowser(t), build1)
57 if strings.Contains(anon, "/cancel") {
58 t.Fatal("anonymous visitor sees the cancel control")
59 }
60
61 // Cancelling from the page lands where the CLI sees it.
62 if status, _ := browserPost(t, alice, build1+"/cancel", url.Values{}); status != 200 {
63 t.Fatalf("cancel post: %d", status)
64 }
65 if out, _, _ := inst.ssh(t, aliceKey, "", "build", "list", "alice/app"); !strings.Contains(out, "unit\tcancelled") {
66 t.Fatalf("build not cancelled: %s", out)
67 }
68 // Cancelled, so the control is gone.
69 if _, body = browserGet(t, alice, build1); strings.Contains(body, "/cancel") {
70 t.Fatalf("cancel control still on a cancelled build:\n%s", body)
71 }
72
73 // Build 2: queued, then claimed by a runner without one actually
74 // running any steps — enough to move it to "running".
75 if _, errOut, code := inst.ssh(t, aliceKey, "", "build", "trigger", "alice/app", "unit"); code != 0 {
76 t.Fatalf("trigger: %s", errOut)
77 }
78 if out, _, code := inst.ssh(t, runnerKey, "", "runner", "next", "alice/app"); code != 0 || strings.Contains(out, "no pending") {
79 t.Fatalf("runner claim: %s", out)
80 }
81 build2 := inst.base() + "/alice/app/builds/2"
82 _, body = browserGet(t, alice, build2)
83 if !strings.Contains(body, "running") {
84 t.Fatalf("build 2 not running:\n%s", body)
85 }
86 // The command accepts cancelling a running build too — that is the
87 // case #162 was filed for, a run going nowhere — so the control stays
88 // up, worded so cancelling does not read as instant.
89 if !strings.Contains(body, `action="/alice/app/builds/2/cancel"`) {
90 t.Fatalf("no cancel control on a running build:\n%s", body)
91 }
92 if !strings.Contains(body, "next check") {
93 t.Fatalf("cancel control does not warn it is not instant:\n%s", body)
94 }
95
96 // Cancelling the running build from the page lands where the CLI sees
97 // it, including the runner-facing wording that it stops at its next
98 // check rather than right away.
99 if status, _ := browserPost(t, alice, build2+"/cancel", url.Values{}); status != 200 {
100 t.Fatalf("cancel running build: %d", status)
101 }
102 out, _, _ := inst.ssh(t, aliceKey, "", "build", "list", "alice/app")
103 if !strings.Contains(out, "unit\tcancelled") {
104 t.Fatalf("running build not cancelled: %s", out)
105 }
106 log, _, _ := inst.ssh(t, aliceKey, "", "build", "log", "alice/app", "2")
107 if !strings.Contains(log, "cancelled by alice while running") {
108 t.Fatalf("log missing the while-running cancellation: %s", log)
109 }
110 // Cancelled, so the control is gone here too.
111 if _, body = browserGet(t, alice, build2); strings.Contains(body, "/cancel") {
112 t.Fatalf("cancel control still on a cancelled build:\n%s", body)
113 }
114
115 // A stale or repeated post against a build that can no longer be
116 // cancelled is refused, and the page says so rather than breaking.
117 status, body := browserPost(t, alice, build1+"/cancel", url.Values{})
118 if status != 200 {
119 t.Fatalf("re-cancel post: %d", status)
120 }
121 if !strings.Contains(body, `class="error"`) || !strings.Contains(body, "cancelled") {
122 t.Fatalf("refusal not surfaced on the page:\n%s", body)
123 }
124}
e2e/formatpickerweb_test.go added +78
@@ -0,0 +1,78 @@
1package e2e
2
3import (
4 "net/url"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11// TestIssueMRWebFormat covers the format picker on the issue and merge
12// request create forms (#160). Neither form offered --format before; a
13// browser session could only ever write markdown.
14func TestIssueMRWebFormat(t *testing.T) {
15 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
16 aliceKey := inst.newKey(t, "alice")
17 inst.admin(t, "admin", "user", "create", "alice",
18 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
19 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
20 t.Fatalf("repo create: %s", errOut)
21 }
22 env := inst.gitEnv(aliceKey)
23 work := t.TempDir()
24 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
25 dir := filepath.Join(work, "w")
26 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
27 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
28 mustGit(t, dir, env, "add", ".")
29 mustGit(t, dir, env, "commit", "-q", "-m", "base")
30 mustGit(t, dir, env, "push", "-q", "origin", "main")
31 mustGit(t, dir, env, "checkout", "-q", "-b", "topic")
32 os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644)
33 mustGit(t, dir, env, "add", ".")
34 mustGit(t, dir, env, "commit", "-q", "-m", "topic")
35 mustGit(t, dir, env, "push", "-q", "origin", "topic")
36
37 alice := inst.login(t, aliceKey)
38 base := inst.base() + "/alice/app"
39 const orgBody = "Some /emphasis/ here.\n"
40
41 // The picker is on both forms, defaulting to Markdown.
42 for _, form := range []string{base + "/issues/new", base + "/mrs/new"} {
43 if _, body := browserGet(t, alice, form); !strings.Contains(body, `name="format"`) ||
44 !strings.Contains(body, `<option value="md" selected>`) {
45 t.Fatalf("no format picker (defaulting to md) on %s:\n%s", form, body)
46 }
47 }
48
49 // An issue written with the org choice renders as org, not markdown.
50 if status, _ := browserPost(t, alice, base+"/issues/new", url.Values{
51 "title": {"org issue"}, "body": {orgBody}, "format": {"org"},
52 }); status != 200 {
53 t.Fatalf("issue create: %d", status)
54 }
55 if _, body := browserGet(t, alice, base+"/issues/1"); !strings.Contains(body, "<em>emphasis</em>") {
56 t.Fatalf("issue body did not render as org:\n%s", body)
57 }
58
59 // A merge request written with the org choice renders as org too.
60 if status, _ := browserPost(t, alice, base+"/mrs/new", url.Values{
61 "source": {"topic"}, "target": {"main"}, "title": {"org mr"}, "body": {orgBody}, "format": {"org"},
62 }); status != 200 {
63 t.Fatalf("mr create: %d", status)
64 }
65 if _, body := browserGet(t, alice, base+"/mrs/1"); !strings.Contains(body, "<em>emphasis</em>") {
66 t.Fatalf("mr body did not render as org:\n%s", body)
67 }
68
69 // A form left on the default choice still writes markdown.
70 if status, _ := browserPost(t, alice, base+"/issues/new", url.Values{
71 "title": {"md issue"}, "body": {orgBody}, "format": {"md"},
72 }); status != 200 {
73 t.Fatalf("issue create: %d", status)
74 }
75 if _, body := browserGet(t, alice, base+"/issues/2"); strings.Contains(body, "<em>emphasis</em>") {
76 t.Fatalf("issue body rendered as org despite the md choice:\n%s", body)
77 }
78}
e2e/profileweb_test.go added +110
@@ -0,0 +1,110 @@
1package e2e
2
3import (
4 "net/url"
5 "strings"
6 "testing"
7)
8
9// TestProfileSettingsWeb covers profile set from the account settings page
10// (#161): description, website, links and about round-trip through the
11// form, and emptying a field actually clears it rather than being skipped.
12func TestProfileSettingsWeb(t *testing.T) {
13 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
14 aliceKey := inst.newKey(t, "alice")
15 inst.admin(t, "admin", "user", "create", "alice",
16 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
17 alice := inst.login(t, aliceKey)
18 settingsURL := inst.base() + "/settings"
19
20 // The form is on the page, every input labelled.
21 _, body := browserGet(t, alice, settingsURL)
22 for _, want := range []string{
23 `<label for="p-description">`, `<label for="p-website">`,
24 `<label for="p-links">`, `<label for="p-about">`, `<label for="format">`,
25 } {
26 if !strings.Contains(body, want) {
27 t.Fatalf("profile form missing %q:\n%s", want, body)
28 }
29 }
30
31 // Setting every field lands where the CLI reads it.
32 status, _ := browserPost(t, alice, settingsURL, url.Values{
33 "field": {"profile"},
34 "description": {"builds small tools"},
35 "website": {"https://alice.example"},
36 "links": {"Mastodon|https://fosstodon.example/@alice\nhttps://alice.example/now"},
37 "about": {"hello there"},
38 "format": {"md"},
39 })
40 if status != 200 && status != 303 {
41 t.Fatalf("profile post: %d", status)
42 }
43 out, _, _ := inst.ssh(t, aliceKey, "", "profile", "show", "--json")
44 for _, want := range []string{
45 `"description":"builds small tools"`, `"website":"https://alice.example"`,
46 `"label":"Mastodon"`, `"url":"https://fosstodon.example/@alice"`,
47 `"url":"https://alice.example/now"`, `"about":"hello there"`,
48 } {
49 if !strings.Contains(out, want) {
50 t.Fatalf("profile set missing %q: %s", want, out)
51 }
52 }
53
54 // The page shows what was just saved.
55 _, body = browserGet(t, alice, settingsURL)
56 for _, want := range []string{
57 "builds small tools", "https://alice.example", "Mastodon|https://fosstodon.example/@alice",
58 "https://alice.example/now", "hello there",
59 } {
60 if !strings.Contains(body, want) {
61 t.Fatalf("settings page did not round-trip %q:\n%s", want, body)
62 }
63 }
64
65 // The whole form resubmits every time, the way a settings page does:
66 // each step below carries the fields already in place and changes one.
67 links := "Mastodon|https://fosstodon.example/@alice\nhttps://alice.example/now"
68
69 // The org choice is honoured on the profile page.
70 if status, _ := browserPost(t, alice, settingsURL, url.Values{
71 "field": {"profile"}, "description": {"builds small tools"},
72 "website": {"https://alice.example"}, "links": {links},
73 "about": {"a /note/ in org"}, "format": {"org"},
74 }); status != 200 && status != 303 {
75 t.Fatalf("profile post (org): %d", status)
76 }
77 if _, page := browserGet(t, alice, inst.base()+"/alice"); !strings.Contains(page, "<em>note</em>") {
78 t.Fatalf("about did not render as org:\n%s", page)
79 }
80
81 // Emptying the website clears it, not leaves it alone.
82 if status, _ := browserPost(t, alice, settingsURL, url.Values{
83 "field": {"profile"}, "description": {"builds small tools"},
84 "website": {""}, "links": {links}, "about": {"a /note/ in org"}, "format": {"org"},
85 }); status != 200 && status != 303 {
86 t.Fatalf("profile post (clear website): %d", status)
87 }
88 out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "--json")
89 if strings.Contains(out, "alice.example") && strings.Contains(out, `"website"`) {
90 t.Fatalf("website not cleared: %s", out)
91 }
92 if !strings.Contains(out, "builds small tools") {
93 t.Fatalf("clearing website clobbered the description: %s", out)
94 }
95 if !strings.Contains(out, `"label":"Mastodon"`) {
96 t.Fatalf("clearing website clobbered the links: %s", out)
97 }
98
99 // Emptying the links field clears the whole list.
100 if status, _ := browserPost(t, alice, settingsURL, url.Values{
101 "field": {"profile"}, "description": {"builds small tools"},
102 "links": {""}, "about": {"a /note/ in org"}, "format": {"org"},
103 }); status != 200 && status != 303 {
104 t.Fatalf("profile post (clear links): %d", status)
105 }
106 out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "--json")
107 if strings.Contains(out, "Mastodon") || strings.Contains(out, `"links"`) {
108 t.Fatalf("links not cleared: %s", out)
109 }
110}
internal/httpd/account.go +67 −10
@@ -6,6 +6,7 @@ import (
66 "net/url"
77 "strings"
88
9 "gitbay.org/gitbay/internal/control"
910 "gitbay.org/gitbay/internal/store"
1011)
1112
@@ -46,21 +47,58 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use
4647 }
4748 emails, _ := s.st.ListEmails(u.ID)
4849
50 var profile control.ProfileOut
51 s.runControlInto(u, []string{"profile", "show"}, &profile)
52
4953 s.render(w, "account.html", struct {
5054 basePage
51 Tab string // marks the rail's Settings row as current
52 Keys []accountKey
53 PGP []accountPGP
54 Emails []store.Email
55 Host string
56 Notice string
57 Message string
58 }{s.baseFor(u), "account", keys, pgp, emails, s.cfg.SiteHost(),
55 Tab string // marks the rail's Settings row as current
56 Keys []accountKey
57 PGP []accountPGP
58 Emails []store.Email
59 Profile control.ProfileOut
60 LinksText string
61 Host string
62 Notice string
63 Message string
64 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), s.cfg.SiteHost(),
5965 s.takeFlash(w, r), r.URL.Query().Get("m")})
6066}
6167
62// accountSubmit routes the account forms to their commands. Everything
63// here is a public key or an address — no secret is accepted over the web.
68// profileLinksText turns a profile's links into the form the textarea
69// shows and reads back: one per line, "label|url" when there is a label
70// and the bare url otherwise.
71func profileLinksText(links []store.ProfileLink) string {
72 lines := make([]string, len(links))
73 for i, l := range links {
74 if l.Label != "" {
75 lines[i] = l.Label + "|" + l.URL
76 } else {
77 lines[i] = l.URL
78 }
79 }
80 return strings.Join(lines, "\n")
81}
82
83// profileLinkArgs turns the textarea back into the --link values profile
84// set expects: one per non-blank line, or a single empty one to clear the
85// list when the field was emptied.
86func profileLinkArgs(raw string) []string {
87 var links []string
88 for _, line := range strings.Split(raw, "\n") {
89 if line = strings.TrimSpace(line); line != "" {
90 links = append(links, line)
91 }
92 }
93 if links == nil {
94 return []string{""}
95 }
96 return links
97}
98
99// accountSubmit routes the account forms to their commands. Keys,
100// addresses and the profile are the whole surface — no secret is accepted
101// over the web.
64102func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
65103 back := func(msg, note string) {
66104 q := ""
@@ -122,6 +160,25 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U
122160 return
123161 }
124162 back("", "address verified")
163 case "profile":
164 format := r.FormValue("format")
165 if format != "org" {
166 format = "md"
167 }
168 argv := []string{"profile", "set",
169 "--description", r.FormValue("description"),
170 "--website", r.FormValue("website"),
171 "--about-format", format,
172 "--file", "-",
173 }
174 for _, link := range profileLinkArgs(r.FormValue("links")) {
175 argv = append(argv, "--link", link)
176 }
177 if msg, ok := s.runControlStdin(u, argv, r.FormValue("about")); !ok {
178 back(msg, "")
179 return
180 }
181 back("", "profile updated")
125182 default:
126183 back("unknown form", "")
127184 }
internal/httpd/accounts.go +12 −2
@@ -311,12 +311,17 @@ func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store
311311 }
312312 }
313313 }
314 format := r.URL.Query().Get("format")
315 if format != "org" {
316 format = "md"
317 }
314318 s.render(w, "issuenew.html", struct {
315319 repoPage
316320 Body string
321 Format string
317322 Template string
318323 Templates []control.IssueTemplate
319 }{p, body, tplName, templates})
324 }{p, body, format, tplName, templates})
320325}
321326
322327// Issue and merge request writes run the command the CLI runs, so the
@@ -326,8 +331,13 @@ func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store
326331func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
327332 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
328333 title := strings.TrimSpace(r.FormValue("title"))
334 format := r.FormValue("format")
335 if format != "org" {
336 format = "md"
337 }
329338 var created control.Created
330 code, msg := s.dispatchIntoStdin(u, []string{"issue", "create", repoPath, "--title", title, "--file", "-"}, r.FormValue("body"), &created)
339 argv := []string{"issue", "create", repoPath, "--title", title, "--format", format, "--file", "-"}
340 code, msg := s.dispatchIntoStdin(u, argv, r.FormValue("body"), &created)
331341 if code != protocol.ExitOK {
332342 http.Error(w, msg, statusForExit(code))
333343 return
internal/httpd/buildpages_test.go +9 −2
@@ -55,8 +55,10 @@ func TestBuildPageRendersCommandOutput(t *testing.T) {
5555 var sb strings.Builder
5656 err := web.Render(&sb, "build.html", struct {
5757 repoPage
58 Build control.BuildOut
59 Log string
58 Build control.BuildOut
59 Log string
60 CanWrite bool
61 Notice string
6062 }{
6163 testRepoPage(),
6264 control.BuildOut{
@@ -65,6 +67,7 @@ func TestBuildPageRendersCommandOutput(t *testing.T) {
6567 CreatedAt: "2026-08-28T04:42:54Z", FinishedAt: "2026-08-28T04:43:06Z",
6668 },
6769 "step 1 ok",
70 true, "",
6871 })
6972 if err != nil {
7073 t.Fatalf("render: %v", err)
@@ -78,4 +81,8 @@ func TestBuildPageRendersCommandOutput(t *testing.T) {
7881 t.Errorf("build.html missing %q", want)
7982 }
8083 }
84 // A finished build offers no cancel control, even to a writer.
85 if strings.Contains(out, "/cancel") {
86 t.Error("build.html offers cancel on a finished build")
87 }
8188}
internal/httpd/builds.go +5 −3
@@ -54,7 +54,9 @@ func (s *Server) build(w http.ResponseWriter, r *http.Request) {
5454
5555 s.render(w, "build.html", struct {
5656 repoPage
57 Build control.BuildOut
58 Log string
59 }{p, b, log})
57 Build control.BuildOut
58 Log string
59 CanWrite bool
60 Notice string
61 }{p, b, log, s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
6062}
internal/httpd/mractions.go +12 −3
@@ -151,6 +151,7 @@ type mrNewPage struct {
151151 Target string
152152 Title string
153153 Body string
154 Format string
154155 Notice string
155156}
156157
@@ -166,10 +167,14 @@ func (s *Server) mrCreateForm(w http.ResponseWriter, r *http.Request, u store.Us
166167 if target == "" {
167168 target = p.Repo.DefaultBranch
168169 }
170 format := q.Get("format")
171 if format != "org" {
172 format = "md"
173 }
169174 s.render(w, "mrnew.html", mrNewPage{
170175 repoPage: p, Branches: branches,
171176 Source: q.Get("source"), Target: target,
172 Title: q.Get("title"), Body: q.Get("body"), Notice: s.takeFlash(w, r),
177 Title: q.Get("title"), Body: q.Get("body"), Format: format, Notice: s.takeFlash(w, r),
173178 })
174179}
175180
@@ -182,9 +187,13 @@ func (s *Server) mrCreateSubmit(w http.ResponseWriter, r *http.Request, u store.
182187 target := strings.TrimSpace(r.FormValue("target"))
183188 title := strings.TrimSpace(r.FormValue("title"))
184189 body := strings.TrimSpace(r.FormValue("body"))
190 format := r.FormValue("format")
191 if format != "org" {
192 format = "md"
193 }
185194
186195 back := func(msg string) {
187 q := url.Values{"source": {source}, "target": {target}, "title": {title}, "body": {body}}
196 q := url.Values{"source": {source}, "target": {target}, "title": {title}, "body": {body}, "format": {format}}
188197 s.setFlash(w, msg)
189198 http.Redirect(w, r, fmt.Sprintf("/%s/mrs/new?%s", p.Repo.Path(), q.Encode()), http.StatusSeeOther)
190199 }
@@ -192,7 +201,7 @@ func (s *Server) mrCreateSubmit(w http.ResponseWriter, r *http.Request, u store.
192201 back("pick a source branch and give the merge request a title")
193202 return
194203 }
195 argv := []string{"mr", "create", p.Repo.Path(), "--source", source, "--title", title}
204 argv := []string{"mr", "create", p.Repo.Path(), "--source", source, "--title", title, "--format", format}
196205 if target != "" {
197206 argv = append(argv, "--target", target)
198207 }
internal/httpd/releaseactions.go +14
@@ -53,3 +53,17 @@ func (s *Server) buildTriggerSubmit(w http.ResponseWriter, r *http.Request, u st
5353 _, msg, code := s.runControlCode(u, []string{"build", "trigger", repo, job})
5454 s.done(w, r, code, msg, func(w http.ResponseWriter, r *http.Request, msg string) { s.backTo(w, r, "builds", msg) })
5555}
56
57// buildCancelSubmit withdraws a build. The page only offers the control
58// while a build is still queued; the command decides for real, so a stale
59// page posting against a build that has since finished sees the refusal
60// instead of a silent no-op.
61func (s *Server) buildCancelSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
62 repo := r.PathValue("owner") + "/" + r.PathValue("repo")
63 n := r.PathValue("n")
64 back := func(w http.ResponseWriter, r *http.Request, msg string) {
65 s.backTo(w, r, "builds/"+n, msg)
66 }
67 _, msg, code := s.runControlCode(u, []string{"build", "cancel", repo, n})
68 s.done(w, r, code, msg, back)
69}
internal/httpd/routes.go +2
@@ -150,6 +150,8 @@ func (s *Server) Routes() []Route {
150150 Handler: s.checkOrigin(s.requireUser(s.releaseSubmit))},
151151 Route{Method: "POST", Pattern: "/{owner}/{repo}/builds", Mutating: true,
152152 Handler: s.checkOrigin(s.requireUser(s.buildTriggerSubmit))},
153 Route{Method: "POST", Pattern: "/{owner}/{repo}/builds/{n}/cancel", Mutating: true,
154 Handler: s.checkOrigin(s.requireUser(s.buildCancelSubmit))},
153155 Route{Method: "GET", Pattern: "/{owner}/{repo}/settings",
154156 Handler: s.requireUser(s.settingsForm)},
155157 Route{Method: "POST", Pattern: "/{owner}/{repo}/settings", Mutating: true,
internal/web/templates/account.html +16
@@ -4,6 +4,22 @@
44{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
55{{if .Message}}<p class="notice" role="status">{{.Message}}</p>{{end}}
66
7<h2>Profile</h2>
8<p class="meta">Shown on your profile page, <a href="/{{.Viewer}}">/{{.Viewer}}</a>.</p>
9<form method="post" action="/settings" class="setform stack">
10 <input type="hidden" name="field" value="profile">
11 <label for="p-description">Description</label>
12 <input type="text" id="p-description" name="description" value="{{.Profile.Description}}" placeholder="one line, shown in listings">
13 <label for="p-website">Website</label>
14 <input type="text" id="p-website" name="website" value="{{.Profile.Website}}" placeholder="https://example.org">
15 <label for="p-links">Links</label>
16 <textarea id="p-links" name="links" rows="3" placeholder="one per line: label|https://... or a bare https://... (at most 5)">{{.LinksText}}</textarea>
17 <label for="p-about">About</label>
18 <textarea id="p-about" name="about" rows="8" placeholder="longer, shown below your repositories">{{.Profile.About}}</textarea>
19 {{template "formatpicker" .Profile.AboutFormat}}
20 <button type="submit">Save profile</button>
21</form>
22
723<h2>SSH keys</h2>
824<p class="meta">Your keys are your identity here. A <code>full</code> key can run
925commands and push; a <code>git</code> key can only move git data, which is what
internal/web/templates/build.html +7
@@ -1,7 +1,14 @@
11{{define "title"}}build {{.Build.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22{{define "content"}}
3{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
34<div class="headrow">
45<h1>Build {{.Build.Number}} <span class="chip check-{{.Build.Status}}">{{.Build.Status}}</span></h1>
6<span class="spacer"></span>
7{{if and .CanWrite (or (eq .Build.Status "pending") (eq .Build.Status "running"))}}
8<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds/{{.Build.Number}}/cancel" class="actions">
9 <button type="submit">{{if eq .Build.Status "running"}}Cancel (stops at the runner's next check){{else}}Cancel{{end}}</button>
10</form>
11{{end}}
512</div>
613<p class="meta">{{.Build.Job}} on {{.Build.Ref}} · <code><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/commit/{{.Build.SHA}}">{{printf "%.10s" .Build.SHA}}</a></code> · queued {{when .Build.CreatedAt}}{{if .Build.FinishedAt}} · finished {{when .Build.FinishedAt}}{{end}}</p>
714{{if .Log}}<pre class="code buildlog">{{.Log}}</pre>{{else}}<p class="empty-note">no log yet</p>{{end}}
internal/web/templates/issuenew.html +1
@@ -6,6 +6,7 @@
66<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new" class="commentform">
77<p><input type="text" name="title" aria-label="Title" placeholder="title" required></p>
88<p><textarea name="body" aria-label="Description" rows="12">{{.Body}}</textarea></p>
9{{template "formatpicker" .Format}}
910<p><input type="text" name="labels" aria-label="Labels" placeholder="labels, space-separated (write access)"></p>
1011<p><button type="submit">Open issue</button></p>
1112</form>
internal/web/templates/layout.html +11
@@ -122,6 +122,17 @@
122122
123123{{define "authorname"}}{{if .User}}<a class="authorlink" href="/{{.User}}" title="{{.Email}}">{{.Name}}</a>{{else}}<span title="{{.Email}}">{{.Name}}</span>{{end}}{{end}}
124124
125{{/* formatpicker is the md/org choice on a body: issue and MR create, and
126 a profile's about text. The argument is the currently selected
127 format; empty selects Markdown, today's default. */}}
128{{define "formatpicker"}}<p class="branchpick">
129 <label for="format">Body markup</label>
130 <select id="format" name="format">
131 <option value="md"{{if ne . "org"}} selected{{end}}>Markdown</option>
132 <option value="org"{{if eq . "org"}} selected{{end}}>Org</option>
133 </select>
134</p>{{end}}
135
125136{{/* difffiles renders a parsed diff: one foldable section per file, with
126137 line-number gutters and review threads inline. Base is the MR's
127138 endpoint and Viewer the signed-in account; the commit page passes
internal/web/templates/mrnew.html +1
@@ -16,6 +16,7 @@
1616</p>
1717<p><input type="text" name="title" aria-label="Title" placeholder="title" value="{{.Title}}" required></p>
1818<p><textarea name="body" aria-label="Description" rows="10" placeholder="what changes, and why">{{.Body}}</textarea></p>
19{{template "formatpicker" .Format}}
1920<p><button type="submit">Open merge request</button></p>
2021</form>
2122<p class="meta">A branch in a fork opens from the CLI: