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/web/issues.go +85−12

@@ -4,6 +4,7 @@ import (
4 4 "bytes"
5 5 "html/template"
6 6 "net/http"
7 "regexp"
7 8 "sort"
8 9 "strings"
9 10
@@ -11,19 +12,27 @@ import (
11 12 "github.com/yuin/goldmark"
12 13 "github.com/yuin/goldmark/extension"
13 14
15 "github.com/rodent-software/vole/internal/gitx"
14 16 "github.com/rodent-software/vole/internal/index"
15 17 "github.com/rodent-software/vole/internal/issues"
18 "github.com/rodent-software/vole/internal/repo"
16 19 )
17 20
18 21 var (
19 22 markdown = goldmark.New(goldmark.WithExtensions(extension.GFM, extension.Typographer))
20 23 sanitizer = bluemonday.UGCPolicy()
24 xrefRE = regexp.MustCompile(`#([0-9A-HJKMNP-TV-Z]{26})\b`)
21 25 )
22 26
23 27 // renderMarkdown converts untrusted commit content into HTML that is safe to put
24 28 // in a page. Issues are authored by pushes, so this is the boundary where a
25 29 // <script> tag stops being text.
26 func renderMarkdown(src string) template.HTML {
30 //
31 // `#<ulid>` becomes a link to the issue. That is done on the markdown source
32 // rather than on the HTML, and fenced code blocks are skipped so a reference
33 // inside a code sample stays literal.
34 func (w *Web) renderMarkdown(repoName, src string) template.HTML {
35 src = linkCrossReferences(repoName, src)
27 36 var buf bytes.Buffer
28 37 if err := markdown.Convert([]byte(src), &buf); err != nil {
29 38 return template.HTML(template.HTMLEscapeString(src))
@@ -31,6 +40,27 @@ func renderMarkdown(src string) template.HTML {
31 40 return template.HTML(sanitizer.SanitizeBytes(buf.Bytes()))
32 41 }
33 42
43 func linkCrossReferences(repoName, src string) string {
44 var out strings.Builder
45 inFence := false
46 for _, line := range strings.SplitAfter(src, "\n") {
47 if strings.HasPrefix(strings.TrimSpace(line), "```") {
48 inFence = !inFence
49 out.WriteString(line)
50 continue
51 }
52 if inFence {
53 out.WriteString(line)
54 continue
55 }
56 out.WriteString(xrefRE.ReplaceAllStringFunc(line, func(m string) string {
57 id := strings.TrimPrefix(m, "#")
58 return "[#" + id + "](/r/" + repoName + "/issues/" + id + ")"
59 }))
60 }
61 return out.String()
62 }
63
34 64 // Issues lists a repo's issues from the index, refreshing it if the issues branch
35 65 // moved since it was last read.
36 66 func (w *Web) Issues(rw http.ResponseWriter, r *http.Request) {
@@ -42,7 +72,12 @@ func (w *Web) Issues(rw http.ResponseWriter, r *http.Request) {
42 72 http.Error(rw, "issue index unavailable: "+err.Error(), http.StatusInternalServerError)
43 73 return
44 74 }
45 rows, err := w.Index.Issues(m.ID)
75 filter := index.Filter{
76 State: strings.TrimSpace(r.URL.Query().Get("state")),
77 Label: strings.TrimSpace(r.URL.Query().Get("label")),
78 Query: strings.TrimSpace(r.URL.Query().Get("q")),
79 }
80 rows, err := w.Index.Issues(m.ID, filter)
46 81 if err != nil {
47 82 http.Error(rw, err.Error(), http.StatusInternalServerError)
48 83 return
@@ -54,19 +89,36 @@ func (w *Web) Issues(rw http.ResponseWriter, r *http.Request) {
54 89 }
55 90 return rows[i].ID > rows[j].ID
56 91 })
57 openCount := 0
92 labels, err := w.Index.Labels(m.ID)
93 if err != nil {
94 labels = nil
95 }
96 total, _ := w.Index.IssueCount(m.ID)
97 open := 0
58 98 for _, row := range rows {
59 99 if row.State == "open" {
60 openCount++
100 open++
61 101 }
62 102 }
63 103 w.render(rw, "issues", struct {
64 104 Page
65 Name string
66 Issues []index.IssueRow
67 OpenCount int
68 Total int
69 }{w.base(m.Name + " · issues"), m.Name, rows, openCount, len(rows)}, 0)
105 Name string
106 Issues []index.IssueRow
107 Labels []string
108 Filter index.Filter
109 Total int
110 Open int
111 Filtered bool
112 }{
113 Page: w.base(m.Name + " · issues"),
114 Name: m.Name,
115 Issues: rows,
116 Labels: labels,
117 Filter: filter,
118 Total: total,
119 Open: open,
120 Filtered: filter.State != "" || filter.Label != "" || filter.Query != "",
121 }, 0)
70 122 }
71 123
72 124 // IssueView renders one issue and its thread. The issue is read from git rather
@@ -92,7 +144,7 @@ func (w *Web) IssueView(rw http.ResponseWriter, r *http.Request) {
92 144 }
93 145 comments := make([]rendered, 0, len(iss.Comments))
94 146 for _, c := range iss.Comments {
95 comments = append(comments, rendered{Comment: c, Body: renderMarkdown(c.Body)})
147 comments = append(comments, rendered{Comment: c, Body: w.renderMarkdown(m.Name, c.Body)})
96 148 }
97 149 w.render(rw, "issue", struct {
98 150 Page
@@ -101,6 +153,27 @@ func (w *Web) IssueView(rw http.ResponseWriter, r *http.Request) {
101 153 Description template.HTML
102 154 Comments []rendered
103 155 Labels string
104 }{w.base(m.Name + " · " + iss.Title), m.Name, iss, renderMarkdown(iss.Description), comments,
105 strings.Join(iss.Labels, ", ")}, 0)
156 Backlinks []gitx.Commit
157 }{
158 Page: w.base(m.Name + " · " + iss.Title),
159 Name: m.Name,
160 Issue: iss,
161 Description: w.renderMarkdown(m.Name, iss.Description),
162 Comments: comments,
163 Labels: strings.Join(iss.Labels, ", "),
164 Backlinks: w.backlinks(m, iss.ID),
165 }, 0)
166 }
167
168 // backlinks finds commits whose message mentions the issue. git does the search,
169 // so there is no second index to keep in sync.
170 func (w *Web) backlinks(m *repo.Meta, id string) []gitx.Commit {
171 if m.DefaultBranch == "" {
172 return nil
173 }
174 commits, err := m.Git().LogGrep(m.DefaultBranch, id, 10)
175 if err != nil {
176 return nil
177 }
178 return commits
106 179 }