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.
Phase 2, first slice: issues as files on a branch
37c01e5aa9b98bbea8567e741cd3af508ae1ba7d · Keenan Nemetz · 17d ago · parent 197ecaa2
Phase 2, first slice: issues as files on a branch Push-based issues, per the decision to keep the web read-only. The format is the contract; nothing here requires a Vole binary on a contributor's machine. issues/<ulid>/meta.json issues/<ulid>/description.md issues/<ulid>/comments/<ulid>.md on refs/heads/issues Design points worth recording: - IDs are ULIDs generated by the writer, so there is no server-assigned counter, no contention, and no ID allocation in the write path at all. - Issues live on a branch rather than a custom ref, so a plain `git clone` carries them. That is the same lesson that moved the user roster off refs/vole/users: `git clone` only mirrors refs/heads/*. - File-per-comment means two people replying to the same issue never touch the same file, so the only merge conflict is two edits to one comment. - Validation happens in pre-receive, so a malformed tree never reaches the web. Every problem is reported at once with the offending path, and a rejected push leaves the branch unmoved. The index gained issue rows, refreshed when the issues ref moves rather than by a hook, plus coverage in the full rebuild. The list page reads the index; the detail page reads git, so bodies and comments are never stale. Markdown is rendered with goldmark and sanitised with bluemonday. Issue bodies arrive by push, which makes them untrusted input on a public page; a test asserts that <script> and javascript: URLs do not survive. Verified by hand with a user who has an editor and a key: filed an issue, added a comment, saw both rendered, then pushed an issue with no title and got back "issues/01M5.../meta.json: title is required" with the branch unmoved.
internal/server/integration_test.go +186
| @@ -24,6 +24,7 @@ import ( | |||
| 24 | 24 | "github.com/rodent-software/vole/internal/config" | |
| 25 | 25 | "github.com/rodent-software/vole/internal/identity" | |
| 26 | 26 | "github.com/rodent-software/vole/internal/index" | |
| 27 | "github.com/rodent-software/vole/internal/repo" | ||
| 27 | 28 | "github.com/rodent-software/vole/internal/server" | |
| 28 | 29 | ) | |
| 29 | 30 | ||
| @@ -413,6 +414,191 @@ func TestRosterIsReloadable(t *testing.T) { | |||
| 413 | 414 | } | |
| 414 | 415 | } | |
| 415 | 416 | ||
| 417 | const testIssueID = "01M5JR2JK29KKMAA1T9MDNTPBP" | ||
| 418 | |||
| 419 | func (f *fixture) issuesRepo(t *testing.T, files map[string]string) string { | ||
| 420 | t.Helper() | ||
| 421 | dir := t.TempDir() | ||
| 422 | if _, errs, err := runGit(t, dir, "init", "-q", "-b", "issues"); err != nil { | ||
| 423 | t.Fatalf("init: %v: %s", err, errs) | ||
| 424 | } | ||
| 425 | for name, content := range files { | ||
| 426 | p := filepath.Join(dir, name) | ||
| 427 | if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { | ||
| 428 | t.Fatal(err) | ||
| 429 | } | ||
| 430 | if err := os.WriteFile(p, []byte(content), 0o644); err != nil { | ||
| 431 | t.Fatal(err) | ||
| 432 | } | ||
| 433 | } | ||
| 434 | if _, errs, err := runGit(t, dir, "add", "-A"); err != nil { | ||
| 435 | t.Fatalf("add: %v: %s", err, errs) | ||
| 436 | } | ||
| 437 | if _, errs, err := runGit(t, dir, "commit", "-qm", "issues"); err != nil { | ||
| 438 | t.Fatalf("commit: %v: %s", err, errs) | ||
| 439 | } | ||
| 440 | return dir | ||
| 441 | } | ||
| 442 | |||
| 443 | func issueFiles(id, title, desc string) map[string]string { | ||
| 444 | return map[string]string{ | ||
| 445 | "issues/" + id + "/meta.json": fmt.Sprintf( | ||
| 446 | `{"title":%q,"state":"open","author":"keenan","created":"2026-10-22T21:00:00Z","labels":["bug"]}`, title), | ||
| 447 | "issues/" + id + "/description.md": desc, | ||
| 448 | } | ||
| 449 | } | ||
| 450 | |||
| 451 | // TestIssuePushedThenRendered is the whole option-A loop: a user with an editor | ||
| 452 | // and a key files a bug, and the read-only web shows it. | ||
| 453 | func TestIssuePushedThenRendered(t *testing.T) { | ||
| 454 | f := newFixture(t) | ||
| 455 | f.publish(f.repo) | ||
| 456 | |||
| 457 | desc := "Steps:\n\n1. push an issue\n2. read it back\n\n**bold claim** and `code`\n" | ||
| 458 | work := f.issuesRepo(t, issueFiles(testIssueID, "Push-based issues work", desc)) | ||
| 459 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/issues"); err != nil { | ||
| 460 | t.Fatalf("push issues: %v\n%s", err, errs) | ||
| 461 | } | ||
| 462 | |||
| 463 | code, body := f.get("/r/" + f.repo + "/issues") | ||
| 464 | if code != 200 { | ||
| 465 | t.Fatalf("issue list: %d", code) | ||
| 466 | } | ||
| 467 | if !strings.Contains(body, "Push-based issues work") { | ||
| 468 | t.Fatalf("issue list missing the title:\n%s", body) | ||
| 469 | } | ||
| 470 | if !strings.Contains(body, "bug") { | ||
| 471 | t.Fatal("issue list missing the label") | ||
| 472 | } | ||
| 473 | |||
| 474 | code, body = f.get("/r/" + f.repo + "/issues/" + testIssueID) | ||
| 475 | if code != 200 { | ||
| 476 | t.Fatalf("issue view: %d", code) | ||
| 477 | } | ||
| 478 | if !strings.Contains(body, "<strong>bold claim</strong>") { | ||
| 479 | t.Fatalf("markdown was not rendered:\n%s", body) | ||
| 480 | } | ||
| 481 | if !strings.Contains(body, "<code>code</code>") { | ||
| 482 | t.Fatal("inline code was not rendered") | ||
| 483 | } | ||
| 484 | // The repo page advertises the count. | ||
| 485 | if _, body := f.get("/r/" + f.repo); !strings.Contains(body, "Issues") { | ||
| 486 | t.Fatal("repo page has no issues link") | ||
| 487 | } | ||
| 488 | } | ||
| 489 | |||
| 490 | // TestIssueMarkdownIsSanitised: issue bodies arrive by push, so an issue is | ||
| 491 | // untrusted input rendered into a public page. | ||
| 492 | func TestIssueMarkdownIsSanitised(t *testing.T) { | ||
| 493 | f := newFixture(t) | ||
| 494 | f.publish(f.repo) | ||
| 495 | desc := "hello\n\n<script>alert('xss')</script>\n\n[click](javascript:alert(1))\n" | ||
| 496 | work := f.issuesRepo(t, issueFiles(testIssueID, "XSS attempt", desc)) | ||
| 497 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/issues"); err != nil { | ||
| 498 | t.Fatalf("push: %s", errs) | ||
| 499 | } | ||
| 500 | code, body := f.get("/r/" + f.repo + "/issues/" + testIssueID) | ||
| 501 | if code != 200 { | ||
| 502 | t.Fatalf("status %d", code) | ||
| 503 | } | ||
| 504 | if strings.Contains(body, "<script>alert") { | ||
| 505 | t.Fatal("script tag survived sanitisation") | ||
| 506 | } | ||
| 507 | if strings.Contains(body, "javascript:alert") { | ||
| 508 | t.Fatal("javascript: URL survived sanitisation") | ||
| 509 | } | ||
| 510 | } | ||
| 511 | |||
| 512 | // TestInvalidIssuePushRejected proves validation happens at the boundary: the | ||
| 513 | // push fails, the message says why, and the branch does not move. | ||
| 514 | func TestInvalidIssuePushRejected(t *testing.T) { | ||
| 515 | f := newFixture(t) | ||
| 516 | f.publish(f.repo) | ||
| 517 | |||
| 518 | good := f.issuesRepo(t, issueFiles(testIssueID, "fine", "body\n")) | ||
| 519 | if _, errs, err := f.git(good, "push", f.sshURL(f.repo), "HEAD:refs/heads/issues"); err != nil { | ||
| 520 | t.Fatalf("good push: %s", errs) | ||
| 521 | } | ||
| 522 | before, _, _ := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"), | ||
| 523 | "rev-parse", "--verify", "--quiet", "refs/heads/issues") | ||
| 524 | |||
| 525 | // Same history, but with a title-less issue added. | ||
| 526 | bad := issueFiles("01M5JR2JK29KKMAA1T9MDNTPBZ", "", "no title\n") | ||
| 527 | p := filepath.Join(good, "issues", "01M5JR2JK29KKMAA1T9MDNTPBZ") | ||
| 528 | if err := os.MkdirAll(p, 0o755); err != nil { | ||
| 529 | t.Fatal(err) | ||
| 530 | } | ||
| 531 | for name, content := range bad { | ||
| 532 | if err := os.WriteFile(filepath.Join(good, name), []byte(content), 0o644); err != nil { | ||
| 533 | t.Fatal(err) | ||
| 534 | } | ||
| 535 | } | ||
| 536 | if _, errs, err := runGit(t, good, "add", "-A"); err != nil { | ||
| 537 | t.Fatalf("add: %s", errs) | ||
| 538 | } | ||
| 539 | if _, errs, err := runGit(t, good, "commit", "-qm", "bad issue"); err != nil { | ||
| 540 | t.Fatalf("commit: %s", errs) | ||
| 541 | } | ||
| 542 | _, errs, err := f.git(good, "push", f.sshURL(f.repo), "HEAD:refs/heads/issues") | ||
| 543 | if err == nil { | ||
| 544 | t.Fatal("push with a title-less issue was accepted") | ||
| 545 | } | ||
| 546 | if !strings.Contains(errs, "title is required") { | ||
| 547 | t.Fatalf("rejection did not explain why:\n%s", errs) | ||
| 548 | } | ||
| 549 | after, _, _ := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"), | ||
| 550 | "rev-parse", "--verify", "--quiet", "refs/heads/issues") | ||
| 551 | if strings.TrimSpace(before) != strings.TrimSpace(after) { | ||
| 552 | t.Fatalf("issues branch moved on a rejected push: %s -> %s", before, after) | ||
| 553 | } | ||
| 554 | } | ||
| 555 | |||
| 556 | // TestIssuesSurviveIndexRebuild: issue rows are derived, so deleting the index | ||
| 557 | // must not lose them. | ||
| 558 | func TestIssuesSurviveIndexRebuild(t *testing.T) { | ||
| 559 | f := newFixture(t) | ||
| 560 | f.publish(f.repo) | ||
| 561 | work := f.issuesRepo(t, issueFiles(testIssueID, "persisted", "body\n")) | ||
| 562 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/issues"); err != nil { | ||
| 563 | t.Fatalf("push: %s", errs) | ||
| 564 | } | ||
| 565 | if err := f.srv.Index.EnsureIssues(mustRepo(t, f)); err != nil { | ||
| 566 | t.Fatal(err) | ||
| 567 | } | ||
| 568 | before, err := f.srv.Index.Issues(repoID(t, f)) | ||
| 569 | if err != nil { | ||
| 570 | t.Fatal(err) | ||
| 571 | } | ||
| 572 | if len(before) != 1 { | ||
| 573 | t.Fatalf("indexed issues = %d, want 1", len(before)) | ||
| 574 | } | ||
| 575 | |||
| 576 | if _, err := f.srv.RebuildIndex(); err != nil { | ||
| 577 | t.Fatal(err) | ||
| 578 | } | ||
| 579 | after, err := f.srv.Index.Issues(repoID(t, f)) | ||
| 580 | if err != nil { | ||
| 581 | t.Fatal(err) | ||
| 582 | } | ||
| 583 | if len(after) != 1 || after[0].Title != before[0].Title || after[0].ID != before[0].ID { | ||
| 584 | t.Fatalf("rebuild lost issues: %+v vs %+v", before, after) | ||
| 585 | } | ||
| 586 | } | ||
| 587 | |||
| 588 | func mustRepo(t *testing.T, f *fixture) *repo.Meta { | ||
| 589 | t.Helper() | ||
| 590 | m, err := f.srv.Repos.Open(f.repo) | ||
| 591 | if err != nil { | ||
| 592 | t.Fatal(err) | ||
| 593 | } | ||
| 594 | return m | ||
| 595 | } | ||
| 596 | |||
| 597 | func repoID(t *testing.T, f *fixture) string { | ||
| 598 | t.Helper() | ||
| 599 | return mustRepo(t, f).ID | ||
| 600 | } | ||
| 601 | |||
| 416 | 602 | func TestHealthzReportsUsers(t *testing.T) { | |
| 417 | 603 | f := newFixture(t) | |
| 418 | 604 | code, body := f.get("/healthz") | |