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
cmd/vole/change.go +67−8
| @@ -28,8 +28,10 @@ func changeUsage() string { | |||
| 28 | 28 | merge <id> | |
| 29 | 29 | close|reopen <id> | |
| 30 | 30 | ||
| 31 | --no-push commits locally without pushing; -- ends option parsing. A comment is | ||
| 32 | yours to edit or delete; an admin may repair somebody else's, audited.` | ||
| 31 | --no-push commits locally without pushing; -- ends option parsing. An id takes a | ||
| 32 | prefix or the #42 number: numbers are one sequence shared with issues, oldest | ||
| 33 | first. --closes takes an issue id or its number. A comment is yours to edit or | ||
| 34 | delete; an admin may repair somebody else's, audited.` | ||
| 33 | 35 | } | |
| 34 | 36 | ||
| 35 | 37 | // cmdChange implements the change-request commands. A request is a promise to | |
| @@ -146,7 +148,9 @@ func cmdChange(args []string) error { | |||
| 146 | 148 | // A linked issue is a promise that landing this request closes it, so the ids | |
| 147 | 149 | // are checked when the request is written rather than at the merge: a typo | |
| 148 | 150 | // should fail while the author is still looking at the command. | |
| 149 | r.Closes = closesFlag(f.all("closes")) | ||
| 151 | if r.Closes, err = resolveCloses(c, f.all("closes")); err != nil { | ||
| 152 | return err | ||
| 153 | } | ||
| 150 | 154 | reapply := func() error { return metadata.CreateRequest(c.Repo, r, author) } | |
| 151 | 155 | if err := reapply(); err != nil { | |
| 152 | 156 | return err | |
| @@ -158,8 +162,14 @@ func cmdChange(args []string) error { | |||
| 158 | 162 | for _, id := range r.Closes { | |
| 159 | 163 | fmt.Fprintf(os.Stderr, "closes %s when merged\n", id) | |
| 160 | 164 | } | |
| 165 | label := "" | ||
| 166 | if nums, nerr := c.Numbers(); nerr == nil { | ||
| 167 | label = " as " + numberLabel(nums, r.ID) | ||
| 168 | } | ||
| 161 | 169 | if u := who.Page("requests", r.ID, ""); u != "" { | |
| 162 | fmt.Fprintf(os.Stderr, "opened at %s\n", u) | ||
| 170 | fmt.Fprintf(os.Stderr, "opened%s at %s\n", label, u) | ||
| 171 | } else { | ||
| 172 | fmt.Fprintf(os.Stderr, "opened%s\n", label) | ||
| 163 | 173 | } | |
| 164 | 174 | return nil | |
| 165 | 175 | ||
| @@ -280,6 +290,10 @@ func cmdChange(args []string) error { | |||
| 280 | 290 | return usagef("--state must be open, closed or all") | |
| 281 | 291 | } | |
| 282 | 292 | format := f.one("format") | |
| 293 | nums, err := c.Numbers() | ||
| 294 | if err != nil { | ||
| 295 | return err | ||
| 296 | } | ||
| 283 | 297 | n := 0 | |
| 284 | 298 | for _, r := range list { | |
| 285 | 299 | if want != "" && want != "all" && r.State != want { | |
| @@ -288,10 +302,10 @@ func cmdChange(args []string) error { | |||
| 288 | 302 | n++ | |
| 289 | 303 | switch format { | |
| 290 | 304 | case "json": | |
| 291 | fmt.Printf(`{"id":%q,"title":%q,"state":%q,"base":%q,"head":%q,"author":%q,"approvals":%d}`+"\n", | ||
| 292 | r.ID, r.Title, r.State, r.Base, r.Head, r.Author, len(r.Approvals)) | ||
| 305 | fmt.Printf(`{"id":%q,"number":%d,"title":%q,"state":%q,"base":%q,"head":%q,"author":%q,"approvals":%d}`+"\n", | ||
| 306 | r.ID, numberOf(nums, r.ID), r.Title, r.State, r.Base, r.Head, r.Author, len(r.Approvals)) | ||
| 293 | 307 | default: | |
| 294 | fmt.Printf("%s\t%s\t%s\t%d/%d\t%s\n", r.ID, r.State, r.Base, r.CurrentApprovals(), 1, r.Title) | ||
| 308 | fmt.Printf("%s\t%s\t%s\t%s\t%d/%d\t%s\n", numberLabel(nums, r.ID), r.ID, r.State, r.Base, r.CurrentApprovals(), 1, r.Title) | ||
| 295 | 309 | } | |
| 296 | 310 | } | |
| 297 | 311 | if n == 0 { | |
| @@ -307,6 +321,11 @@ func cmdChange(args []string) error { | |||
| 307 | 321 | if err != nil { | |
| 308 | 322 | return err | |
| 309 | 323 | } | |
| 324 | nums, err := c.Numbers() | ||
| 325 | if err != nil { | ||
| 326 | return err | ||
| 327 | } | ||
| 328 | fmt.Printf("number: %s\n", numberLabel(nums, r.ID)) | ||
| 310 | 329 | fmt.Printf("id: %s\n", r.ID) | |
| 311 | 330 | fmt.Printf("title: %s\n", r.Title) | |
| 312 | 331 | fmt.Printf("state: %s\n", r.State) | |
| @@ -379,7 +398,9 @@ func cmdChange(args []string) error { | |||
| 379 | 398 | case f.has("no-closes"): | |
| 380 | 399 | closes, linksChanged = nil, len(r.Closes) > 0 | |
| 381 | 400 | case f.given("closes"): | |
| 382 | closes = closesFlag(f.all("closes")) | ||
| 401 | if closes, err = resolveCloses(c, f.all("closes")); err != nil { | ||
| 402 | return err | ||
| 403 | } | ||
| 383 | 404 | linksChanged = !equalIDs(closes, r.Closes) | |
| 384 | 405 | } | |
| 385 | 406 | if !baseChanged && !headMoved && !linksChanged { | |
| @@ -523,6 +544,44 @@ func closesFlag(values []string) []string { | |||
| 523 | 544 | return out | |
| 524 | 545 | } | |
| 525 | 546 | ||
| 547 | // resolveCloses turns `#42` and `42` into the issue's ULID before the link is | ||
| 548 | // written. A number that names a change request is refused here rather than at the | ||
| 549 | // merge: a request cannot close a request, and the author is still looking. | ||
| 550 | func resolveCloses(c *client.Branch, values []string) ([]string, error) { | ||
| 551 | ids := closesFlag(values) | ||
| 552 | var nums *metadata.Numbers | ||
| 553 | out := make([]string, 0, len(ids)) | ||
| 554 | for _, id := range ids { | ||
| 555 | num, ok := metadata.ParseNumber(id) | ||
| 556 | if !ok { | ||
| 557 | out = append(out, metadata.TrimReference(id)) | ||
| 558 | continue | ||
| 559 | } | ||
| 560 | if nums == nil { | ||
| 561 | var err error | ||
| 562 | if nums, err = c.Numbers(); err != nil { | ||
| 563 | return nil, err | ||
| 564 | } | ||
| 565 | } | ||
| 566 | found, kind, ok := nums.At(num) | ||
| 567 | if !ok { | ||
| 568 | return nil, fmt.Errorf("no issue or change request is #%d", num) | ||
| 569 | } | ||
| 570 | if kind != metadata.KindIssue { | ||
| 571 | return nil, fmt.Errorf("#%d is a change request, not an issue: %s", num, found) | ||
| 572 | } | ||
| 573 | out = append(out, found) | ||
| 574 | } | ||
| 575 | return out, nil | ||
| 576 | } | ||
| 577 | |||
| 578 | // numberOf is the alias as a number, for the JSON form: 0 says the branch has not | ||
| 579 | // numbered this entity, which is what a consumer can test without parsing `#?`. | ||
| 580 | func numberOf(nums *metadata.Numbers, id string) int { | ||
| 581 | num, _ := nums.Of(id) | ||
| 582 | return num | ||
| 583 | } | ||
| 584 | |||
| 526 | 585 | // equalIDs compares two id lists as sets after normalisation, which is what deciding | |
| 527 | 586 | // "did the links change" needs: order and duplicates are not edits. | |
| 528 | 587 | func equalIDs(a, b []string) bool { | |