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.

Serve repositories from the root of the URL space

ed03b3d1fa16ef6b55e2773aaf10e3be13282c3a · Keenan Nemetz · 4h ago · parent d37d301f

Serve repositories from the root of the URL space

A repository is now /<name> and its Git transport /<name>.git, so the URL a
person opens, the URL a clone uses, and the Go module path are the same name.
The forge's own pages moved under /-/ (admin, accounts, the directory, the
feed, sign-in, assets), a prefix no repository name can start with: names are
[a-z0-9...] and cannot begin with "-". That leaves the router with two disjoint
spaces and removes the /r/ and /git/ prefixes, which existed only to keep
repositories away from the forge's own paths.

Routes: repoRoutes lose /r/; global, auth and admin routes gain /-/; assets
move to /-/static/ because a subtree pattern like /static/ cannot coexist with
one-segment-wildcard repo routes — Go's mux reports them as conflicting
neither-more-specific patterns. The catch-all still renders the styled 404.

Transport: githttp.Route parses /<name>.git/<rest> instead of a /git/ prefix,
and forgepaths.go hands such requests to the proxy before the mux, because a
wildcard fills a whole segment and cannot match "<name>.git". LFS stays on the
mux (<name>.git/info/lfs/...), and the Go module discovery probe moves to the
same pre-router so a package path under a public repository is answered even
when its first segments name a page tab.

Names: repo.ValidateName now refuses the four root paths the forge must keep —
static, robots.txt, sitemap.xml, sitemap-pages.xml. Everything else is an
ordinary repository name, including admin, u, and activity.

Docs and the packaged URLs follow the new layout; robots.txt, the sitemaps and
the instance activity feed point at the new paths. No aliases: the old /r/ and
/git/ URLs are gone.

Verified with ./vole workflow check and ./vole workflow run verify.

server/integration_test.go +67−67

@@ -407,7 +407,7 @@ func (f *fixture) sshURL(name string) string {
407 407 }
408 408
409 409 func (f *fixture) httpURL(name string) string {
410 return f.http.URL + "/git/" + name + ".git"
410 return f.http.URL + "/" + name + ".git"
411 411 }
412 412
413 413 func (f *fixture) git(dir string, args ...string) (string, string, error) {
@@ -546,10 +546,10 @@ func TestPushOverSSHThenReadOverBothTransports(t *testing.T) {
546 546 }
547 547
548 548 // The web shows it without any login.
549 if code, body := f.get("/r/" + f.repo); code != 200 || !strings.Contains(body, "readme.md") {
549 if code, body := f.get("/" + f.repo); code != 200 || !strings.Contains(body, "readme.md") {
550 550 t.Fatalf("overview page: %d", code)
551 551 }
552 if code, body := f.get("/r/" + f.repo + "/blob/main/readme.md"); code != 200 || !strings.Contains(body, "hello from vole") {
552 if code, body := f.get("/" + f.repo + "/blob/main/readme.md"); code != 200 || !strings.Contains(body, "hello from vole") {
553 553 t.Fatalf("blob page: %d", code)
554 554 }
555 555 if code, _ := f.get("/"); code != 200 {
@@ -567,7 +567,7 @@ func TestPrivateRepoIsInvisible(t *testing.T) {
567 567 t.Fatalf("push: %v\n%s", err, errs)
568 568 }
569 569
570 for _, path := range []string{"/r/" + f.repo, "/r/" + f.repo + "/blob/main/secret.md"} {
570 for _, path := range []string{"/" + f.repo, "/" + f.repo + "/blob/main/secret.md"} {
571 571 if code, _ := f.get(path); code != http.StatusNotFound {
572 572 t.Fatalf("%s: status %d, want 404", path, code)
573 573 }
@@ -579,7 +579,7 @@ func TestPrivateRepoIsInvisible(t *testing.T) {
579 579 if _, _, err := runGit(t, "", "clone", "x", f.httpURL(f.repo), filepath.Join(t.TempDir(), "p")); err == nil {
580 580 // Clone of an empty/nonexistent repo may exit 0 with a warning; check the
581 581 // HTTP layer directly instead.
582 resp, err := http.Get(f.http.URL + "/git/" + f.repo + ".git/info/refs?service=git-upload-pack")
582 resp, err := http.Get(f.http.URL + "/" + f.repo + ".git/info/refs?service=git-upload-pack")
583 583 if err == nil {
584 584 defer resp.Body.Close()
585 585 if resp.StatusCode != http.StatusNotFound {
@@ -760,7 +760,7 @@ func TestIssuePushedThenRendered(t *testing.T) {
760 760 t.Fatalf("push issues: %v\n%s", err, errs)
761 761 }
762 762
763 code, body := f.get("/r/" + f.repo + "/issues")
763 code, body := f.get("/" + f.repo + "/issues")
764 764 if code != 200 {
765 765 t.Fatalf("issue list: %d", code)
766 766 }
@@ -771,7 +771,7 @@ func TestIssuePushedThenRendered(t *testing.T) {
771 771 t.Fatal("issue list missing the label")
772 772 }
773 773
774 code, body = f.get("/r/" + f.repo + "/issues/" + testIssueID)
774 code, body = f.get("/" + f.repo + "/issues/" + testIssueID)
775 775 if code != 200 {
776 776 t.Fatalf("issue view: %d", code)
777 777 }
@@ -790,7 +790,7 @@ func TestIssuePushedThenRendered(t *testing.T) {
790 790 t.Fatalf("the empty comment state prints a CLI command:\n%s", body)
791 791 }
792 792 // The repo page advertises the count.
793 if _, body := f.get("/r/" + f.repo); !strings.Contains(body, "Issues") {
793 if _, body := f.get("/" + f.repo); !strings.Contains(body, "Issues") {
794 794 t.Fatal("repo page has no issues link")
795 795 }
796 796 }
@@ -806,7 +806,7 @@ func TestIssueMarkdownIsSanitised(t *testing.T) {
806 806 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
807 807 t.Fatalf("push: %s", errs)
808 808 }
809 code, body := f.get("/r/" + f.repo + "/issues/" + testIssueID)
809 code, body := f.get("/" + f.repo + "/issues/" + testIssueID)
810 810 if code != 200 {
811 811 t.Fatalf("status %d", code)
812 812 }
@@ -967,25 +967,25 @@ func TestIssueCLIEndToEnd(t *testing.T) {
967 967 if got := serverFile("issues/" + id + "/issue.md"); !strings.Contains(got, " - wilbur") {
968 968 t.Fatalf("@ was not stripped:\n%s", got)
969 969 }
970 if _, body := f.get("/r/" + f.repo + "/issues?assignee=wilbur"); !strings.Contains(body, "Filed from the CLI") {
970 if _, body := f.get("/" + f.repo + "/issues?assignee=wilbur"); !strings.Contains(body, "Filed from the CLI") {
971 971 t.Fatal("the assignment is not filterable on the web")
972 972 }
973 973
974 974 if !strings.HasPrefix(f.serverIssues(t), "") {
975 975 t.Fatal("issues branch missing on the server")
976 976 }
977 if _, body := f.get("/r/" + f.repo + "/issues"); !strings.Contains(body, "Filed from the CLI") {
977 if _, body := f.get("/" + f.repo + "/issues"); !strings.Contains(body, "Filed from the CLI") {
978 978 t.Fatalf("issue not rendered after CLI push:\n%s", body)
979 979 }
980 if code, _ := f.get("/r/" + f.repo + "/issues/" + id); code != 200 {
980 if code, _ := f.get("/" + f.repo + "/issues/" + id); code != 200 {
981 981 t.Fatalf("issue page: %d", code)
982 982 }
983 983 // The page names the `#42` alias, and `/n/1` — the kind-agnostic route a `#1`
984 984 // in a body links to — lands on the same issue.
985 if _, body := f.get("/r/" + f.repo + "/issues/" + id); !strings.Contains(body, "#1") {
985 if _, body := f.get("/" + f.repo + "/issues/" + id); !strings.Contains(body, "#1") {
986 986 t.Fatal("the issue page does not show its number")
987 987 }
988 if _, body := f.get("/r/" + f.repo + "/n/1"); !strings.Contains(body, "Filed from the CLI") {
988 if _, body := f.get("/" + f.repo + "/n/1"); !strings.Contains(body, "Filed from the CLI") {
989 989 t.Fatal("#1 does not resolve to the issue")
990 990 }
991 991
@@ -1004,7 +1004,7 @@ func TestIssueCLIEndToEnd(t *testing.T) {
1004 1004 if _, errs, err := run("issue", "comment", "-m", "A reply from the CLI.", short); err != nil {
1005 1005 t.Fatalf("comment: %v\n%s", err, errs)
1006 1006 }
1007 if _, body := f.get("/r/" + f.repo + "/issues/" + id); !strings.Contains(body, "A reply from the CLI.") {
1007 if _, body := f.get("/" + f.repo + "/issues/" + id); !strings.Contains(body, "A reply from the CLI.") {
1008 1008 t.Fatal("comment not rendered")
1009 1009 }
1010 1010
@@ -1027,7 +1027,7 @@ func TestIssueCLIEndToEnd(t *testing.T) {
1027 1027 if len(replyID) < 8 {
1028 1028 t.Fatalf("no reply id printed: %q", replyOut)
1029 1029 }
1030 _, issuePage := f.get("/r/" + f.repo + "/issues/" + id)
1030 _, issuePage := f.get("/" + f.repo + "/issues/" + id)
1031 1031 if !strings.Contains(issuePage, `class="comment-replies"`) || !strings.Contains(issuePage, `id="c-`+replyID+`"`) ||
1032 1032 !strings.Contains(issuePage, "A threaded answer.") {
1033 1033 t.Fatalf("the CLI reply is not nested:\n%s", issuePage)
@@ -1038,7 +1038,7 @@ func TestIssueCLIEndToEnd(t *testing.T) {
1038 1038 if _, errs, err := run("issue", "edit-comment", short, cid, "-m", "A corrected reply."); err != nil {
1039 1039 t.Fatalf("edit-comment: %v\n%s", err, errs)
1040 1040 }
1041 _, body := f.get("/r/" + f.repo + "/issues/" + id)
1041 _, body := f.get("/" + f.repo + "/issues/" + id)
1042 1042 if !strings.Contains(body, "A corrected reply.") || strings.Contains(body, "A typo-ridden reply.") {
1043 1043 t.Fatalf("the edit did not replace the comment:\n%s", body)
1044 1044 }
@@ -1047,14 +1047,14 @@ func TestIssueCLIEndToEnd(t *testing.T) {
1047 1047 if _, errs, err := run("issue", "edit", short, "--title", "Retitled from the CLI", "-l", "bug", "-l", "cli"); err != nil {
1048 1048 t.Fatalf("edit: %v\n%s", err, errs)
1049 1049 }
1050 if _, body := f.get("/r/" + f.repo + "/issues/" + id); !strings.Contains(body, "Retitled from the CLI") ||
1050 if _, body := f.get("/" + f.repo + "/issues/" + id); !strings.Contains(body, "Retitled from the CLI") ||
1051 1051 !strings.Contains(body, "A corrected reply.") {
1052 1052 t.Fatalf("the issue edit lost the title or the thread:\n%s", body)
1053 1053 }
1054 1054 if _, errs, err := run("issue", "rm-comment", short, cid); err != nil {
1055 1055 t.Fatalf("rm-comment: %v\n%s", err, errs)
1056 1056 }
1057 if _, body := f.get("/r/" + f.repo + "/issues/" + id); strings.Contains(body, "A corrected reply.") {
1057 if _, body := f.get("/" + f.repo + "/issues/" + id); strings.Contains(body, "A corrected reply.") {
1058 1058 t.Fatal("the deleted comment is still rendered")
1059 1059 }
1060 1060
@@ -1073,7 +1073,7 @@ func TestIssueCLIEndToEnd(t *testing.T) {
1073 1073 if _, errs, err := run("issue", "rm-comment", short, tombstoneRoot); err != nil {
1074 1074 t.Fatalf("delete parent: %v\n%s", err, errs)
1075 1075 }
1076 _, body = f.get("/r/" + f.repo + "/issues/" + id)
1076 _, body = f.get("/" + f.repo + "/issues/" + id)
1077 1077 if !strings.Contains(body, "Comment deleted") || !strings.Contains(body, `id="c-`+tombstoneReply+`"`) ||
1078 1078 !strings.Contains(body, "Still here.") || !strings.Contains(body, "a deleted comment") {
1079 1079 t.Fatalf("the deleted parent left no tombstone for its reply:\n%s", body)
@@ -1085,7 +1085,7 @@ func TestIssueCLIEndToEnd(t *testing.T) {
1085 1085 if _, errs, err := run("issue", "close", short); err != nil {
1086 1086 t.Fatalf("close: %v\n%s", err, errs)
1087 1087 }
1088 if _, body := f.get("/r/" + f.repo + "/issues/" + id); !strings.Contains(body, "closed") {
1088 if _, body := f.get("/" + f.repo + "/issues/" + id); !strings.Contains(body, "closed") {
1089 1089 t.Fatal("issue did not close")
1090 1090 }
1091 1091
@@ -1164,7 +1164,7 @@ func TestWhoAmIAnswersFromTheInstance(t *testing.T) {
1164 1164 if strings.Contains(raw, "keenan-nemetz") {
1165 1165 t.Fatalf("the git-config guess reached the branch:\n%s", raw)
1166 1166 }
1167 want := "http://forge.test/r/" + f.repo + "/issues/" + id
1167 want := "http://forge.test/" + f.repo + "/issues/" + id
1168 1168 if !strings.Contains(errs, want) {
1169 1169 t.Fatalf("the CLI did not print where to read it (%q):\n%s", want, errs)
1170 1170 }
@@ -1178,7 +1178,7 @@ func TestWhoAmIAnswersFromTheInstance(t *testing.T) {
1178 1178 if !strings.Contains(errs, want+"#c-"+cid) {
1179 1179 t.Fatalf("the comment permalink is wrong:\n%s", errs)
1180 1180 }
1181 _, page := f.get("/r/" + f.repo + "/issues/" + id)
1181 _, page := f.get("/" + f.repo + "/issues/" + id)
1182 1182 if !strings.Contains(page, `id="c-`+cid+`"`) {
1183 1183 t.Fatalf("the page has no anchor for %s:\n%s", cid, page)
1184 1184 }
@@ -1353,7 +1353,7 @@ func TestAURLRemoteIsFetchedBeforeWriting(t *testing.T) {
1353 1353 }
1354 1354 // The second write only lands if it fetched first, so both being on the branch
1355 1355 // is the assertion.
1356 _, body := f.get("/r/" + f.repo + "/issues")
1356 _, body := f.get("/" + f.repo + "/issues")
1357 1357 for _, want := range []string{"from repo 0", "from repo 1"} {
1358 1358 if !strings.Contains(body, want) {
1359 1359 t.Fatalf("%q is missing from the branch:\n%s", want, body)
@@ -1369,8 +1369,8 @@ func TestEmptyStatesPrintAWorkingCommand(t *testing.T) {
1369 1369 f := newFixture(t)
1370 1370 f.publish(f.repo)
1371 1371 for _, c := range []struct{ path, want string }{
1372 {"/r/" + f.repo + "/issues", "vole issue new --title"},
1373 {"/r/" + f.repo + "/changes", "vole change new --title"},
1372 {"/" + f.repo + "/issues", "vole issue new --title"},
1373 {"/" + f.repo + "/changes", "vole change new --title"},
1374 1374 } {
1375 1375 code, body := f.get(c.path)
1376 1376 if code != 200 {
@@ -1421,7 +1421,7 @@ func TestRequestPageSaysWhenItWouldConflict(t *testing.T) {
1421 1421 t.Fatalf("request push: %v", err)
1422 1422 }
1423 1423
1424 code, body := f.get("/r/" + f.repo + "/changes/" + reqID)
1424 code, body := f.get("/" + f.repo + "/changes/" + reqID)
1425 1425 if code != 200 {
1426 1426 t.Fatalf("request page: %d", code)
1427 1427 }
@@ -1444,7 +1444,7 @@ func TestRequestPageSaysWhenItWouldConflict(t *testing.T) {
1444 1444 }, "clean request"); err != nil {
1445 1445 t.Fatalf("clean request push: %v", err)
1446 1446 }
1447 if _, body := f.get("/r/" + f.repo + "/changes/" + cleanID); !strings.Contains(body, "Merges into main cleanly.") {
1447 if _, body := f.get("/" + f.repo + "/changes/" + cleanID); !strings.Contains(body, "Merges into main cleanly.") {
1448 1448 t.Fatalf("a mergeable branch was not reported as clean:\n%s", body)
1449 1449 }
1450 1450
@@ -1488,7 +1488,7 @@ func TestRequestPageSaysWhenItWouldConflict(t *testing.T) {
1488 1488 }, "ghost request"); err != nil {
1489 1489 t.Fatalf("ghost request push: %v", err)
1490 1490 }
1491 if code, body := f.get("/r/" + f.repo + "/changes/" + ghostID); code != 200 ||
1491 if code, body := f.get("/" + f.repo + "/changes/" + ghostID); code != 200 ||
1492 1492 strings.Contains(body, "no longer merges") || strings.Contains(body, "Merges into main cleanly.") {
1493 1493 t.Fatalf("the page claimed a merge result it could not compute (%d):\n%s", code, body)
1494 1494 }
@@ -1521,7 +1521,7 @@ func TestRequestDiscussionIsPushedAndOwned(t *testing.T) {
1521 1521
1522 1522 // An empty discussion says so, and leaves the reader at the composer rather
1523 1523 // than sending them to a clone for a command the page can take.
1524 code, body := f.get("/r/" + f.repo + "/changes/" + reqID)
1524 code, body := f.get("/" + f.repo + "/changes/" + reqID)
1525 1525 if code != 200 {
1526 1526 t.Fatalf("request page: %d", code)
1527 1527 }
@@ -1546,7 +1546,7 @@ func TestRequestDiscussionIsPushedAndOwned(t *testing.T) {
1546 1546 if len(cid) != 26 {
1547 1547 t.Fatalf("no comment id printed: %q", out)
1548 1548 }
1549 _, body = f.get("/r/" + f.repo + "/changes/" + reqID)
1549 _, body = f.get("/" + f.repo + "/changes/" + reqID)
1550 1550 if !strings.Contains(body, "Looks good to me.") || !strings.Contains(body, `id="c-`+cid+`"`) {
1551 1551 t.Fatalf("the comment is not rendered with an anchor:\n%s", body)
1552 1552 }
@@ -1563,7 +1563,7 @@ func TestRequestDiscussionIsPushedAndOwned(t *testing.T) {
1563 1563 if len(replyID) != 26 {
1564 1564 t.Fatalf("no reply id printed: %q", replyOut)
1565 1565 }
1566 _, body = f.get("/r/" + f.repo + "/changes/" + reqID)
1566 _, body = f.get("/" + f.repo + "/changes/" + reqID)
1567 1567 if !strings.Contains(body, `class="comment-replies"`) || !strings.Contains(body, `id="c-`+replyID+`"`) ||
1568 1568 !strings.Contains(body, "A threaded reply.") {
1569 1569 t.Fatalf("the CLI request reply is not nested:\n%s", body)
@@ -1597,7 +1597,7 @@ func TestRequestDiscussionIsPushedAndOwned(t *testing.T) {
1597 1597 if err := other.apply(map[string]string{commentPath(wilburCm): commentFile("wilbur", created, "one question, rephrased\n")}, "edit own"); err != nil {
1598 1598 t.Fatalf("editing your own comment was refused: %v", err)
1599 1599 }
1600 _, body = f.get("/r/" + f.repo + "/changes/" + reqID)
1600 _, body = f.get("/" + f.repo + "/changes/" + reqID)
1601 1601 if !strings.Contains(body, "one question, rephrased") || strings.Contains(body, "one question</") {
1602 1602 t.Fatalf("the edited comment was not replaced:\n%s", body)
1603 1603 }
@@ -1613,7 +1613,7 @@ func TestRequestDiscussionIsPushedAndOwned(t *testing.T) {
1613 1613 if _, errs, err := f.cli(clone, "change", "rm-comment", reqID, cid); err != nil {
1614 1614 t.Fatalf("delete parent comment: %v\n%s", err, errs)
1615 1615 }
1616 _, body = f.get("/r/" + f.repo + "/changes/" + reqID)
1616 _, body = f.get("/" + f.repo + "/changes/" + reqID)
1617 1617 if !strings.Contains(body, "Comment deleted") || !strings.Contains(body, "A threaded reply.") {
1618 1618 t.Fatalf("the deleted request parent left no tombstone:\n%s", body)
1619 1619 }
@@ -1669,12 +1669,12 @@ func TestReviewCommentAnchorsALine(t *testing.T) {
1669 1669 }
1670 1670 // The Files tab puts the comment beneath that line; the conversation still
1671 1671 // labels the card with where it points.
1672 _, body := f.get("/r/" + f.repo + "/changes/" + reqID + "/files/code.txt")
1672 _, body := f.get("/" + f.repo + "/changes/" + reqID + "/files/code.txt")
1673 1673 if !strings.Contains(body, `id="inline-c-`+newCm+`"`) || !strings.Contains(body, "#c-"+newCm) ||
1674 1674 !strings.Contains(body, "why FEATURE?") {
1675 1675 t.Fatalf("the diff has no inline note for the comment:\n%s", body)
1676 1676 }
1677 _, body = f.get("/r/" + f.repo + "/changes/" + reqID)
1677 _, body = f.get("/" + f.repo + "/changes/" + reqID)
1678 1678 if !strings.Contains(body, "code.txt:2") || !strings.Contains(body, "(new side)") ||
1679 1679 !strings.Contains(body, `class="comment-snippet"`) || !strings.Contains(body, `class="snippet-line anchor"`) ||
1680 1680 !strings.Contains(body, "View in Files") {
@@ -1730,12 +1730,12 @@ func TestReviewCommentAnchorsALine(t *testing.T) {
1730 1730 if _, errs, err := f.cli(clone, "change", "update", reqID, "--head", nextHead); err != nil {
1731 1731 t.Fatalf("request update: %v\n%s", err, errs)
1732 1732 }
1733 _, body = f.get("/r/" + f.repo + "/changes/" + reqID)
1733 _, body = f.get("/" + f.repo + "/changes/" + reqID)
1734 1734 if !strings.Contains(body, "the file has changed since this comment") ||
1735 1735 !strings.Contains(body, `class="snippet-line anchor"`) || !strings.Contains(body, "original line") {
1736 1736 t.Fatalf("a moved file did not keep the original snippet for the stale comment:\n%s", body)
1737 1737 }
1738 _, body = f.get("/r/" + f.repo + "/changes/" + reqID + "/files/code.txt")
1738 _, body = f.get("/" + f.repo + "/changes/" + reqID + "/files/code.txt")
1739 1739 if strings.Contains(body, `id="inline-c-`+newCm+`"`) {
1740 1740 t.Fatalf("the stale comment kept its inline note:\n%s", body)
1741 1741 }
@@ -1773,7 +1773,7 @@ func TestIssueFiltersAndSearch(t *testing.T) {
1773 1773 {"?state=closed", "Docs typo"},
1774 1774 {"?q=parser", "Slow parser"},
1775 1775 } {
1776 code, body := f.get("/r/" + f.repo + "/issues" + c.query)
1776 code, body := f.get("/" + f.repo + "/issues" + c.query)
1777 1777 if code != 200 {
1778 1778 t.Fatalf("%q: status %d", c.query, code)
1779 1779 }
@@ -1782,11 +1782,11 @@ func TestIssueFiltersAndSearch(t *testing.T) {
1782 1782 }
1783 1783 }
1784 1784 // A label filter must exclude issues without that label.
1785 if _, body := f.get("/r/" + f.repo + "/issues?label=docs"); strings.Contains(body, "Segfault") {
1785 if _, body := f.get("/" + f.repo + "/issues?label=docs"); strings.Contains(body, "Segfault") {
1786 1786 t.Fatal("label filter did not exclude other issues")
1787 1787 }
1788 1788 // Negation case: searching a word only in the body.
1789 if _, body := f.get("/r/" + f.repo + "/issues?q=throughput"); !strings.Contains(body, "Slow parser") {
1789 if _, body := f.get("/" + f.repo + "/issues?q=throughput"); !strings.Contains(body, "Slow parser") {
1790 1790 t.Fatal("body search did not match")
1791 1791 }
1792 1792 }
@@ -1814,7 +1814,7 @@ func TestIssueAssigneesRenderedFilteredAndValidated(t *testing.T) {
1814 1814 }
1815 1815
1816 1816 // The detail page shows the assignee as a link that filters.
1817 code, body := f.get("/r/" + f.repo + "/issues/" + assigned)
1817 code, body := f.get("/" + f.repo + "/issues/" + assigned)
1818 1818 if code != 200 {
1819 1819 t.Fatalf("issue page: %d", code)
1820 1820 }
@@ -1822,11 +1822,11 @@ func TestIssueAssigneesRenderedFilteredAndValidated(t *testing.T) {
1822 1822 t.Fatalf("assignee is not rendered as a filter link:\n%s", body)
1823 1823 }
1824 1824 // The list page offers the assignee as a filter and shows it on the row.
1825 if _, body := f.get("/r/" + f.repo + "/issues"); !strings.Contains(body, "Assignee") {
1825 if _, body := f.get("/" + f.repo + "/issues"); !strings.Contains(body, "Assignee") {
1826 1826 t.Fatalf("list has no assignee filters:\n%s", body)
1827 1827 }
1828 1828 // And the filter excludes issues with a different assignee.
1829 _, body = f.get("/r/" + f.repo + "/issues?assignee=wilbur")
1829 _, body = f.get("/" + f.repo + "/issues?assignee=wilbur")
1830 1830 if !strings.Contains(body, "assigned bug") {
1831 1831 t.Fatalf("assignee filter dropped the assigned issue:\n%s", body)
1832 1832 }
@@ -1926,7 +1926,7 @@ func TestCommentEditsBelongToTheirAuthor(t *testing.T) {
1926 1926 t.Fatalf("refusal did not name the author: %v", err)
1927 1927 }
1928 1928 // None of those refusals touched the thread.
1929 if _, body := f.get("/r/" + f.repo + "/issues/" + issueID); !strings.Contains(body, "original words") {
1929 if _, body := f.get("/" + f.repo + "/issues/" + issueID); !strings.Contains(body, "original words") {
1930 1930 t.Fatalf("a refused edit changed the rendered thread:\n%s", body)
1931 1931 }
1932 1932
@@ -1937,7 +1937,7 @@ func TestCommentEditsBelongToTheirAuthor(t *testing.T) {
1937 1937 if err := meta.apply(map[string]string{commentPath(wilburCm): commentFile("wilbur", created, "second draft\n")}, "edit own"); err != nil {
1938 1938 t.Fatalf("edit own comment: %v", err)
1939 1939 }
1940 _, body := f.get("/r/" + f.repo + "/issues/" + issueID)
1940 _, body := f.get("/" + f.repo + "/issues/" + issueID)
1941 1941 if !strings.Contains(body, "second draft") || strings.Contains(body, "first draft") {
1942 1942 t.Fatalf("own edit did not replace the body:\n%s", body)
1943 1943 }
@@ -1954,7 +1954,7 @@ func TestCommentEditsBelongToTheirAuthor(t *testing.T) {
1954 1954 if err := admin.remove(commentPath(wilburCm)); err != nil {
1955 1955 t.Fatalf("admin delete: %v", err)
1956 1956 }
1957 if _, body := f.get("/r/" + f.repo + "/issues/" + issueID); strings.Contains(body, "second draft") {
1957 if _, body := f.get("/" + f.repo + "/issues/" + issueID); strings.Contains(body, "second draft") {
1958 1958 t.Fatal("a deleted comment is still rendered")
1959 1959 }
1960 1960 audit, err := os.ReadFile(f.cfg.AuditLog())
@@ -1985,13 +1985,13 @@ func TestCrossReferencesLinksIssues(t *testing.T) {
1985 1985 t.Fatalf("push issues: %s", errs)
1986 1986 }
1987 1987
1988 _, body := f.get("/r/" + f.repo + "/issues/" + other)
1989 if !strings.Contains(body, `href="/r/demo/issues/`+target+`"`) {
1988 _, body := f.get("/" + f.repo + "/issues/" + other)
1989 if !strings.Contains(body, `href="/demo/issues/`+target+`"`) {
1990 1990 t.Fatalf("cross-reference not linked:\n%s", body)
1991 1991 }
1992 1992 // The reference inside a fenced block must stay literal, not become a link.
1993 1993 fence := body[strings.Index(body, "<pre>"):]
1994 if strings.Contains(fence[:min(len(fence), 400)], `href="/r/demo/issues/`+target) {
1994 if strings.Contains(fence[:min(len(fence), 400)], `href="/demo/issues/`+target) {
1995 1995 t.Fatal("cross-reference inside a code fence was linked")
1996 1996 }
1997 1997
@@ -2014,7 +2014,7 @@ func TestCrossReferencesLinksIssues(t *testing.T) {
2014 2014 t.Fatalf("push main: %s", errs)
2015 2015 }
2016 2016 f.publish(f.repo)
2017 if _, body := f.get("/r/" + f.repo + "/issues/" + target); !strings.Contains(body, "Referenced in commits") {
2017 if _, body := f.get("/" + f.repo + "/issues/" + target); !strings.Contains(body, "Referenced in commits") {
2018 2018 t.Fatalf("backlink section missing:\n%s", body)
2019 2019 }
2020 2020 }
@@ -3072,14 +3072,14 @@ jobs:
3072 3072 if err != nil {
3073 3073 t.Fatalf("finished run has no log file: %v", err)
3074 3074 }
3075 code, body := f.get("/r/" + f.repo + "/runs/" + done.ID + "/log")
3075 code, body := f.get("/" + f.repo + "/runs/" + done.ID + "/log")
3076 3076 if code != 200 || !strings.Contains(body, "hello from the runner") {
3077 3077 t.Fatalf("run log page = %d, body:\n%s", code, body)
3078 3078 }
3079 3079 if body != string(stored) {
3080 3080 t.Fatalf("the served log differs from %s", logPath)
3081 3081 }
3082 dlReq, dlErr := http.NewRequest(http.MethodGet, f.http.URL+"/r/"+f.repo+"/runs/"+done.ID+"/log?download=1", nil)
3082 dlReq, dlErr := http.NewRequest(http.MethodGet, f.http.URL+"/"+f.repo+"/runs/"+done.ID+"/log?download=1", nil)
3083 3083 if dlErr != nil {
3084 3084 t.Fatal(dlErr)
3085 3085 }
@@ -3124,7 +3124,7 @@ jobs:
3124 3124 if rec.Steps[0].LogSize == 0 || rec.Steps[0].Duration() <= 0 {
3125 3125 t.Fatalf("the step has no timing or log range: %+v", rec.Steps[0])
3126 3126 }
3127 code, stepLog := f.get("/r/" + f.repo + "/runs/" + done.ID + "/log?step=1")
3127 code, stepLog := f.get("/" + f.repo + "/runs/" + done.ID + "/log?step=1")
3128 3128 if code != 200 || !strings.Contains(stepLog, "hello from the runner") {
3129 3129 t.Fatalf("step log = %d, body:\n%s", code, stepLog)
3130 3130 }
@@ -3133,7 +3133,7 @@ jobs:
3133 3133 }
3134 3134 // The archived object is served directly, so a range request works without the
3135 3135 // server reading the whole log.
3136 req, err := http.NewRequest(http.MethodGet, f.http.URL+"/r/"+f.repo+"/runs/"+done.ID+"/log", nil)
3136 req, err := http.NewRequest(http.MethodGet, f.http.URL+"/"+f.repo+"/runs/"+done.ID+"/log", nil)
3137 3137 if err != nil {
3138 3138 t.Fatal(err)
3139 3139 }
@@ -3149,7 +3149,7 @@ jobs:
3149 3149 // Attribution is the boundary's, and the page says who it accepted the result from.
3150 3150 // A finished run shows each step once; the whole log is a download, not a second
3151 3151 // copy of every step's output in an accordion above them.
3152 if _, page := f.get("/r/" + f.repo + "/runs/" + done.ID); !strings.Contains(page, "reported by") ||
3152 if _, page := f.get("/" + f.repo + "/runs/" + done.ID); !strings.Contains(page, "reported by") ||
3153 3153 !strings.Contains(page, "say hello") || !strings.Contains(page, "step=1") ||
3154 3154 !strings.Contains(page, "Download full log") || !strings.Contains(page, "download=1") {
3155 3155 t.Fatalf("the run page does not show the result, the step log, or the full-log download:\n%s", page)
@@ -3195,7 +3195,7 @@ jobs:
3195 3195 run := f.waitForRun(t, 10*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusRunning })
3196 3196 deadline := time.Now().Add(8 * time.Second)
3197 3197 for {
3198 code, body := f.get("/r/" + f.repo + "/runs/" + run.ID + "/log?after=0")
3198 code, body := f.get("/" + f.repo + "/runs/" + run.ID + "/log?after=0")
3199 3199 if code == 200 && strings.Contains(body, "first line") {
3200 3200 break
3201 3201 }
@@ -3204,12 +3204,12 @@ jobs:
3204 3204 }
3205 3205 time.Sleep(100 * time.Millisecond)
3206 3206 }
3207 if _, page := f.get("/r/" + f.repo + "/runs/" + run.ID); !strings.Contains(page, "data-log-stream") ||
3207 if _, page := f.get("/" + f.repo + "/runs/" + run.ID); !strings.Contains(page, "data-log-stream") ||
3208 3208 !strings.Contains(page, "Job output") || !strings.Contains(page, "slow") {
3209 3209 t.Fatalf("the running run page does not show the live log viewer or step accordion:\n%s", page)
3210 3210 }
3211 3211 // The live viewer polls; a running attempt must not claim the log is final.
3212 resp, err := http.Get(f.http.URL + "/r/" + f.repo + "/runs/" + run.ID + "/log?after=0")
3212 resp, err := http.Get(f.http.URL + "/" + f.repo + "/runs/" + run.ID + "/log?after=0")
3213 3213 if err != nil {
3214 3214 t.Fatal(err)
3215 3215 }
@@ -3224,11 +3224,11 @@ jobs:
3224 3224 if done.Status != runs.StatusSuccess {
3225 3225 t.Fatalf("run status = %s (exit %d)", done.Status, done.ExitCode)
3226 3226 }
3227 code, body := f.get("/r/" + f.repo + "/runs/" + done.ID + "/log")
3227 code, body := f.get("/" + f.repo + "/runs/" + done.ID + "/log")
3228 3228 if code != 200 || !strings.Contains(body, "first line") || !strings.Contains(body, "second line") {
3229 3229 t.Fatalf("finished log = %d, body:\n%s", code, body)
3230 3230 }
3231 resp, err = http.Get(f.http.URL + "/r/" + f.repo + "/runs/" + done.ID + "/log?after=0")
3231 resp, err = http.Get(f.http.URL + "/" + f.repo + "/runs/" + done.ID + "/log?after=0")
3232 3232 if err != nil {
3233 3233 t.Fatal(err)
3234 3234 }
@@ -3375,7 +3375,7 @@ jobs:
3375 3375 }
3376 3376
3377 3377 // The page reads the attempt, not a tree walk.
3378 if _, page := f.get("/r/" + f.repo + "/runs/" + done.ID); !strings.Contains(page, "say hello") {
3378 if _, page := f.get("/" + f.repo + "/runs/" + done.ID); !strings.Contains(page, "say hello") {
3379 3379 t.Fatalf("the run page does not show the stored step:\n%s", page)
3380 3380 }
3381 3381 }
@@ -3433,7 +3433,7 @@ jobs:
3433 3433 return r.Status == runs.StatusSuccess || r.Status == runs.StatusFailed
3434 3434 })
3435 3435 if done.Status != runs.StatusSuccess {
3436 _, body := f.get("/r/" + f.repo + "/runs/" + done.ID + "/log")
3436 _, body := f.get("/" + f.repo + "/runs/" + done.ID + "/log")
3437 3437 t.Fatalf("the retried run did not succeed: %s (exit %d)\n%s",
3438 3438 done.Status, done.ExitCode, body)
3439 3439 }
@@ -3512,7 +3512,7 @@ jobs:
3512 3512 // way the web does rather than expecting text in the queue row.
3513 3513 logOf := func(r *runs.Run) string {
3514 3514 t.Helper()
3515 code, body := f.get("/r/" + f.repo + "/runs/" + r.ID + "/log")
3515 code, body := f.get("/" + f.repo + "/runs/" + r.ID + "/log")
3516 3516 if code != 200 {
3517 3517 t.Fatalf("log of run %s = %d", r.ID, code)
3518 3518 }
@@ -3671,7 +3671,7 @@ func TestOnlyAllowedRunnersGreenLightABranch(t *testing.T) {
3671 3671 t.Fatalf("the refusal does not say why:\n%s", errs)
3672 3672 }
3673 3673 // The page explains the same thing where somebody would look for it.
3674 if _, page := f.get("/r/" + f.repo + "/runs/" + done.ID); !strings.Contains(page, "does not count as a check") {
3674 if _, page := f.get("/" + f.repo + "/runs/" + done.ID); !strings.Contains(page, "does not count as a check") {
3675 3675 t.Fatalf("the run page does not say the result does not count:\n%s", page)
3676 3676 }
3677 3677
@@ -4004,7 +4004,7 @@ func repoID(t *testing.T, f *fixture) string {
4004 4004 func TestHealthzIsAPlainProbe(t *testing.T) {
4005 4005 t.Parallel()
4006 4006 f := newFixture(t)
4007 code, body := f.get("/healthz")
4007 code, body := f.get("/-/healthz")
4008 4008 if code != 200 {
4009 4009 t.Fatalf("healthz: %d", code)
4010 4010 }
@@ -4065,7 +4065,7 @@ jobs:
4065 4065 if done.Status != runs.StatusSuccess {
4066 4066 t.Fatalf("run status = %s", done.Status)
4067 4067 }
4068 if code, body := f.get("/r/" + f.repo + "/runs/" + done.ID + "/log"); code != 200 ||
4068 if code, body := f.get("/" + f.repo + "/runs/" + done.ID + "/log"); code != 200 ||
4069 4069 !strings.Contains(body, "on the right machine") {
4070 4070 t.Fatalf("run log = %d:\n%s", code, body)
4071 4071 }