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.

Require a linked issue when a change request opens

0789f22ea633bf995417893c0052abb6a36d5520 · Keenan Nemetz · 9d ago · parent 1265ea55

Require a linked issue when a change request opens

Add a per-repository policy for teams that want every proposed update to trace back to an issue. The browser marks Closes as required, and the pre-receive hook applies the same rule to requests added through the CLI or a hand-written metadata commit. Existing requests stay editable and landable.

server/issues_changes_test.go +67

@@ -440,6 +440,73 @@ func TestChangeLinksIssuesFromTheIssuesSection(t *testing.T) {
440 440 }
441 441 }
442 442
443 func TestRepositoryCanRequireALinkedIssueForNewChanges(t *testing.T) {
444 t.Parallel()
445 f := newFixture(t)
446 f.publish(f.repo)
447
448 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
449 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
450 t.Fatalf("main push: %s", errs)
451 }
452 feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature")
453 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
454 t.Fatalf("feature push: %s", errs)
455 }
456
457 m := mustRepo(t, f)
458 a, err := m.ACL()
459 if err != nil {
460 t.Fatal(err)
461 }
462 a.RequireLinkedIssue = true
463 if err := m.SetACL(a, "test"); err != nil {
464 t.Fatal(err)
465 }
466
467 sess, cookie := f.browserSession(t, f.ownerID)
468 resp, body := f.browserGet(t, "/r/"+f.repo+"/requests/new", cookie)
469 if resp.StatusCode != http.StatusOK || !strings.Contains(body, "requires a linked issue") ||
470 !strings.Contains(body, "required") {
471 t.Fatalf("new change page does not explain the rule (%d):\n%s", resp.StatusCode, body)
472 }
473
474 form := url.Values{
475 "csrf": {sess.CSRF}, "title": {"Unlinked change"}, "body": {"body\n"},
476 "base": {"main"}, "head": {"feature"},
477 }
478 resp, body = f.browserPost(t, "/r/"+f.repo+"/requests", cookie, form)
479 if resp.StatusCode != http.StatusBadRequest || !strings.Contains(body, "requires at least one linked issue") {
480 t.Fatalf("an unlinked change was accepted (%d):\n%s", resp.StatusCode, body)
481 }
482
483 resp, body = f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{
484 "csrf": {sess.CSRF}, "title": {"The linked issue"}, "body": {"body\n"},
485 })
486 if resp.StatusCode != http.StatusSeeOther {
487 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
488 }
489 issueID := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/issues/")
490 if issueID == "" {
491 t.Fatalf("no issue id in %q", resp.Header.Get("Location"))
492 }
493
494 form.Set("title", "Linked change")
495 form.Set("closes", issueID)
496 resp, body = f.browserPost(t, "/r/"+f.repo+"/requests", cookie, form)
497 if resp.StatusCode != http.StatusSeeOther {
498 t.Fatalf("a linked change was rejected (%d):\n%s", resp.StatusCode, body)
499 }
500 id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/requests/")
501 got, err := metadata.GetRequest(m.Git(), id)
502 if err != nil {
503 t.Fatal(err)
504 }
505 if len(got.Closes) != 1 || got.Closes[0] != issueID {
506 t.Fatalf("linked issues = %v, want %s", got.Closes, issueID)
507 }
508 }
509
443 510 func (f *fixture) htmxPost(t *testing.T, path, cookie string, form url.Values) (*http.Response, string) {
444 511 t.Helper()
445 512 req, err := http.NewRequest("POST", f.http.URL+path, strings.NewReader(form.Encode()))