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.

Number issues and change requests for human references

e05f1c48d4ee55635cd7b77889f0545137198106 · Keenan Nemetz · 13d ago · parent 41d1fee9

Number issues and change requests for human references

A ULID is an unreadable handle, and the 8-character prefix the list showed
is mostly timestamp: this repository's 27 issues shared seven distinct
six-character prefixes and two pairs already collided at eight. The part
people actually type carried almost no information.

Issues and change requests now share one gap-free sequence (#1, #42, ...)
derived from the creation order in the metadata branch's history, the way
one forge counts both in a single run.

The number is not stored. It is the rank of the entity's creation commit,
so nothing is written when an entity is created, a repository needs no
backfill, the same history gives the same numbers on every clone, and
`vole admin index rebuild` reproduces them from git. A ULID stays the
identity: paths, front matter, `closes:` and audit entries are unchanged,
and data/index.db only caches the mapping so a page render does not walk
history.

Every id-taking command accepts a number, `#42` links in a body through a
kind-agnostic /r/<repo>/n/42 route, and --closes resolves an alias to the
ULID before writing.

Closes #45

metadata/number.go +175

@@ -0,0 +1,175 @@
1 // Numbers are the short aliases people type: `#42` instead of a 26-character
2 // ULID.
3 //
4 // A number is not stored. It is the 1-based rank of the entity's creation commit
5 // in the metadata branch's history, counted over issues and change requests
6 // together, the way one forge counts both in a single sequence. Three things
7 // follow from deriving it rather than allocating it:
8 //
9 // - No counter and no ledger. Nothing is written when an entity is created, so
10 // there is no second serialized commit per issue and no number to lose.
11 // - Existing repositories need no backfill. Every issue and request that is
12 // already in history gets its number the first time anyone asks.
13 // - The answer is the same on every clone and every instance serving the same
14 // history, because it is a function of the commits, not of a server's state.
15 //
16 // The ULID stays the identity: paths, front matter, `closes:` lists, backlinks
17 // and the audit log are untouched. A number is a view of the history, so an
18 // admin rewind that rewrites that history renumbers what came after it. That is
19 // the one cost of not storing a copy, and it is why documentation calls a ULID
20 // the durable reference and `#42` a convenience.
21 package metadata
22
23 import (
24 "regexp"
25 "sort"
26 "strconv"
27 "strings"
28
29 "github.com/rodent-software/vole/core/git"
30 )
31
32 // Kinds of numbered entity. A number is ambiguous without one, because issues
33 // and change requests share the sequence.
34 const (
35 KindIssue = "issue"
36 KindRequest = "request"
37 )
38
39 var (
40 // numberIssuePathRE and numberRequestPathRE match the tool that creates an
41 // entity, not every file under it: a comment added to an existing issue in the
42 // same commit must not take a number, and an approval never held one.
43 numberIssuePathRE = regexp.MustCompile(`^issues/([0-9A-HJKMNP-TV-Z]{26})/issue\.md$`)
44 numberRequestPathRE = regexp.MustCompile(`^requests/([0-9A-HJKMNP-TV-Z]{26})/request\.md$`)
45 numberRE = regexp.MustCompile(`^[0-9]{1,9}$`)
46 )
47
48 // Numbers maps entity ids to their creation rank and back.
49 type Numbers struct {
50 byID map[string]int
51 byNo map[int]string
52 kind map[string]string
53 }
54
55 func newNumbers() *Numbers {
56 return &Numbers{
57 byID: map[string]int{},
58 byNo: map[int]string{},
59 kind: map[string]string{},
60 }
61 }
62
63 // LoadNumbers reads the numbering of a metadata branch. It reads history rather
64 // than the current tree, so an entity that has since been deleted keeps the
65 // number it was given and no later entity takes it.
66 func LoadNumbers(g *git.Repo, ref string) (*Numbers, error) {
67 commit, err := g.RevParse(ref)
68 if err != nil {
69 return nil, err
70 }
71 if commit == "" {
72 return newNumbers(), nil
73 }
74 groups, err := g.AddedPathsPerCommit(commit, IssuesDir, RequestsDir)
75 if err != nil {
76 return nil, err
77 }
78 return RankAdditions(groups), nil
79 }
80
81 // RankAdditions numbers the entities a history added, oldest commit first. It is
82 // the pure half of LoadNumbers, so the rules can be tested without a repository.
83 //
84 // Within one commit the order is the ULID's, which is creation order because a
85 // ULID starts with its timestamp. A path that is deleted and later re-added keeps
86 // its first number: an alias someone already quoted must not come to mean
87 // something else.
88 func RankAdditions(commits [][]string) *Numbers {
89 n := newNumbers()
90 next := 1
91 for _, paths := range commits {
92 type entity struct{ id, kind string }
93 var batch []entity
94 for _, path := range paths {
95 if m := numberIssuePathRE.FindStringSubmatch(path); m != nil {
96 batch = append(batch, entity{id: m[1], kind: KindIssue})
97 continue
98 }
99 if m := numberRequestPathRE.FindStringSubmatch(path); m != nil {
100 batch = append(batch, entity{id: m[1], kind: KindRequest})
101 }
102 }
103 sort.Slice(batch, func(i, j int) bool { return batch[i].id < batch[j].id })
104 for _, e := range batch {
105 if _, seen := n.byID[e.id]; seen {
106 continue
107 }
108 n.byID[e.id] = next
109 n.byNo[next] = e.id
110 n.kind[e.id] = e.kind
111 next++
112 }
113 }
114 return n
115 }
116
117 // Of returns the number an entity was given.
118 func (n *Numbers) Of(id string) (int, bool) {
119 if n == nil {
120 return 0, false
121 }
122 num, ok := n.byID[id]
123 return num, ok
124 }
125
126 // At returns the entity a number names, and which kind it is.
127 func (n *Numbers) At(number int) (id, kind string, ok bool) {
128 if n == nil {
129 return "", "", false
130 }
131 id, ok = n.byNo[number]
132 if !ok {
133 return "", "", false
134 }
135 return id, n.kind[id], true
136 }
137
138 // Kind returns the kind of a numbered entity, or "" when it has no number.
139 func (n *Numbers) Kind(id string) string {
140 if n == nil {
141 return ""
142 }
143 return n.kind[id]
144 }
145
146 // Len is how many entities have a number.
147 func (n *Numbers) Len() int {
148 if n == nil {
149 return 0
150 }
151 return len(n.byID)
152 }
153
154 // TrimReference removes the decoration a typed reference carries: surrounding
155 // space and the leading `#` of `#42`. Every id-taking surface trims it, so the way
156 // someone writes a reference in prose is the way they may pass it on the command
157 // line.
158 func TrimReference(given string) string {
159 return strings.TrimPrefix(strings.TrimSpace(given), "#")
160 }
161
162 // ParseNumber reports whether a typed id is a number rather than a ULID or a
163 // ULID prefix. A leading `#` is accepted. Length is the discriminator: a ULID is
164 // 26 characters, so a shorter run of digits cannot be one and is read as an
165 // alias. Nothing a person types is ambiguous between the two.
166 func ParseNumber(given string) (int, bool) {
167 if !numberRE.MatchString(TrimReference(given)) {
168 return 0, false
169 }
170 num, err := strconv.Atoi(TrimReference(given))
171 if err != nil || num < 1 {
172 return 0, false
173 }
174 return num, true
175 }