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/gitx/gitx.go +46−1

@@ -30,6 +30,13 @@ type Repo struct {
30 30 func Open(dir string) *Repo { return &Repo{Dir: dir} }
31 31
32 32 func (r *Repo) run(stdin []byte, args ...string) ([]byte, string, error) {
33 return r.runEnv(nil, stdin, args...)
34 }
35
36 // runEnv is run with extra environment entries appended, so a single call can
37 // carry an author without mutating process state. os/exec keeps the last value
38 // for a duplicated key, which is what makes the override reliable.
39 func (r *Repo) runEnv(extra []string, stdin []byte, args ...string) ([]byte, string, error) {
33 40 cmd := exec.Command("git", args...)
34 41 cmd.Env = append(os.Environ(),
35 42 "GIT_DIR="+r.Dir,
@@ -37,6 +44,7 @@ func (r *Repo) run(stdin []byte, args ...string) ([]byte, string, error) {
37 44 "GIT_AUTHOR_NAME=vole", "GIT_AUTHOR_EMAIL=vole@localhost",
38 45 "GIT_COMMITTER_NAME=vole", "GIT_COMMITTER_EMAIL=vole@localhost",
39 46 )
47 cmd.Env = append(cmd.Env, extra...)
40 48 if stdin != nil {
41 49 cmd.Stdin = bytes.NewReader(stdin)
42 50 }
@@ -206,6 +214,27 @@ func (r *Repo) HashObject(payload []byte) (string, error) {
206 214 return strings.TrimSpace(string(out)), nil
207 215 }
208 216
217 // CommitTreeAs commits with a specific author, which is the audit record for a
218 // metadata change.
219 func (r *Repo) CommitTreeAs(tree, parent, msg, author string) (string, error) {
220 args := []string{"commit-tree", tree}
221 if parent != "" {
222 args = append(args, "-p", parent)
223 }
224 env := []string{}
225 if strings.TrimSpace(author) != "" {
226 name := strings.TrimSpace(author)
227 env = append(env,
228 "GIT_AUTHOR_NAME="+name, "GIT_AUTHOR_EMAIL="+name+"@vole",
229 "GIT_COMMITTER_NAME="+name, "GIT_COMMITTER_EMAIL="+name+"@vole")
230 }
231 out, errs, err := r.runEnv(env, []byte(msg), args...)
232 if err != nil {
233 return "", fmt.Errorf("commit-tree: %w: %s", err, errs)
234 }
235 return strings.TrimSpace(string(out)), nil
236 }
237
209 238 func (r *Repo) CommitTree(tree, parent, msg string) (string, error) {
210 239 args := []string{"commit-tree", tree}
211 240 if parent != "" {
@@ -306,6 +335,22 @@ func (r *Repo) Log(rev string, n int) ([]Commit, error) {
306 335 if err != nil {
307 336 return nil, fmt.Errorf("log %q: %w: %s", rev, err, errs)
308 337 }
338 return parseCommits(out), nil
339 }
340
341 // LogGrep returns commits whose message mentions pattern. Used for issue
342 // backlinks, so the search happens in git rather than in a second index.
343 func (r *Repo) LogGrep(rev, pattern string, n int) ([]Commit, error) {
344 out, errs, err := r.run(nil, "log",
345 "--grep="+pattern, "--fixed-strings", "--format=%H%x1f%an%x1f%ae%x1f%at%x1f%s",
346 "-n", strconv.Itoa(n), rev, "--")
347 if err != nil {
348 return nil, fmt.Errorf("log --grep %q: %w: %s", pattern, err, errs)
349 }
350 return parseCommits(out), nil
351 }
352
353 func parseCommits(out []byte) []Commit {
309 354 var cs []Commit
310 355 for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
311 356 if line == "" {
@@ -318,7 +363,7 @@ func (r *Repo) Log(rev string, n int) ([]Commit, error) {
318 363 ts, _ := strconv.ParseInt(f[3], 10, 64)
319 364 cs = append(cs, Commit{SHA: f[0], Author: f[1], Email: f[2], When: ts, Subject: f[4]})
320 365 }
321 return cs, nil
366 return cs
322 367 }
323 368
324 369 func (r *Repo) CountCommits(rev string) (int, error) {