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/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software: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/index/issues.go +59−6

@@ -47,19 +47,30 @@ func (s *Store) IndexIssues(m *repo.Meta) error {
47 47 if _, err := tx.Exec(`DELETE FROM issues WHERE repo_id=?`, m.ID); err != nil {
48 48 return err
49 49 }
50 if _, err := tx.Exec(`DELETE FROM issue_labels WHERE repo_id=?`, m.ID); err != nil {
51 return err
52 }
50 53 for _, iss := range list {
51 54 updated := iss.Updated
52 55 if updated.IsZero() {
53 56 updated = iss.Created
54 57 }
55 58 if _, err := tx.Exec(
56 `INSERT INTO issues(repo_id, id, title, state, author, created, updated, labels, comments)
57 VALUES(?,?,?,?,?,?,?,?,?)`,
59 `INSERT INTO issues(repo_id, id, title, state, author, created, updated, labels, comments, body)
60 VALUES(?,?,?,?,?,?,?,?,?,?)`,
58 61 m.ID, iss.ID, iss.Title, iss.State, iss.Author,
59 62 iss.Created.Unix(), updated.Unix(), strings.Join(iss.Labels, "\x1f"), len(iss.Comments),
63 iss.Description,
60 64 ); err != nil {
61 65 return err
62 66 }
67 for _, l := range iss.Labels {
68 if _, err := tx.Exec(
69 `INSERT OR IGNORE INTO issue_labels(repo_id, issue_id, label) VALUES(?,?,?)`,
70 m.ID, iss.ID, l); err != nil {
71 return err
72 }
73 }
63 74 }
64 75 if _, err := tx.Exec(`UPDATE repos SET issues_sha=? WHERE id=?`, sha, m.ID); err != nil {
65 76 return err
@@ -75,10 +86,33 @@ func unixTime(sec int64) time.Time {
75 86 return time.Unix(sec, 0).UTC()
76 87 }
77 88
78 func (s *Store) Issues(repoID string) ([]IssueRow, error) {
79 rows, err := s.db.Query(
80 `SELECT id, title, state, author, created, updated, labels, comments
81 FROM issues WHERE repo_id=? ORDER BY id DESC`, repoID)
89 // Filter narrows an issue list. Empty fields are ignored.
90 type Filter struct {
91 State string
92 Label string
93 Query string
94 }
95
96 func (s *Store) Issues(repoID string, f Filter) ([]IssueRow, error) {
97 q := `SELECT id, title, state, author, created, updated, labels, comments
98 FROM issues WHERE repo_id=?`
99 args := []any{repoID}
100 if f.State != "" && f.State != "all" {
101 q += ` AND state=?`
102 args = append(args, f.State)
103 }
104 if f.Label != "" {
105 q += ` AND id IN (SELECT issue_id FROM issue_labels WHERE repo_id=? AND label=?)`
106 args = append(args, repoID, f.Label)
107 }
108 if term := strings.TrimSpace(f.Query); term != "" {
109 q += ` AND (title LIKE ? OR body LIKE ?)`
110 like := "%" + term + "%"
111 args = append(args, like, like)
112 }
113 q += ` ORDER BY id DESC`
114
115 rows, err := s.db.Query(q, args...)
82 116 if err != nil {
83 117 return nil, err
84 118 }
@@ -122,6 +156,25 @@ type errNotFound struct{}
122 156
123 157 func (errNotFound) Error() string { return "not found" }
124 158
159 // Labels returns the labels in use for a repo, for the filter UI.
160 func (s *Store) Labels(repoID string) ([]string, error) {
161 rows, err := s.db.Query(
162 `SELECT DISTINCT label FROM issue_labels WHERE repo_id=? ORDER BY label`, repoID)
163 if err != nil {
164 return nil, err
165 }
166 defer rows.Close()
167 var out []string
168 for rows.Next() {
169 var l string
170 if err := rows.Scan(&l); err != nil {
171 return nil, err
172 }
173 out = append(out, l)
174 }
175 return out, rows.Err()
176 }
177
125 178 // IssueCount is used by the repo page.
126 179 func (s *Store) IssueCount(repoID string) (int, error) {
127 180 var n int