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/issues/write.go +198

@@ -0,0 +1,198 @@
1 package issues
2
3 import (
4 "encoding/json"
5 "fmt"
6 "strings"
7 "time"
8
9 "github.com/rodent-software/vole/internal/gitx"
10 )
11
12 // New fills in the fields a caller should not have to get right.
13 func New(title, body, author string, labels []string, now time.Time) (*Issue, error) {
14 id, err := NewULID(now)
15 if err != nil {
16 return nil, err
17 }
18 return &Issue{
19 ID: id,
20 Title: strings.TrimSpace(title),
21 State: "open",
22 Author: strings.TrimSpace(author),
23 Created: now.UTC(),
24 Labels: labels,
25 Description: body,
26 }, nil
27 }
28
29 // Comment builds a comment with a fresh id.
30 func NewComment(body, author string, now time.Time) (*Comment, error) {
31 id, err := NewULID(now)
32 if err != nil {
33 return nil, err
34 }
35 return &Comment{ID: id, Author: strings.TrimSpace(author), Created: now.UTC(), Body: body}, nil
36 }
37
38 // Create writes a new issue onto the issues ref, creating the branch if needed.
39 func Create(g *gitx.Repo, iss *Issue, author string) error {
40 if err := validateForWrite(iss); err != nil {
41 return err
42 }
43 meta, err := marshalMeta(iss)
44 if err != nil {
45 return err
46 }
47 base := fmt.Sprintf("%s/%s/", Dir, iss.ID)
48 _, err = gitx.CommitFiles(g, Ref, func(files map[string][]byte) error {
49 if _, exists := files[base+"meta.json"]; exists {
50 return fmt.Errorf("issues: %s already exists", iss.ID)
51 }
52 files[base+"meta.json"] = meta
53 files[base+"description.md"] = []byte(iss.Description)
54 return nil
55 }, fmt.Sprintf("issue: %s (%s)", iss.Title, iss.ID), actorOr(author, iss.Author))
56 return err
57 }
58
59 // Update applies mutate to an existing issue and commits the result.
60 func Update(g *gitx.Repo, id string, mutate func(*Issue) error, actor string) (*Issue, error) {
61 list, err := Load(g, Ref)
62 if err != nil {
63 return nil, err
64 }
65 var target *Issue
66 for _, iss := range list {
67 if iss.ID == id {
68 target = iss
69 break
70 }
71 }
72 if target == nil {
73 return nil, fmt.Errorf("issues: no issue %s", id)
74 }
75 if err := mutate(target); err != nil {
76 return nil, err
77 }
78 target.Updated = time.Now().UTC()
79 if err := validateForWrite(target); err != nil {
80 return nil, err
81 }
82 meta, err := marshalMeta(target)
83 if err != nil {
84 return nil, err
85 }
86 base := fmt.Sprintf("%s/%s/", Dir, target.ID)
87 _, err = gitx.CommitFiles(g, Ref, func(files map[string][]byte) error {
88 files[base+"meta.json"] = meta
89 files[base+"description.md"] = []byte(target.Description)
90 return nil
91 }, fmt.Sprintf("issue: update %s", target.ID), actorOr(actor, target.Author))
92 if err != nil {
93 return nil, err
94 }
95 return target, nil
96 }
97
98 // AddComment appends a comment file to an existing issue.
99 func AddComment(g *gitx.Repo, id string, c *Comment, actor string) error {
100 list, err := Load(g, Ref)
101 if err != nil {
102 return err
103 }
104 found := false
105 for _, iss := range list {
106 if iss.ID == id {
107 found = true
108 break
109 }
110 }
111 if !found {
112 return fmt.Errorf("issues: no issue %s", id)
113 }
114 if strings.TrimSpace(c.Author) == "" {
115 c.Author = actor
116 }
117 if strings.TrimSpace(c.Author) == "" {
118 return fmt.Errorf("issues: comment needs an author")
119 }
120 if c.Created.IsZero() {
121 c.Created = time.Now().UTC()
122 }
123 path := fmt.Sprintf("%s/%s/%s/%s.md", Dir, id, commentsDir, c.ID)
124 _, err = gitx.CommitFiles(g, Ref, func(files map[string][]byte) error {
125 if _, exists := files[path]; exists {
126 return fmt.Errorf("issues: comment %s already exists", c.ID)
127 }
128 files[path] = []byte(renderCommentFile(c))
129 return nil
130 }, fmt.Sprintf("issue: comment on %s", id), c.Author)
131 return err
132 }
133
134 func renderCommentFile(c *Comment) string {
135 var b strings.Builder
136 b.WriteString("---\n")
137 fmt.Fprintf(&b, "author: %s\n", c.Author)
138 fmt.Fprintf(&b, "created: %s\n", c.Created.UTC().Format(time.RFC3339))
139 b.WriteString("---\n")
140 b.WriteString(c.Body)
141 if !strings.HasSuffix(c.Body, "\n") {
142 b.WriteString("\n")
143 }
144 return b.String()
145 }
146
147 func marshalMeta(iss *Issue) ([]byte, error) {
148 m := meta{
149 Title: iss.Title,
150 State: iss.State,
151 Author: iss.Author,
152 Created: iss.Created.UTC().Format(time.RFC3339),
153 Labels: iss.Labels,
154 Assignees: iss.Assignees,
155 }
156 if !iss.Updated.IsZero() {
157 m.Updated = iss.Updated.UTC().Format(time.RFC3339)
158 }
159 b, err := json.MarshalIndent(m, "", " ")
160 if err != nil {
161 return nil, err
162 }
163 return append(b, '\n'), nil
164 }
165
166 // validateForWrite runs the same checks the push boundary applies, so the CLI
167 // fails locally instead of after a round trip.
168 func validateForWrite(iss *Issue) error {
169 var problems []string
170 if strings.TrimSpace(iss.Title) == "" {
171 problems = append(problems, "title is required")
172 }
173 if len(iss.Title) > maxTitleLen {
174 problems = append(problems, fmt.Sprintf("title is %d bytes, limit %d", len(iss.Title), maxTitleLen))
175 }
176 if iss.State != "open" && iss.State != "closed" {
177 problems = append(problems, fmt.Sprintf("state must be open or closed, got %q", iss.State))
178 }
179 if strings.TrimSpace(iss.Author) == "" {
180 problems = append(problems, "author is required")
181 }
182 for _, l := range iss.Labels {
183 if !labelRE.MatchString(l) {
184 problems = append(problems, fmt.Sprintf("invalid label %q", l))
185 }
186 }
187 if len(problems) > 0 {
188 return &ValidationError{Problems: problems}
189 }
190 return nil
191 }
192
193 func actorOr(primary, fallback string) string {
194 if strings.TrimSpace(primary) != "" {
195 return strings.TrimSpace(primary)
196 }
197 return strings.TrimSpace(fallback)
198 }