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.

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