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.

Blame · metadata/number.go

Line attribution on main · 196 line(s)

e05f1c48
Keenan Nemetz
13d ago
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
d37d301f
Keenan Nemetz
17h ago
29 "rodent.software/vole/core/git"
e05f1c48
Keenan Nemetz
13d ago
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$`)
b492b557
Keenan Nemetz
3d ago
44 numberRequestPathRE = regexp.MustCompile("^" + regexp.QuoteMeta(ChangesDir) + `/([0-9A-HJKMNP-TV-Z]{26})/change\.md$`)
e05f1c48
Keenan Nemetz
13d ago
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 }
c9202f7f
Keenan Nemetz
10d ago
71 return LoadNumbersAt(g, commit)
72 }
73
74 // LoadNumbersAt numbers the entities a commit's history added. It takes the
75 // commit rather than a ref so the index can resolve the metadata branch once.
76 func LoadNumbersAt(g *git.Repo, commit string) (*Numbers, error) {
e05f1c48
Keenan Nemetz
13d ago
77 if commit == "" {
78 return newNumbers(), nil
79 }
b492b557
Keenan Nemetz
3d ago
80 groups, err := g.AddedPathsPerCommit(commit, IssuesDir, ChangesDir)
e05f1c48
Keenan Nemetz
13d ago
81 if err != nil {
82 return nil, err
83 }
84 return RankAdditions(groups), nil
85 }
86
87 // RankAdditions numbers the entities a history added, oldest commit first. It is
88 // the pure half of LoadNumbers, so the rules can be tested without a repository.
89 //
90 // Within one commit the order is the ULID's, which is creation order because a
91 // ULID starts with its timestamp. A path that is deleted and later re-added keeps
92 // its first number: an alias someone already quoted must not come to mean
93 // something else.
94 func RankAdditions(commits [][]string) *Numbers {
95 n := newNumbers()
96 next := 1
34a404b3
Keenan Nemetz
10d ago
97 for _, e := range Additions(commits) {
98 if _, seen := n.byID[e.ID]; seen {
99 continue
100 }
101 n.byID[e.ID] = next
102 n.byNo[next] = e.ID
103 n.kind[e.ID] = e.Kind
104 next++
105 }
106 return n
107 }
108
109 // Entity is one thing the numbering sequence counts.
110 type Entity struct {
111 ID string
112 Kind string
113 }
114
115 // Additions turns per-commit path lists into the entities those commits created,
116 // oldest commit first and ULID-ordered within one commit. It is the half of
117 // RankAdditions that an incremental refresh reuses: a range of new commits can
118 // be ranked without replaying the whole history.
119 func Additions(commits [][]string) []Entity {
120 var out []Entity
e05f1c48
Keenan Nemetz
13d ago
121 for _, paths := range commits {
34a404b3
Keenan Nemetz
10d ago
122 var batch []Entity
e05f1c48
Keenan Nemetz
13d ago
123 for _, path := range paths {
124 if m := numberIssuePathRE.FindStringSubmatch(path); m != nil {
34a404b3
Keenan Nemetz
10d ago
125 batch = append(batch, Entity{ID: m[1], Kind: KindIssue})
e05f1c48
Keenan Nemetz
13d ago
126 continue
127 }
128 if m := numberRequestPathRE.FindStringSubmatch(path); m != nil {
34a404b3
Keenan Nemetz
10d ago
129 batch = append(batch, Entity{ID: m[1], Kind: KindRequest})
e05f1c48
Keenan Nemetz
13d ago
130 }
131 }
34a404b3
Keenan Nemetz
10d ago
132 sort.Slice(batch, func(i, j int) bool { return batch[i].ID < batch[j].ID })
133 out = append(out, batch...)
e05f1c48
Keenan Nemetz
13d ago
134 }
34a404b3
Keenan Nemetz
10d ago
135 return out
e05f1c48
Keenan Nemetz
13d ago
136 }
137
138 // Of returns the number an entity was given.
139 func (n *Numbers) Of(id string) (int, bool) {
140 if n == nil {
141 return 0, false
142 }
143 num, ok := n.byID[id]
144 return num, ok
145 }
146
147 // At returns the entity a number names, and which kind it is.
148 func (n *Numbers) At(number int) (id, kind string, ok bool) {
149 if n == nil {
150 return "", "", false
151 }
152 id, ok = n.byNo[number]
153 if !ok {
154 return "", "", false
155 }
156 return id, n.kind[id], true
157 }
158
159 // Kind returns the kind of a numbered entity, or "" when it has no number.
160 func (n *Numbers) Kind(id string) string {
161 if n == nil {
162 return ""
163 }
164 return n.kind[id]
165 }
166
167 // Len is how many entities have a number.
168 func (n *Numbers) Len() int {
169 if n == nil {
170 return 0
171 }
172 return len(n.byID)
173 }
174
175 // TrimReference removes the decoration a typed reference carries: surrounding
176 // space and the leading `#` of `#42`. Every id-taking surface trims it, so the way
177 // someone writes a reference in prose is the way they may pass it on the command
178 // line.
179 func TrimReference(given string) string {
180 return strings.TrimPrefix(strings.TrimSpace(given), "#")
181 }
182
183 // ParseNumber reports whether a typed id is a number rather than a ULID or a
184 // ULID prefix. A leading `#` is accepted. Length is the discriminator: a ULID is
185 // 26 characters, so a shorter run of digits cannot be one and is read as an
186 // alias. Nothing a person types is ambiguous between the two.
187 func ParseNumber(given string) (int, bool) {
188 if !numberRE.MatchString(TrimReference(given)) {
189 return 0, false
190 }
191 num, err := strconv.Atoi(TrimReference(given))
192 if err != nil || num < 1 {
193 return 0, false
194 }
195 return num, true
196 }