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) | |