Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Let the browser edit issue and change details

a4e7a99a9ecf697586abdb884351a722fc25a864 · Keenan Nemetz · 5d ago · parent de84c32b

Let the browser edit issue and change details

An issue's title and description could only be corrected with the CLI, and a
change request's prose could not be corrected at all after it was opened. Add an
edit page for each, offered to the author and to a maintainer. A change edit writes
through a new metadata writer so the record is still validated, and neither edit
touches the discussion, labels, or review state.

server/issues_changes_test.go +128

@@ -9,6 +9,7 @@ import (
9 9 "io"
10 10 "net/http"
11 11 "net/url"
12 "path/filepath"
12 13 "strconv"
13 14 "strings"
14 15 "testing"
@@ -64,6 +65,133 @@ func TestIssueCanBeClosedAndReopenedInTheBrowser(t *testing.T) {
64 65 }
65 66 }
66 67
68 // The browser could file, triage, discuss and close an issue or a change, but it
69 // could not correct what one said. The two edit pages are the missing half: the
70 // author and a maintainer may rewrite the title and description, and nobody else
71 // may, on either surface.
72 func TestIssueAndChangeDetailsCanBeEditedInTheBrowser(t *testing.T) {
73 t.Parallel()
74 f := newFixture(t)
75 f.publish(f.repo)
76
77 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
78 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
79 t.Fatalf("main push: %s", errs)
80 }
81 feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature")
82 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
83 t.Fatalf("feature push: %s", errs)
84 }
85
86 sess, cookie := f.browserSession(t, f.ownerID)
87
88 // An issue: the page offers Edit, the form carries what the issue says, and
89 // saving returns to the issue with the new prose.
90 resp, body := f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{
91 "csrf": {sess.CSRF}, "title": {"First draft"}, "body": {"original body\n"},
92 })
93 if resp.StatusCode != http.StatusSeeOther {
94 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
95 }
96 issueID := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/issues/")
97 if issueID == "" {
98 t.Fatalf("no issue id in %q", resp.Header.Get("Location"))
99 }
100 _, page := f.browserGet(t, "/r/"+f.repo+"/issues/"+issueID, cookie)
101 if !strings.Contains(page, "/issues/"+issueID+"/edit") || !strings.Contains(page, "Edit</a>") {
102 t.Fatalf("the issue page offers no edit:\n%s", page)
103 }
104 resp, form := f.browserGet(t, "/r/"+f.repo+"/issues/"+issueID+"/edit", cookie)
105 if resp.StatusCode != http.StatusOK || !strings.Contains(form, `action="/r/`+f.repo+`/issues/`+issueID+`/edit"`) ||
106 !strings.Contains(form, `value="First draft"`) || !strings.Contains(form, "original body") {
107 t.Fatalf("issue edit page = %d:\n%s", resp.StatusCode, form)
108 }
109 resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+issueID+"/edit", cookie, url.Values{
110 "csrf": {sess.CSRF}, "title": {"Corrected issue"}, "body": {"corrected issue body\n"},
111 })
112 if resp.StatusCode != http.StatusSeeOther {
113 t.Fatalf("edit issue: %d\n%s", resp.StatusCode, body)
114 }
115 iss, err := index.LoadIssue(mustRepo(t, f), issueID)
116 if err != nil {
117 t.Fatal(err)
118 }
119 if iss.Title != "Corrected issue" || strings.TrimSpace(iss.Description) != "corrected issue body" {
120 t.Fatalf("edited issue = %q / %q", iss.Title, iss.Description)
121 }
122 if _, page := f.browserGet(t, "/r/"+f.repo+"/issues/"+issueID, cookie); !strings.Contains(page, "Corrected issue") ||
123 !strings.Contains(page, "corrected issue body") {
124 t.Fatalf("the issue page does not show the edit:\n%s", page)
125 }
126
127 // A change: same controls, and the same writer.
128 resp, body = f.browserPost(t, "/r/"+f.repo+"/requests", cookie, url.Values{
129 "csrf": {sess.CSRF}, "title": {"First change draft"}, "body": {"original change body\n"},
130 "base": {"main"}, "head": {"feature"},
131 })
132 if resp.StatusCode != http.StatusSeeOther {
133 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
134 }
135 changeID := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/requests/")
136 if changeID == "" {
137 t.Fatalf("no change id in %q", resp.Header.Get("Location"))
138 }
139 _, page = f.browserGet(t, "/r/"+f.repo+"/requests/"+changeID, cookie)
140 if !strings.Contains(page, "/requests/"+changeID+"/edit") || !strings.Contains(page, "Edit</a>") {
141 t.Fatalf("the change page offers no edit:\n%s", page)
142 }
143 resp, form = f.browserGet(t, "/r/"+f.repo+"/requests/"+changeID+"/edit", cookie)
144 if resp.StatusCode != http.StatusOK || !strings.Contains(form, `action="/r/`+f.repo+`/requests/`+changeID+`/edit"`) ||
145 !strings.Contains(form, `value="First change draft"`) || !strings.Contains(form, "original change body") {
146 t.Fatalf("change edit page = %d:\n%s", resp.StatusCode, form)
147 }
148 resp, body = f.browserPost(t, "/r/"+f.repo+"/requests/"+changeID+"/edit", cookie, url.Values{
149 "csrf": {sess.CSRF}, "title": {"Corrected change"}, "body": {"corrected change body\n"},
150 })
151 if resp.StatusCode != http.StatusSeeOther {
152 t.Fatalf("edit change: %d\n%s", resp.StatusCode, body)
153 }
154 req, err := metadata.GetRequest(mustRepo(t, f).Git(), changeID)
155 if err != nil {
156 t.Fatal(err)
157 }
158 if req.Title != "Corrected change" || strings.TrimSpace(req.Description) != "corrected change body" {
159 t.Fatalf("edited change = %q / %q", req.Title, req.Description)
160 }
161 if _, page := f.browserGet(t, "/r/"+f.repo+"/requests/"+changeID, cookie); !strings.Contains(page, "Corrected change") ||
162 !strings.Contains(page, "corrected change body") {
163 t.Fatalf("the change page does not show the edit:\n%s", page)
164 }
165
166 // Somebody who is neither the author nor a maintainer may open neither form
167 // and may not post either edit.
168 pub, _ := keypair(t, filepath.Join(f.cfg.DataDir, "id_stranger"))
169 stranger, err := f.srv.Identity.Create("stranger", pub, "test")
170 if err != nil {
171 t.Fatal(err)
172 }
173 strangerSess, strangerCookie := f.browserSession(t, stranger.Name)
174 for _, path := range []string{
175 "/r/" + f.repo + "/issues/" + issueID + "/edit",
176 "/r/" + f.repo + "/requests/" + changeID + "/edit",
177 } {
178 if resp, _ := f.browserGet(t, path, strangerCookie); resp.StatusCode != http.StatusForbidden {
179 t.Fatalf("stranger got %d from %s, want 403", resp.StatusCode, path)
180 }
181 }
182 for _, path := range []string{
183 "/r/" + f.repo + "/issues/" + issueID + "/edit",
184 "/r/" + f.repo + "/requests/" + changeID + "/edit",
185 } {
186 resp, _ := f.browserPost(t, path, strangerCookie, url.Values{
187 "csrf": {strangerSess.CSRF}, "title": {"Not mine"}, "body": {"not mine\n"},
188 })
189 if resp.StatusCode != http.StatusForbidden {
190 t.Fatalf("stranger's edit of %s = %d, want 403", path, resp.StatusCode)
191 }
192 }
193 }
194
67 195 func TestChangeCanBeDiscussedAndClosedInTheBrowser(t *testing.T) {
68 196 t.Parallel()
69 197 f := newFixture(t)