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.
List a change's linked issues in a Details section
cad93cfe2b6d4ed4c3ed5a1431af4ecb194484ce · Keenan Nemetz · 10d ago · parent 5c22953e
List a change's linked issues in a Details section The conversation sidebar showed linked issues as one comma-separated line, so there was no way to see what each link was or change it without the CLI. The Details section lists them with the same rows the overview uses for recent issues, and the change's author or a maintainer can link another issue or remove one while it is open. Both controls go through SetRequestCloses, so the browser and 'vole change update --closes' write the same metadata.
server/issues_changes_test.go +123
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "io" | |
| 10 | 10 | "net/http" | |
| 11 | 11 | "net/url" | |
| 12 | "strconv" | ||
| 12 | 13 | "strings" | |
| 13 | 14 | "testing" | |
| 14 | 15 | ||
| @@ -288,6 +289,128 @@ func TestIssueAssigneesCanBeSetAndEditedInTheBrowser(t *testing.T) { | |||
| 288 | 289 | } | |
| 289 | 290 | } | |
| 290 | 291 | ||
| 292 | func TestChangeLinksIssuesFromTheDetailsSection(t *testing.T) { | ||
| 293 | t.Parallel() | ||
| 294 | f := newFixture(t) | ||
| 295 | f.publish(f.repo) | ||
| 296 | |||
| 297 | base := workRepo(t, map[string]string{"readme.md": "v1\n"}) | ||
| 298 | if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 299 | t.Fatalf("main push: %s", errs) | ||
| 300 | } | ||
| 301 | feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature") | ||
| 302 | if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil { | ||
| 303 | t.Fatalf("feature push: %s", errs) | ||
| 304 | } | ||
| 305 | |||
| 306 | sess, cookie := f.browserSession(t, f.ownerID) | ||
| 307 | createIssue := func(title string) string { | ||
| 308 | t.Helper() | ||
| 309 | resp, body := f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{ | ||
| 310 | "csrf": {sess.CSRF}, "title": {title}, "body": {"body\n"}, | ||
| 311 | }) | ||
| 312 | if resp.StatusCode != http.StatusSeeOther { | ||
| 313 | t.Fatalf("create issue %q: %d\n%s", title, resp.StatusCode, body) | ||
| 314 | } | ||
| 315 | id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/issues/") | ||
| 316 | if id == "" { | ||
| 317 | t.Fatalf("no issue id in %q", resp.Header.Get("Location")) | ||
| 318 | } | ||
| 319 | return id | ||
| 320 | } | ||
| 321 | first := createIssue("First linked issue") | ||
| 322 | second := createIssue("Second linked issue") | ||
| 323 | |||
| 324 | resp, body := f.browserPost(t, "/r/"+f.repo+"/requests", cookie, url.Values{ | ||
| 325 | "csrf": {sess.CSRF}, "title": {"Linked issues"}, "body": {"body\n"}, | ||
| 326 | "base": {"main"}, "head": {"feature"}, "closes": {first}, | ||
| 327 | }) | ||
| 328 | if resp.StatusCode != http.StatusSeeOther { | ||
| 329 | t.Fatalf("create change: %d\n%s", resp.StatusCode, body) | ||
| 330 | } | ||
| 331 | id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/requests/") | ||
| 332 | if id == "" { | ||
| 333 | t.Fatalf("no change id in %q", resp.Header.Get("Location")) | ||
| 334 | } | ||
| 335 | |||
| 336 | // The Details section uses the overview's issue rows and offers the link | ||
| 337 | // controls instead of the old one-line "Closes ... when merged". | ||
| 338 | _, page := f.browserGet(t, "/r/"+f.repo+"/requests/"+id, cookie) | ||
| 339 | for _, want := range []string{ | ||
| 340 | `id="request-details"`, "First linked issue", `value="` + first + `"`, | ||
| 341 | `value="add"`, `value="remove"`, | ||
| 342 | } { | ||
| 343 | if !strings.Contains(page, want) { | ||
| 344 | t.Fatalf("the request page is missing %q:\n%s", want, page) | ||
| 345 | } | ||
| 346 | } | ||
| 347 | if strings.Contains(page, "when merged.") { | ||
| 348 | t.Fatalf("the old closes line is still on the page:\n%s", page) | ||
| 349 | } | ||
| 350 | |||
| 351 | // A number works in the link field, and the section comes back in place. | ||
| 352 | m := mustRepo(t, f) | ||
| 353 | nums, err := metadata.LoadNumbers(m.Git(), metadata.Ref) | ||
| 354 | if err != nil { | ||
| 355 | t.Fatal(err) | ||
| 356 | } | ||
| 357 | number, ok := nums.Of(second) | ||
| 358 | if !ok { | ||
| 359 | t.Fatalf("the second issue has no number") | ||
| 360 | } | ||
| 361 | resp, body = f.htmxPost(t, "/r/"+f.repo+"/requests/"+id+"/issues", cookie, url.Values{ | ||
| 362 | "csrf": {sess.CSRF}, "action": {"add"}, "issue": {"#" + strconv.Itoa(number)}, | ||
| 363 | }) | ||
| 364 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="request-details"`) || | ||
| 365 | !strings.Contains(body, "Second linked issue") || !strings.Contains(body, "First linked issue") { | ||
| 366 | t.Fatalf("add issue response is not the expected section (%d):\n%s", resp.StatusCode, body) | ||
| 367 | } | ||
| 368 | if got, err := metadata.GetRequest(m.Git(), id); err != nil || len(got.Closes) != 2 { | ||
| 369 | t.Fatalf("linked issues = %v, %v; want both", got, err) | ||
| 370 | } | ||
| 371 | |||
| 372 | // Removing a link is the same write, and the row disappears. | ||
| 373 | resp, body = f.htmxPost(t, "/r/"+f.repo+"/requests/"+id+"/issues", cookie, url.Values{ | ||
| 374 | "csrf": {sess.CSRF}, "action": {"remove"}, "issue": {first}, | ||
| 375 | }) | ||
| 376 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="request-details"`) || | ||
| 377 | strings.Contains(body, "First linked issue") || !strings.Contains(body, "Second linked issue") { | ||
| 378 | t.Fatalf("remove issue response is not the expected section (%d):\n%s", resp.StatusCode, body) | ||
| 379 | } | ||
| 380 | got, err := metadata.GetRequest(m.Git(), id) | ||
| 381 | if err != nil || len(got.Closes) != 1 || got.Closes[0] != second { | ||
| 382 | t.Fatalf("linked issues = %v, %v; want only %s", got, err, second) | ||
| 383 | } | ||
| 384 | |||
| 385 | // A duplicate says so beside the control instead of rewriting the list. | ||
| 386 | resp, body = f.htmxPost(t, "/r/"+f.repo+"/requests/"+id+"/issues", cookie, url.Values{ | ||
| 387 | "csrf": {sess.CSRF}, "action": {"add"}, "issue": {second}, | ||
| 388 | }) | ||
| 389 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, "already linked") { | ||
| 390 | t.Fatalf("duplicate link response (%d):\n%s", resp.StatusCode, body) | ||
| 391 | } | ||
| 392 | |||
| 393 | // A closed request keeps its record but offers no edits, and the writer | ||
| 394 | // refuses one posted anyway. | ||
| 395 | resp, body = f.browserPost(t, "/r/"+f.repo+"/requests/"+id+"/state", cookie, url.Values{ | ||
| 396 | "csrf": {sess.CSRF}, "state": {"closed"}, | ||
| 397 | }) | ||
| 398 | if resp.StatusCode != http.StatusSeeOther { | ||
| 399 | t.Fatalf("close change: %d\n%s", resp.StatusCode, body) | ||
| 400 | } | ||
| 401 | _, page = f.browserGet(t, "/r/"+f.repo+"/requests/"+id, cookie) | ||
| 402 | if !strings.Contains(page, "Second linked issue") || strings.Contains(page, `value="remove"`) || | ||
| 403 | strings.Contains(page, `value="add"`) { | ||
| 404 | t.Fatalf("a closed request does not show the right Details section:\n%s", page) | ||
| 405 | } | ||
| 406 | resp, _ = f.htmxPost(t, "/r/"+f.repo+"/requests/"+id+"/issues", cookie, url.Values{ | ||
| 407 | "csrf": {sess.CSRF}, "action": {"remove"}, "issue": {second}, | ||
| 408 | }) | ||
| 409 | if resp.StatusCode != http.StatusForbidden { | ||
| 410 | t.Fatalf("a closed request changed its issues: status=%d", resp.StatusCode) | ||
| 411 | } | ||
| 412 | } | ||
| 413 | |||
| 291 | 414 | func (f *fixture) htmxPost(t *testing.T, path, cookie string, form url.Values) (*http.Response, string) { | |
| 292 | 415 | t.Helper() | |
| 293 | 416 | req, err := http.NewRequest("POST", f.http.URL+path, strings.NewReader(form.Encode())) | |