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
server/web/numbers.go +105
| @@ -0,0 +1,105 @@ | |||
| 1 | package web | ||
| 2 | |||
| 3 | // The `#42` alias at the web boundary. | ||
| 4 | // | ||
| 5 | // A number is not stored and not authoritative: it is the creation rank of the | ||
| 6 | // entity in the metadata branch's history (metadata.Numbers). The index keeps a | ||
| 7 | // copy so a page render does not walk history, and Ensure refreshes that copy | ||
| 8 | // whenever the branch moves. Every surface here reads the same table, so the | ||
| 9 | // number on the list page, the number in the URL and the number in a body agree. | ||
| 10 | |||
| 11 | import ( | ||
| 12 | "fmt" | ||
| 13 | "net/http" | ||
| 14 | "strconv" | ||
| 15 | |||
| 16 | "github.com/rodent-software/vole/forge/repo" | ||
| 17 | "github.com/rodent-software/vole/metadata" | ||
| 18 | ) | ||
| 19 | |||
| 20 | // entityNumbers returns a repo's numbers, keyed by entity id, refreshing the | ||
| 21 | // derived rows if the metadata branch moved. A repo whose branch cannot be read | ||
| 22 | // gets an empty map: an unnumbered page is better than a failed one, and the | ||
| 23 | // templates render a missing number as `#?` rather than `#0`. | ||
| 24 | func (w *Web) entityNumbers(m *repo.Meta) map[string]int { | ||
| 25 | if err := w.Index.Ensure(m); err != nil { | ||
| 26 | return map[string]int{} | ||
| 27 | } | ||
| 28 | nums, err := w.Index.Numbers(m.ID) | ||
| 29 | if err != nil { | ||
| 30 | return map[string]int{} | ||
| 31 | } | ||
| 32 | return nums | ||
| 33 | } | ||
| 34 | |||
| 35 | // numberLabel renders a number the way every surface writes it. Zero means the | ||
| 36 | // branch did not number the entity, which is a fact worth showing as unknown | ||
| 37 | // rather than as a number nobody assigned. | ||
| 38 | func numberLabel(number int) string { | ||
| 39 | if number < 1 { | ||
| 40 | return "#?" | ||
| 41 | } | ||
| 42 | return "#" + strconv.Itoa(number) | ||
| 43 | } | ||
| 44 | |||
| 45 | // resolveCloses turns `42` and `#42` in a Closes field into the issue's ULID, the | ||
| 46 | // form the file stores. A number that names a change request is refused: a request | ||
| 47 | // cannot close a request, and the author is still looking at the form. | ||
| 48 | func (w *Web) resolveCloses(m *repo.Meta, values []string) ([]string, error) { | ||
| 49 | out := make([]string, 0, len(values)) | ||
| 50 | nums := false | ||
| 51 | for _, v := range values { | ||
| 52 | num, ok := metadata.ParseNumber(v) | ||
| 53 | if !ok { | ||
| 54 | if v = metadata.TrimReference(v); v != "" { | ||
| 55 | out = append(out, v) | ||
| 56 | } | ||
| 57 | continue | ||
| 58 | } | ||
| 59 | if !nums { | ||
| 60 | // One refresh for the whole field, not one per number. | ||
| 61 | if err := w.Index.Ensure(m); err != nil { | ||
| 62 | return nil, fmt.Errorf("issue numbering unavailable: %w", err) | ||
| 63 | } | ||
| 64 | nums = true | ||
| 65 | } | ||
| 66 | id, kind, ok := w.Index.ByNumber(m.ID, num) | ||
| 67 | if !ok { | ||
| 68 | return nil, fmt.Errorf("no issue or change request is #%d", num) | ||
| 69 | } | ||
| 70 | if kind != metadata.KindIssue { | ||
| 71 | return nil, fmt.Errorf("#%d is a change request, not an issue", num) | ||
| 72 | } | ||
| 73 | out = append(out, id) | ||
| 74 | } | ||
| 75 | return out, nil | ||
| 76 | } | ||
| 77 | |||
| 78 | // NumberAlias is /r/<name>/n/<number>: one link that works for an issue and for a | ||
| 79 | // change request. A body can say `#42` before anyone knows which kind it is, and | ||
| 80 | // the redirect resolves the question from the index instead of guessing. | ||
| 81 | func (w *Web) NumberAlias(rw http.ResponseWriter, r *http.Request) { | ||
| 82 | m, ok := w.open(rw, r) | ||
| 83 | if !ok { | ||
| 84 | return | ||
| 85 | } | ||
| 86 | num, ok := metadata.ParseNumber(r.PathValue("num")) | ||
| 87 | if !ok { | ||
| 88 | w.notFound(rw, r) | ||
| 89 | return | ||
| 90 | } | ||
| 91 | if err := w.Index.Ensure(m); err != nil { | ||
| 92 | w.serverError(rw, r, "number index unavailable: "+err.Error()) | ||
| 93 | return | ||
| 94 | } | ||
| 95 | id, kind, ok := w.Index.ByNumber(m.ID, num) | ||
| 96 | if !ok { | ||
| 97 | w.notFound(rw, r) | ||
| 98 | return | ||
| 99 | } | ||
| 100 | section := "issues" | ||
| 101 | if kind == metadata.KindRequest { | ||
| 102 | section = "requests" | ||
| 103 | } | ||
| 104 | http.Redirect(rw, r, "/r/"+m.Name+"/"+section+"/"+id, http.StatusSeeOther) | ||
| 105 | } | ||