Three web parity gaps: markup picker, profile form, build cancel !269
15 files changed, +469 −20
Layout: unified · split
e2e/buildcancelweb_test.go added +124
| @@ -0,0 +1,124 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 16 | func 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 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 14 | func 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 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 12 | func 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 ( | ||
| 6 | 6 | "net/url" |
| 7 | 7 | "strings" |
| 8 | 8 | |
| 9 | "gitbay.org/gitbay/internal/control" | |
| 9 | 10 | "gitbay.org/gitbay/internal/store" |
| 10 | 11 | ) |
| 11 | 12 | |
| @@ -46,21 +47,58 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use | ||
| 46 | 47 | } |
| 47 | 48 | emails, _ := s.st.ListEmails(u.ID) |
| 48 | 49 | |
| 50 | var profile control.ProfileOut | |
| 51 | s.runControlInto(u, []string{"profile", "show"}, &profile) | |
| 52 | ||
| 49 | 53 | s.render(w, "account.html", struct { |
| 50 | 54 | 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(), | |
| 59 | 65 | s.takeFlash(w, r), r.URL.Query().Get("m")}) |
| 60 | 66 | } |
| 61 | 67 | |
| 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. | |
| 71 | func 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. | |
| 86 | func 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. | |
| 64 | 102 | func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| 65 | 103 | back := func(msg, note string) { |
| 66 | 104 | q := "" |
| @@ -122,6 +160,25 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U | ||
| 122 | 160 | return |
| 123 | 161 | } |
| 124 | 162 | 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") | |
| 125 | 182 | default: |
| 126 | 183 | back("unknown form", "") |
| 127 | 184 | } |
internal/httpd/accounts.go +12 −2
| @@ -311,12 +311,17 @@ func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store | ||
| 311 | 311 | } |
| 312 | 312 | } |
| 313 | 313 | } |
| 314 | format := r.URL.Query().Get("format") | |
| 315 | if format != "org" { | |
| 316 | format = "md" | |
| 317 | } | |
| 314 | 318 | s.render(w, "issuenew.html", struct { |
| 315 | 319 | repoPage |
| 316 | 320 | Body string |
| 321 | Format string | |
| 317 | 322 | Template string |
| 318 | 323 | Templates []control.IssueTemplate |
| 319 | }{p, body, tplName, templates}) | |
| 324 | }{p, body, format, tplName, templates}) | |
| 320 | 325 | } |
| 321 | 326 | |
| 322 | 327 | // 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 | ||
| 326 | 331 | func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| 327 | 332 | repoPath := r.PathValue("owner") + "/" + r.PathValue("repo") |
| 328 | 333 | title := strings.TrimSpace(r.FormValue("title")) |
| 334 | format := r.FormValue("format") | |
| 335 | if format != "org" { | |
| 336 | format = "md" | |
| 337 | } | |
| 329 | 338 | 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) | |
| 331 | 341 | if code != protocol.ExitOK { |
| 332 | 342 | http.Error(w, msg, statusForExit(code)) |
| 333 | 343 | return |
internal/httpd/buildpages_test.go +9 −2
| @@ -55,8 +55,10 @@ func TestBuildPageRendersCommandOutput(t *testing.T) { | ||
| 55 | 55 | var sb strings.Builder |
| 56 | 56 | err := web.Render(&sb, "build.html", struct { |
| 57 | 57 | repoPage |
| 58 | Build control.BuildOut | |
| 59 | Log string | |
| 58 | Build control.BuildOut | |
| 59 | Log string | |
| 60 | CanWrite bool | |
| 61 | Notice string | |
| 60 | 62 | }{ |
| 61 | 63 | testRepoPage(), |
| 62 | 64 | control.BuildOut{ |
| @@ -65,6 +67,7 @@ func TestBuildPageRendersCommandOutput(t *testing.T) { | ||
| 65 | 67 | CreatedAt: "2026-08-28T04:42:54Z", FinishedAt: "2026-08-28T04:43:06Z", |
| 66 | 68 | }, |
| 67 | 69 | "step 1 ok", |
| 70 | true, "", | |
| 68 | 71 | }) |
| 69 | 72 | if err != nil { |
| 70 | 73 | t.Fatalf("render: %v", err) |
| @@ -78,4 +81,8 @@ func TestBuildPageRendersCommandOutput(t *testing.T) { | ||
| 78 | 81 | t.Errorf("build.html missing %q", want) |
| 79 | 82 | } |
| 80 | 83 | } |
| 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 | } | |
| 81 | 88 | } |
internal/httpd/builds.go +5 −3
| @@ -54,7 +54,9 @@ func (s *Server) build(w http.ResponseWriter, r *http.Request) { | ||
| 54 | 54 | |
| 55 | 55 | s.render(w, "build.html", struct { |
| 56 | 56 | 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)}) | |
| 60 | 62 | } |
internal/httpd/mractions.go +12 −3
| @@ -151,6 +151,7 @@ type mrNewPage struct { | ||
| 151 | 151 | Target string |
| 152 | 152 | Title string |
| 153 | 153 | Body string |
| 154 | Format string | |
| 154 | 155 | Notice string |
| 155 | 156 | } |
| 156 | 157 | |
| @@ -166,10 +167,14 @@ func (s *Server) mrCreateForm(w http.ResponseWriter, r *http.Request, u store.Us | ||
| 166 | 167 | if target == "" { |
| 167 | 168 | target = p.Repo.DefaultBranch |
| 168 | 169 | } |
| 170 | format := q.Get("format") | |
| 171 | if format != "org" { | |
| 172 | format = "md" | |
| 173 | } | |
| 169 | 174 | s.render(w, "mrnew.html", mrNewPage{ |
| 170 | 175 | repoPage: p, Branches: branches, |
| 171 | 176 | 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), | |
| 173 | 178 | }) |
| 174 | 179 | } |
| 175 | 180 | |
| @@ -182,9 +187,13 @@ func (s *Server) mrCreateSubmit(w http.ResponseWriter, r *http.Request, u store. | ||
| 182 | 187 | target := strings.TrimSpace(r.FormValue("target")) |
| 183 | 188 | title := strings.TrimSpace(r.FormValue("title")) |
| 184 | 189 | body := strings.TrimSpace(r.FormValue("body")) |
| 190 | format := r.FormValue("format") | |
| 191 | if format != "org" { | |
| 192 | format = "md" | |
| 193 | } | |
| 185 | 194 | |
| 186 | 195 | 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}} | |
| 188 | 197 | s.setFlash(w, msg) |
| 189 | 198 | http.Redirect(w, r, fmt.Sprintf("/%s/mrs/new?%s", p.Repo.Path(), q.Encode()), http.StatusSeeOther) |
| 190 | 199 | } |
| @@ -192,7 +201,7 @@ func (s *Server) mrCreateSubmit(w http.ResponseWriter, r *http.Request, u store. | ||
| 192 | 201 | back("pick a source branch and give the merge request a title") |
| 193 | 202 | return |
| 194 | 203 | } |
| 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} | |
| 196 | 205 | if target != "" { |
| 197 | 206 | argv = append(argv, "--target", target) |
| 198 | 207 | } |
internal/httpd/releaseactions.go +14
| @@ -53,3 +53,17 @@ func (s *Server) buildTriggerSubmit(w http.ResponseWriter, r *http.Request, u st | ||
| 53 | 53 | _, msg, code := s.runControlCode(u, []string{"build", "trigger", repo, job}) |
| 54 | 54 | s.done(w, r, code, msg, func(w http.ResponseWriter, r *http.Request, msg string) { s.backTo(w, r, "builds", msg) }) |
| 55 | 55 | } |
| 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. | |
| 61 | func (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 { | ||
| 150 | 150 | Handler: s.checkOrigin(s.requireUser(s.releaseSubmit))}, |
| 151 | 151 | Route{Method: "POST", Pattern: "/{owner}/{repo}/builds", Mutating: true, |
| 152 | 152 | 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))}, | |
| 153 | 155 | Route{Method: "GET", Pattern: "/{owner}/{repo}/settings", |
| 154 | 156 | Handler: s.requireUser(s.settingsForm)}, |
| 155 | 157 | Route{Method: "POST", Pattern: "/{owner}/{repo}/settings", Mutating: true, |
internal/web/templates/account.html +16
| @@ -4,6 +4,22 @@ | ||
| 4 | 4 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} |
| 5 | 5 | {{if .Message}}<p class="notice" role="status">{{.Message}}</p>{{end}} |
| 6 | 6 | |
| 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 | ||
| 7 | 23 | <h2>SSH keys</h2> |
| 8 | 24 | <p class="meta">Your keys are your identity here. A <code>full</code> key can run |
| 9 | 25 | commands 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 @@ | ||
| 1 | 1 | {{define "title"}}build {{.Build.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} | |
| 3 | 4 | <div class="headrow"> |
| 4 | 5 | <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}} | |
| 5 | 12 | </div> |
| 6 | 13 | <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> |
| 7 | 14 | {{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 @@ | ||
| 6 | 6 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new" class="commentform"> |
| 7 | 7 | <p><input type="text" name="title" aria-label="Title" placeholder="title" required></p> |
| 8 | 8 | <p><textarea name="body" aria-label="Description" rows="12">{{.Body}}</textarea></p> |
| 9 | {{template "formatpicker" .Format}} | |
| 9 | 10 | <p><input type="text" name="labels" aria-label="Labels" placeholder="labels, space-separated (write access)"></p> |
| 10 | 11 | <p><button type="submit">Open issue</button></p> |
| 11 | 12 | </form> |
internal/web/templates/layout.html +11
| @@ -122,6 +122,17 @@ | ||
| 122 | 122 | |
| 123 | 123 | {{define "authorname"}}{{if .User}}<a class="authorlink" href="/{{.User}}" title="{{.Email}}">{{.Name}}</a>{{else}}<span title="{{.Email}}">{{.Name}}</span>{{end}}{{end}} |
| 124 | 124 | |
| 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 | ||
| 125 | 136 | {{/* difffiles renders a parsed diff: one foldable section per file, with |
| 126 | 137 | line-number gutters and review threads inline. Base is the MR's |
| 127 | 138 | endpoint and Viewer the signed-in account; the commit page passes |
internal/web/templates/mrnew.html +1
| @@ -16,6 +16,7 @@ | ||
| 16 | 16 | </p> |
| 17 | 17 | <p><input type="text" name="title" aria-label="Title" placeholder="title" value="{{.Title}}" required></p> |
| 18 | 18 | <p><textarea name="body" aria-label="Description" rows="10" placeholder="what changes, and why">{{.Body}}</textarea></p> |
| 19 | {{template "formatpicker" .Format}} | |
| 19 | 20 | <p><button type="submit">Open merge request</button></p> |
| 20 | 21 | </form> |
| 21 | 22 | <p class="meta">A branch in a fork opens from the CLI: |