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