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: issue CLI, cross-references, backlinks, filter and search

c1e3e3ca696c2bae8692775977fdae21ed50bc4a · Keenan Nemetz · 18d ago · parent 37c01e5a

Phase 2: issue CLI, cross-references, backlinks, filter and search

The issue format needed no client, and still does not: every one of these is
sugar over files anyone can write. The CLI exists so the common case is not
ceremony.

- `vole issue new|comment|close|reopen|list|show` works on the clone in the
  current directory. It fetches the issues branch first so its commit is a
  fast-forward the server will accept, validates locally so a malformed issue
  never costs a round trip, then pushes. Writing goes through gitx.CommitFiles,
  the shared CAS path, so a concurrent author's commit cannot be clobbered.
- Ids may be abbreviated. `list` prints eight characters, so every id-taking
  command resolves prefixes and refuses an ambiguous one with the candidates
  listed. This was a real bug: list printed short ids while comment and close
  demanded the full ULID, so copying the id from the CLI's own output failed.
  The integration test now drives the CLI with a prefix.
- `#<ulid>` in an issue body or comment renders as a link. Rewriting happens on
  the markdown source, skipping fenced blocks so a reference inside a code
  sample stays literal; a test asserts both halves.
- Backlinks: commits whose message mentions an id are listed on the issue, found
  with `git log --grep`. git does the search, so there is no second index.
- Filtering and search on the list page: state, label, and a LIKE over title and
  body, backed by an `issue_labels` table and an indexed `body` column.

Notifications are deliberately not built. The web is read-only and has no user
identity, so "in-app" has nobody to notify and email would need subscriptions,
which need a store. The honest fit is a per-repo Atom feed when someone wants
one; the roadmap says so rather than leaving a phantom item.

Verified by hand with a clone and a key: filed an issue, listed it, commented
using the short id, closed it, confirmed the web showed the closed state, the
rendered comment and the label filter, and that an ambiguous prefix is refused.

internal/server/integration_test.go +190−2

@@ -565,7 +565,7 @@ func TestIssuesSurviveIndexRebuild(t *testing.T) {
565 565 if err := f.srv.Index.EnsureIssues(mustRepo(t, f)); err != nil {
566 566 t.Fatal(err)
567 567 }
568 before, err := f.srv.Index.Issues(repoID(t, f))
568 before, err := f.srv.Index.Issues(repoID(t, f), index.Filter{})
569 569 if err != nil {
570 570 t.Fatal(err)
571 571 }
@@ -576,7 +576,7 @@ func TestIssuesSurviveIndexRebuild(t *testing.T) {
576 576 if _, err := f.srv.RebuildIndex(); err != nil {
577 577 t.Fatal(err)
578 578 }
579 after, err := f.srv.Index.Issues(repoID(t, f))
579 after, err := f.srv.Index.Issues(repoID(t, f), index.Filter{})
580 580 if err != nil {
581 581 t.Fatal(err)
582 582 }
@@ -585,6 +585,194 @@ func TestIssuesSurviveIndexRebuild(t *testing.T) {
585 585 }
586 586 }
587 587
588 // TestIssueCLIEndToEnd drives the sugar path: a clone with a remote, the CLI
589 // writing files and committing, and the push landing on the server.
590 func TestIssueCLIEndToEnd(t *testing.T) {
591 f := newFixture(t)
592 f.publish(f.repo)
593 // A commit on main so the clone is not empty and has a default branch.
594 work := workRepo(t, map[string]string{"readme.md": "hi\n"})
595 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
596 t.Fatalf("push main: %s", errs)
597 }
598
599 clone := filepath.Join(t.TempDir(), "clone")
600 if _, errs, err := f.git("", "clone", "-q", f.sshURL(f.repo), clone); err != nil {
601 t.Fatalf("clone: %s", errs)
602 }
603
604 run := func(args ...string) (string, string, error) {
605 cmd := exec.Command(binPath, args...)
606 cmd.Dir = clone
607 cmd.Env = append(os.Environ(),
608 "GIT_SSH_COMMAND=ssh -i "+f.keyPath+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR",
609 "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
610 "USER=keenan")
611 var out, errb bytes.Buffer
612 cmd.Stdout, cmd.Stderr = &out, &errb
613 err := cmd.Run()
614 return out.String(), errb.String(), err
615 }
616
617 out, errs, err := run("issue", "new", "-title", "Filed from the CLI", "-body", "Body from the CLI.", "-label", "bug")
618 if err != nil {
619 t.Fatalf("issue new: %v\n%s", err, errs)
620 }
621 fields := strings.Fields(out)
622 if len(fields) == 0 {
623 t.Fatalf("no id printed: %q", out)
624 }
625 id := fields[0]
626
627 if !strings.HasPrefix(f.serverIssues(t), "") {
628 t.Fatal("issues branch missing on the server")
629 }
630 if _, body := f.get("/r/" + f.repo + "/issues"); !strings.Contains(body, "Filed from the CLI") {
631 t.Fatalf("issue not rendered after CLI push:\n%s", body)
632 }
633 if code, _ := f.get("/r/" + f.repo + "/issues/" + id); code != 200 {
634 t.Fatalf("issue page: %d", code)
635 }
636
637 // Use the short id that `issue list` prints: a prefix has to resolve, or the
638 // CLI's own output is unusable.
639 short := id
640 if len(short) > 8 {
641 short = short[:8]
642 }
643 if out, errs, err := run("issue", "list"); err != nil || !strings.Contains(out, short) {
644 t.Fatalf("issue list did not show a usable id: %v %q %s", err, out, errs)
645 }
646 if _, errs, err := run("issue", "comment", short, "-body", "A reply from the CLI."); err != nil {
647 t.Fatalf("comment: %v\n%s", err, errs)
648 }
649 if _, body := f.get("/r/" + f.repo + "/issues/" + id); !strings.Contains(body, "A reply from the CLI.") {
650 t.Fatal("comment not rendered")
651 }
652
653 if _, errs, err := run("issue", "close", short); err != nil {
654 t.Fatalf("close: %v\n%s", err, errs)
655 }
656 if _, body := f.get("/r/" + f.repo + "/issues/" + id); !strings.Contains(body, "closed") {
657 t.Fatal("issue did not close")
658 }
659
660 // And a local validation failure never reaches the server.
661 if _, errs, err := run("issue", "new", "-title", "", "-body", "x"); err == nil {
662 t.Fatalf("empty title accepted by the CLI: %s", errs)
663 }
664 }
665
666 func (f *fixture) serverIssues(t *testing.T) string {
667 t.Helper()
668 out, _, _ := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"),
669 "rev-parse", "--verify", "--quiet", "refs/heads/issues")
670 return strings.TrimSpace(out)
671 }
672
673 // TestIssueFiltersAndSearch covers the SQL paths behind the list page.
674 func TestIssueFiltersAndSearch(t *testing.T) {
675 f := newFixture(t)
676 f.publish(f.repo)
677 files := map[string]string{}
678 add := func(id, title, state, body string, labels string) {
679 files["issues/"+id+"/meta.json"] = fmt.Sprintf(
680 `{"title":%q,"state":%q,"author":"keenan","created":"2026-10-22T21:00:00Z","labels":%s}`,
681 title, state, labels)
682 files["issues/"+id+"/description.md"] = body
683 }
684 add("01M5JR2JK29KKMAA1T9MDNTPB1", "Segfault on startup", "open", "crash in parser", `["bug"]`)
685 add("01M5JR2JK29KKMAA1T9MDNTPB2", "Docs typo", "closed", "spelling", `["docs"]`)
686 add("01M5JR2JK29KKMAA1T9MDNTPB3", "Slow parser", "open", "throughput", `["bug","perf"]`)
687
688 work := f.issuesRepo(t, files)
689 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/issues"); err != nil {
690 t.Fatalf("push: %s", errs)
691 }
692
693 for _, c := range []struct{ query, want string }{
694 {"", "Docs typo"},
695 {"?label=bug", "Segfault on startup"},
696 {"?state=closed", "Docs typo"},
697 {"?q=parser", "Slow parser"},
698 } {
699 code, body := f.get("/r/" + f.repo + "/issues" + c.query)
700 if code != 200 {
701 t.Fatalf("%q: status %d", c.query, code)
702 }
703 if !strings.Contains(body, c.want) {
704 t.Fatalf("%q: missing %q", c.query, c.want)
705 }
706 }
707 // A label filter must exclude issues without that label.
708 if _, body := f.get("/r/" + f.repo + "/issues?label=docs"); strings.Contains(body, "Segfault") {
709 t.Fatal("label filter did not exclude other issues")
710 }
711 // Negation case: searching a word only in the body.
712 if _, body := f.get("/r/" + f.repo + "/issues?q=throughput"); !strings.Contains(body, "Slow parser") {
713 t.Fatal("body search did not match")
714 }
715 }
716
717 // TestCrossReferencesLinksIssues: a #<ulid> in a body becomes a link, and a
718 // commit mentioning an issue shows as a backlink.
719 func TestCrossReferencesLinksIssues(t *testing.T) {
720 f := newFixture(t)
721 f.publish(f.repo)
722 const target = "01M5JR2JK29KKMAA1T9MDNTPC9"
723 const other = "01M5JR2JK29KKMAA1T9MDNTPD1"
724
725 files := issueFiles(target, "Referenced issue", "plain\n")
726 for k, v := range issueFiles(other, "Referring issue",
727 "See #"+target+" for context.\n\n```\n#"+target+"\n```\n") {
728 files[k] = v
729 }
730 work := f.issuesRepo(t, files)
731 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/issues"); err != nil {
732 t.Fatalf("push issues: %s", errs)
733 }
734
735 _, body := f.get("/r/" + f.repo + "/issues/" + other)
736 if !strings.Contains(body, `href="/r/demo/issues/`+target+`"`) {
737 t.Fatalf("cross-reference not linked:\n%s", body)
738 }
739 // The reference inside a fenced block must stay literal, not become a link.
740 fence := body[strings.Index(body, "<pre>"):]
741 if strings.Contains(fence[:min(len(fence), 400)], `href="/r/demo/issues/`+target) {
742 t.Fatal("cross-reference inside a code fence was linked")
743 }
744
745 // Backlink: a commit message mentioning the issue id. git --grep searches
746 // messages, not file contents.
747 readme := workRepo(t, map[string]string{"readme.md": "change\n"})
748 if _, errs, err := f.git(readme, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
749 t.Fatalf("push main: %s", errs)
750 }
751 if err := os.WriteFile(filepath.Join(readme, "b.txt"), []byte("x\n"), 0o644); err != nil {
752 t.Fatal(err)
753 }
754 if _, errs, err := runGit(t, readme, "add", "-A"); err != nil {
755 t.Fatalf("add: %v: %s", err, errs)
756 }
757 if _, errs, err := runGit(t, readme, "commit", "-qm", "fixes #"+target); err != nil {
758 t.Fatalf("commit: %v: %s", err, errs)
759 }
760 if _, errs, err := f.git(readme, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
761 t.Fatalf("push main: %s", errs)
762 }
763 f.publish(f.repo)
764 if _, body := f.get("/r/" + f.repo + "/issues/" + target); !strings.Contains(body, "Referenced in commits") {
765 t.Fatalf("backlink section missing:\n%s", body)
766 }
767 }
768
769 func min(a, b int) int {
770 if a < b {
771 return a
772 }
773 return b
774 }
775
588 776 func mustRepo(t *testing.T, f *fixture) *repo.Meta {
589 777 t.Helper()
590 778 m, err := f.srv.Repos.Open(f.repo)