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 | "net/url" | 6 | "net/url" |
| 7 | "strings" | 7 | "strings" |
| 8 | 8 | ||
| 9 | "gitbay.org/gitbay/internal/control" | ||
| 9 | "gitbay.org/gitbay/internal/store" | 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 | emails, _ := s.st.ListEmails(u.ID) | 48 | emails, _ := s.st.ListEmails(u.ID) |
| 48 | 49 | ||
| 50 | var profile control.ProfileOut | ||
| 51 | s.runControlInto(u, []string{"profile", "show"}, &profile) | ||
| 52 | |||
| 49 | s.render(w, "account.html", struct { | 53 | s.render(w, "account.html", struct { |
| 50 | basePage | 54 | basePage |
| 51 | Tab string // marks the rail's Settings row as current | 55 | Tab string // marks the rail's Settings row as current |
| 52 | Keys []accountKey | 56 | Keys []accountKey |
| 53 | PGP []accountPGP | 57 | PGP []accountPGP |
| 54 | Emails []store.Email | 58 | Emails []store.Email |
| 55 | Host string | 59 | Profile control.ProfileOut |
| 56 | Notice string | 60 | LinksText string |
| 57 | Message string | 61 | Host string |
| 58 | }{s.baseFor(u), "account", keys, pgp, emails, s.cfg.SiteHost(), | 62 | Notice string |
| 63 | Message string | ||
| 64 | }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), s.cfg.SiteHost(), | ||
| 59 | s.takeFlash(w, r), r.URL.Query().Get("m")}) | 65 | s.takeFlash(w, r), r.URL.Query().Get("m")}) |
| 60 | } | 66 | } |
| 61 | 67 | ||
| 62 | // accountSubmit routes the account forms to their commands. Everything | 68 | // profileLinksText turns a profile's links into the form the textarea |
| 63 | // here is a public key or an address — no secret is accepted over the web. | 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 | func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | 102 | func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| 65 | back := func(msg, note string) { | 103 | back := func(msg, note string) { |
| 66 | q := "" | 104 | q := "" |
| @@ -122,6 +160,25 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U | |||
| 122 | return | 160 | return |
| 123 | } | 161 | } |
| 124 | back("", "address verified") | 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 | default: | 182 | default: |
| 126 | back("unknown form", "") | 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 | s.render(w, "issuenew.html", struct { | 318 | s.render(w, "issuenew.html", struct { |
| 315 | repoPage | 319 | repoPage |
| 316 | Body string | 320 | Body string |
| 321 | Format string | ||
| 317 | Template string | 322 | Template string |
| 318 | Templates []control.IssueTemplate | 323 | Templates []control.IssueTemplate |
| 319 | }{p, body, tplName, templates}) | 324 | }{p, body, format, tplName, templates}) |
| 320 | } | 325 | } |
| 321 | 326 | ||
| 322 | // Issue and merge request writes run the command the CLI runs, so the | 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 | func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | 331 | func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| 327 | repoPath := r.PathValue("owner") + "/" + r.PathValue("repo") | 332 | repoPath := r.PathValue("owner") + "/" + r.PathValue("repo") |
| 328 | title := strings.TrimSpace(r.FormValue("title")) | 333 | title := strings.TrimSpace(r.FormValue("title")) |
| 334 | format := r.FormValue("format") | ||
| 335 | if format != "org" { | ||
| 336 | format = "md" | ||
| 337 | } | ||
| 329 | var created control.Created | 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 | if code != protocol.ExitOK { | 341 | if code != protocol.ExitOK { |
| 332 | http.Error(w, msg, statusForExit(code)) | 342 | http.Error(w, msg, statusForExit(code)) |
| 333 | return | 343 | return |
internal/httpd/buildpages_test.go +9 −2
| @@ -55,8 +55,10 @@ func TestBuildPageRendersCommandOutput(t *testing.T) { | |||
| 55 | var sb strings.Builder | 55 | var sb strings.Builder |
| 56 | err := web.Render(&sb, "build.html", struct { | 56 | err := web.Render(&sb, "build.html", struct { |
| 57 | repoPage | 57 | repoPage |
| 58 | Build control.BuildOut | 58 | Build control.BuildOut |
| 59 | Log string | 59 | Log string |
| 60 | CanWrite bool | ||
| 61 | Notice string | ||
| 60 | }{ | 62 | }{ |
| 61 | testRepoPage(), | 63 | testRepoPage(), |
| 62 | control.BuildOut{ | 64 | control.BuildOut{ |
| @@ -65,6 +67,7 @@ func TestBuildPageRendersCommandOutput(t *testing.T) { | |||
| 65 | CreatedAt: "2026-08-28T04:42:54Z", FinishedAt: "2026-08-28T04:43:06Z", | 67 | CreatedAt: "2026-08-28T04:42:54Z", FinishedAt: "2026-08-28T04:43:06Z", |
| 66 | }, | 68 | }, |
| 67 | "step 1 ok", | 69 | "step 1 ok", |
| 70 | true, "", | ||
| 68 | }) | 71 | }) |
| 69 | if err != nil { | 72 | if err != nil { |
| 70 | t.Fatalf("render: %v", err) | 73 | t.Fatalf("render: %v", err) |
| @@ -78,4 +81,8 @@ func TestBuildPageRendersCommandOutput(t *testing.T) { | |||
| 78 | t.Errorf("build.html missing %q", want) | 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 | s.render(w, "build.html", struct { | 55 | s.render(w, "build.html", struct { |
| 56 | repoPage | 56 | repoPage |
| 57 | Build control.BuildOut | 57 | Build control.BuildOut |
| 58 | Log string | 58 | Log string |
| 59 | }{p, b, log}) | 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 | Target string | 151 | Target string |
| 152 | Title string | 152 | Title string |
| 153 | Body string | 153 | Body string |
| 154 | Format string | ||
| 154 | Notice string | 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 | if target == "" { | 167 | if target == "" { |
| 167 | target = p.Repo.DefaultBranch | 168 | target = p.Repo.DefaultBranch |
| 168 | } | 169 | } |
| 170 | format := q.Get("format") | ||
| 171 | if format != "org" { | ||
| 172 | format = "md" | ||
| 173 | } | ||
| 169 | s.render(w, "mrnew.html", mrNewPage{ | 174 | s.render(w, "mrnew.html", mrNewPage{ |
| 170 | repoPage: p, Branches: branches, | 175 | repoPage: p, Branches: branches, |
| 171 | Source: q.Get("source"), Target: target, | 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 | target := strings.TrimSpace(r.FormValue("target")) | 187 | target := strings.TrimSpace(r.FormValue("target")) |
| 183 | title := strings.TrimSpace(r.FormValue("title")) | 188 | title := strings.TrimSpace(r.FormValue("title")) |
| 184 | body := strings.TrimSpace(r.FormValue("body")) | 189 | body := strings.TrimSpace(r.FormValue("body")) |
| 190 | format := r.FormValue("format") | ||
| 191 | if format != "org" { | ||
| 192 | format = "md" | ||
| 193 | } | ||
| 185 | 194 | ||
| 186 | back := func(msg string) { | 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 | s.setFlash(w, msg) | 197 | s.setFlash(w, msg) |
| 189 | http.Redirect(w, r, fmt.Sprintf("/%s/mrs/new?%s", p.Repo.Path(), q.Encode()), http.StatusSeeOther) | 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 | back("pick a source branch and give the merge request a title") | 201 | back("pick a source branch and give the merge request a title") |
| 193 | return | 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 | if target != "" { | 205 | if target != "" { |
| 197 | argv = append(argv, "--target", target) | 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 | _, msg, code := s.runControlCode(u, []string{"build", "trigger", repo, job}) | 53 | _, msg, code := s.runControlCode(u, []string{"build", "trigger", repo, job}) |
| 54 | s.done(w, r, code, msg, func(w http.ResponseWriter, r *http.Request, msg string) { s.backTo(w, r, "builds", msg) }) | 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 | Handler: s.checkOrigin(s.requireUser(s.releaseSubmit))}, | 150 | Handler: s.checkOrigin(s.requireUser(s.releaseSubmit))}, |
| 151 | Route{Method: "POST", Pattern: "/{owner}/{repo}/builds", Mutating: true, | 151 | Route{Method: "POST", Pattern: "/{owner}/{repo}/builds", Mutating: true, |
| 152 | Handler: s.checkOrigin(s.requireUser(s.buildTriggerSubmit))}, | 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 | Route{Method: "GET", Pattern: "/{owner}/{repo}/settings", | 155 | Route{Method: "GET", Pattern: "/{owner}/{repo}/settings", |
| 154 | Handler: s.requireUser(s.settingsForm)}, | 156 | Handler: s.requireUser(s.settingsForm)}, |
| 155 | Route{Method: "POST", Pattern: "/{owner}/{repo}/settings", Mutating: true, | 157 | Route{Method: "POST", Pattern: "/{owner}/{repo}/settings", Mutating: true, |
internal/web/templates/account.html +16
| @@ -4,6 +4,22 @@ | |||
| 4 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} | 4 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} |
| 5 | {{if .Message}}<p class="notice" role="status">{{.Message}}</p>{{end}} | 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 | <h2>SSH keys</h2> | 23 | <h2>SSH keys</h2> |
| 8 | <p class="meta">Your keys are your identity here. A <code>full</code> key can run | 24 | <p class="meta">Your keys are your identity here. A <code>full</code> key can run |
| 9 | commands and push; a <code>git</code> key can only move git data, which is what | 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 | {{define "title"}}build {{.Build.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | 1 | {{define "title"}}build {{.Build.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | {{define "content"}} | 2 | {{define "content"}} |
| 3 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} | ||
| 3 | <div class="headrow"> | 4 | <div class="headrow"> |
| 4 | <h1>Build {{.Build.Number}} <span class="chip check-{{.Build.Status}}">{{.Build.Status}}</span></h1> | 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 | </div> | 12 | </div> |
| 6 | <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> | 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 | {{if .Log}}<pre class="code buildlog">{{.Log}}</pre>{{else}}<p class="empty-note">no log yet</p>{{end}} | 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 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new" class="commentform"> | 6 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new" class="commentform"> |
| 7 | <p><input type="text" name="title" aria-label="Title" placeholder="title" required></p> | 7 | <p><input type="text" name="title" aria-label="Title" placeholder="title" required></p> |
| 8 | <p><textarea name="body" aria-label="Description" rows="12">{{.Body}}</textarea></p> | 8 | <p><textarea name="body" aria-label="Description" rows="12">{{.Body}}</textarea></p> |
| 9 | {{template "formatpicker" .Format}} | ||
| 9 | <p><input type="text" name="labels" aria-label="Labels" placeholder="labels, space-separated (write access)"></p> | 10 | <p><input type="text" name="labels" aria-label="Labels" placeholder="labels, space-separated (write access)"></p> |
| 10 | <p><button type="submit">Open issue</button></p> | 11 | <p><button type="submit">Open issue</button></p> |
| 11 | </form> | 12 | </form> |
internal/web/templates/layout.html +11
| @@ -122,6 +122,17 @@ | |||
| 122 | 122 | ||
| 123 | {{define "authorname"}}{{if .User}}<a class="authorlink" href="/{{.User}}" title="{{.Email}}">{{.Name}}</a>{{else}}<span title="{{.Email}}">{{.Name}}</span>{{end}}{{end}} | 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 | {{/* difffiles renders a parsed diff: one foldable section per file, with | 136 | {{/* difffiles renders a parsed diff: one foldable section per file, with |
| 126 | line-number gutters and review threads inline. Base is the MR's | 137 | line-number gutters and review threads inline. Base is the MR's |
| 127 | endpoint and Viewer the signed-in account; the commit page passes | 138 | endpoint and Viewer the signed-in account; the commit page passes |
internal/web/templates/mrnew.html +1
| @@ -16,6 +16,7 @@ | |||
| 16 | </p> | 16 | </p> |
| 17 | <p><input type="text" name="title" aria-label="Title" placeholder="title" value="{{.Title}}" required></p> | 17 | <p><input type="text" name="title" aria-label="Title" placeholder="title" value="{{.Title}}" required></p> |
| 18 | <p><textarea name="body" aria-label="Description" rows="10" placeholder="what changes, and why">{{.Body}}</textarea></p> | 18 | <p><textarea name="body" aria-label="Description" rows="10" placeholder="what changes, and why">{{.Body}}</textarea></p> |
| 19 | {{template "formatpicker" .Format}} | ||
| 19 | <p><button type="submit">Open merge request</button></p> | 20 | <p><button type="submit">Open merge request</button></p> |
| 20 | </form> | 21 | </form> |
| 21 | <p class="meta">A branch in a fork opens from the CLI: | 22 | <p class="meta">A branch in a fork opens from the CLI: |