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, first slice: issues as files on a branch

37c01e5aa9b98bbea8567e741cd3af508ae1ba7d · Keenan Nemetz · 18d ago · parent 197ecaa2

Phase 2, first slice: issues as files on a branch

Push-based issues, per the decision to keep the web read-only. The format is the
contract; nothing here requires a Vole binary on a contributor's machine.

  issues/<ulid>/meta.json
  issues/<ulid>/description.md
  issues/<ulid>/comments/<ulid>.md      on refs/heads/issues

Design points worth recording:

- IDs are ULIDs generated by the writer, so there is no server-assigned counter,
  no contention, and no ID allocation in the write path at all.
- Issues live on a branch rather than a custom ref, so a plain `git clone` carries
  them. That is the same lesson that moved the user roster off refs/vole/users:
  `git clone` only mirrors refs/heads/*.
- File-per-comment means two people replying to the same issue never touch the
  same file, so the only merge conflict is two edits to one comment.
- Validation happens in pre-receive, so a malformed tree never reaches the web.
  Every problem is reported at once with the offending path, and a rejected push
  leaves the branch unmoved.

The index gained issue rows, refreshed when the issues ref moves rather than by a
hook, plus coverage in the full rebuild. The list page reads the index; the
detail page reads git, so bodies and comments are never stale.

Markdown is rendered with goldmark and sanitised with bluemonday. Issue bodies
arrive by push, which makes them untrusted input on a public page; a test asserts
that <script> and javascript: URLs do not survive.

Verified by hand with a user who has an editor and a key: filed an issue, added a
comment, saw both rendered, then pushed an issue with no title and got back
"issues/01M5.../meta.json: title is required" with the branch unmoved.

internal/index/issues.go +133

@@ -0,0 +1,133 @@
1 package index
2
3 import (
4 "encoding/json"
5 "strings"
6 "time"
7
8 "github.com/rodent-software/vole/internal/gitx"
9 "github.com/rodent-software/vole/internal/issues"
10 "github.com/rodent-software/vole/internal/repo"
11 )
12
13 // EnsureIssues refreshes a repo's indexed issues if the issues branch moved.
14 // Called on read, so a push is visible without a hook or a full rebuild.
15 func (s *Store) EnsureIssues(m *repo.Meta) error {
16 current := ""
17 if sha, err := m.Git().RevParse(issues.Ref); err == nil {
18 current = sha
19 }
20 var stored string
21 err := s.db.QueryRow(`SELECT issues_sha FROM repos WHERE id=?`, m.ID).Scan(&stored)
22 if err != nil {
23 return err
24 }
25 if stored == current {
26 return nil
27 }
28 return s.IndexIssues(m)
29 }
30
31 // IndexIssues replaces a repo's issue rows from the issues branch.
32 func (s *Store) IndexIssues(m *repo.Meta) error {
33 list, err := issues.Load(m.Git(), issues.Ref)
34 if err != nil {
35 return err
36 }
37 sha, err := m.Git().RevParse(issues.Ref)
38 if err != nil {
39 return err
40 }
41
42 tx, err := s.db.Begin()
43 if err != nil {
44 return err
45 }
46 defer tx.Rollback()
47 if _, err := tx.Exec(`DELETE FROM issues WHERE repo_id=?`, m.ID); err != nil {
48 return err
49 }
50 for _, iss := range list {
51 updated := iss.Updated
52 if updated.IsZero() {
53 updated = iss.Created
54 }
55 if _, err := tx.Exec(
56 `INSERT INTO issues(repo_id, id, title, state, author, created, updated, labels, comments)
57 VALUES(?,?,?,?,?,?,?,?,?)`,
58 m.ID, iss.ID, iss.Title, iss.State, iss.Author,
59 iss.Created.Unix(), updated.Unix(), strings.Join(iss.Labels, "\x1f"), len(iss.Comments),
60 ); err != nil {
61 return err
62 }
63 }
64 if _, err := tx.Exec(`UPDATE repos SET issues_sha=? WHERE id=?`, sha, m.ID); err != nil {
65 return err
66 }
67 return tx.Commit()
68 }
69
70 // Issues returns a repo's indexed issues, newest first.
71 func unixTime(sec int64) time.Time {
72 if sec == 0 {
73 return time.Time{}
74 }
75 return time.Unix(sec, 0).UTC()
76 }
77
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)
82 if err != nil {
83 return nil, err
84 }
85 defer rows.Close()
86 var out []IssueRow
87 for rows.Next() {
88 var r IssueRow
89 var created, updated int64
90 var labels string
91 if err := rows.Scan(&r.ID, &r.Title, &r.State, &r.Author, &created, &updated, &labels, &r.Comments); err != nil {
92 return nil, err
93 }
94 r.Created = unixTime(created)
95 r.Updated = unixTime(updated)
96 if labels != "" {
97 r.Labels = strings.Split(labels, "\x1f")
98 }
99 out = append(out, r)
100 }
101 return out, rows.Err()
102 }
103
104 // LoadIssue reads one issue straight from git, so the rendered body and comments
105 // are always the committed ones.
106 func LoadIssue(m *repo.Meta, id string) (*issues.Issue, error) {
107 list, err := issues.Load(m.Git(), issues.Ref)
108 if err != nil {
109 return nil, err
110 }
111 for _, iss := range list {
112 if iss.ID == id {
113 return iss, nil
114 }
115 }
116 return nil, ErrNotFound
117 }
118
119 var ErrNotFound = errNotFound{}
120
121 type errNotFound struct{}
122
123 func (errNotFound) Error() string { return "not found" }
124
125 // IssueCount is used by the repo page.
126 func (s *Store) IssueCount(repoID string) (int, error) {
127 var n int
128 err := s.db.QueryRow(`SELECT COUNT(*) FROM issues WHERE repo_id=?`, repoID).Scan(&n)
129 return n, err
130 }
131
132 var _ = json.Marshal
133 var _ = gitx.ZeroSHA