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/hook/hook_test.go +60

@@ -7,12 +7,14 @@ import (
7 7 "path/filepath"
8 8 "strings"
9 9 "testing"
10 "time"
10 11
11 12 "github.com/rodent-software/vole/ci/runs"
12 13 "github.com/rodent-software/vole/core/git"
13 14 "github.com/rodent-software/vole/forge/access"
14 15 "github.com/rodent-software/vole/forge/identity"
15 16 "github.com/rodent-software/vole/forge/repo"
17 "github.com/rodent-software/vole/metadata"
16 18 )
17 19
18 20 type fixture struct {
@@ -339,3 +341,61 @@ func TestPostReceiveSurvivesAnUnwritableLog(t *testing.T) {
339 341 t.Fatalf("an unwritable audit log failed the push (exit %d)", code)
340 342 }
341 343 }
344
345 // A repository can require a linked issue when a change is opened. The rule is
346 // checked for requests a push adds; turning it on must not block edits to the
347 // requests that were already there.
348 func TestNewChangeRequestRequiresALinkedIssueWhenConfigured(t *testing.T) {
349 t.Parallel()
350 f := newFixture(t, "hello\n")
351 f.setACL(func(a *access.ACL) { a.RequireLinkedIssue = true })
352
353 g := f.m.Git()
354 now := time.Now().UTC()
355 iss, err := metadata.NewIssue("A bug", "details\n", "owner", nil, nil, now)
356 if err != nil {
357 t.Fatal(err)
358 }
359 if err := metadata.CreateIssue(g, iss, "owner"); err != nil {
360 t.Fatal(err)
361 }
362 issueTip, err := g.RevParse(metadata.Ref)
363 if err != nil {
364 t.Fatal(err)
365 }
366
367 linked, err := metadata.NewRequest("Linked change", "body\n", "owner", "main", f.head, now)
368 if err != nil {
369 t.Fatal(err)
370 }
371 linked.Closes = []string{iss.ID}
372 if err := metadata.CreateRequest(g, linked, "owner"); err != nil {
373 t.Fatal(err)
374 }
375 linkedTip, err := g.RevParse(metadata.Ref)
376 if err != nil {
377 t.Fatal(err)
378 }
379 if code, out := f.runRef(Config{Actor: "owner"}, issueTip, linkedTip, metadata.Ref); code != 0 {
380 t.Fatalf("a linked request was rejected: %s", out)
381 }
382
383 unlinked, err := metadata.NewRequest("Unlinked change", "body\n", "owner", "main", f.head, now)
384 if err != nil {
385 t.Fatal(err)
386 }
387 if err := metadata.CreateRequest(g, unlinked, "owner"); err != nil {
388 t.Fatal(err)
389 }
390 unlinkedTip, err := g.RevParse(metadata.Ref)
391 if err != nil {
392 t.Fatal(err)
393 }
394 code, out := f.runRef(Config{Actor: "owner"}, linkedTip, unlinkedTip, metadata.Ref)
395 if code == 0 {
396 t.Fatal("an unlinked request was accepted")
397 }
398 if !strings.Contains(out, "requires at least one linked issue") {
399 t.Fatalf("the refusal does not explain the rule: %s", out)
400 }
401 }