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 | } | |