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