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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 1h ago 4b8c9e1d
history blame raw 18.4 KiB
1package main
2
3import (
4 "fmt"
5 "os"
6 "sort"
7 "strconv"
8 "strings"
9 "time"
10
11 "rodent.software/vole/client"
12 "rodent.software/vole/internal/format"
13 "rodent.software/vole/metadata"
14)
15
16func changeUsage() string {
17 return `usage: vole change <command> [options] [args]
18
19 new --title <text> --base <branch> [--head <rev>] [--closes <issue>]... [-m <text> | -F <file>]
20 list [--state open|closed|all] [--format <fmt>]
21 show <id>
22 comment <id> [-m <text> | -F <file>] [--path <file> --line <n> [--side new|old]] [--reply-to <comment-id>]
23 edit-comment <id> <comment-id> [-m <text> | -F <file>]
24 rm-comment <id> <comment-id>
25 update <id> [--base <branch>] [--head <rev>] [--closes <issue>]... [--no-closes]
26 approve <id> [--as <account>]
27 merge <id>
28 close|reopen <id>
29
30--no-push commits locally without pushing; -- ends option parsing. An id takes a
31prefix or the #42 number: numbers are one sequence shared with issues, oldest
32first. --closes takes an issue id or its number. A comment is yours to edit or
33delete; an admin may repair somebody else's, audited.`
34}
35
36// cmdChange implements the change-request commands. A request is a promise to
37// land one specific commit; approving it approves that commit, not the branch. A
38// request opened from a branch follows it, so a push moves the head and the
39// approval has to name the tip it actually reviewed.
40func cmdChange(args []string) error {
41 if len(args) == 0 {
42 fmt.Fprint(os.Stderr, changeUsage()+"\n")
43 return usageErr{msg: "no change subcommand given"}
44 }
45 if args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
46 fmt.Fprintln(os.Stdout, changeUsage())
47 return nil
48 }
49 sub := args[0]
50 specs := []flagSpec{
51 {long: "title", short: "t"},
52 {long: "base", short: "b"},
53 {long: "head", short: "H"},
54 {long: "closes"},
55 {long: "message", short: "m"},
56 {long: "file", short: "F"},
57 {long: "as"},
58 {long: "path"},
59 {long: "line"},
60 {long: "side"},
61 {long: "reply-to"},
62 {long: "state"},
63 {long: "remote"},
64 {long: "format"},
65 {long: "no-push", boolean: true},
66 {long: "no-closes", boolean: true},
67 }
68 f, err := parseFlags(args[1:], specs...)
69 if err != nil {
70 return err
71 }
72 if f.help {
73 fmt.Fprintln(os.Stdout, changeUsage())
74 return nil
75 }
76 changeFlags := map[string][]string{
77 "new": {"title", "base", "head", "closes", "message", "file", "remote", "no-push"},
78 "comment": {"message", "file", "path", "line", "side", "reply-to", "remote", "no-push"},
79 "edit-comment": {"message", "file", "remote", "no-push"},
80 "rm-comment": {"remote", "no-push"},
81 "list": {"state", "format", "remote"},
82 "show": {"remote"},
83 "update": {"base", "head", "closes", "no-closes", "remote", "no-push"},
84 "approve": {"as", "remote", "no-push"},
85 "merge": {"remote"},
86 "close": {"remote", "no-push"},
87 "reopen": {"remote", "no-push"},
88 }
89 if names, ok := changeFlags[sub]; ok {
90 if err := f.checkAllowed("vole change "+sub, names...); err != nil {
91 return err
92 }
93 } else {
94 fmt.Fprint(os.Stderr, changeUsage()+"\n")
95 return usagef("unknown change subcommand %q", sub)
96 }
97
98 c, err := client.NewBranch(f.one("remote"), metadata.Ref)
99 if err != nil {
100 return err
101 }
102 // The author of a request is the account the key resolves to. Reading needs
103 // no identity; writing does.
104 var who *client.Actor
105 var whoErr error
106 if sub != "list" && sub != "show" {
107 who, whoErr = client.ResolveActor(c)
108 }
109 author := ""
110 if who != nil {
111 author = who.Name
112 }
113 noPush := f.has("no-push")
114 // `approve --as` names another account, so it must be on the roster. The
115 // roster is one SSH verb away, on the same key.
116 ros, rosterErr := client.LoadRoster(c.Repo.Dir, c.Remote)
117
118 switch sub {
119 case "new":
120 if len(f.rest) > 0 {
121 return usagef("unexpected argument %q", f.rest[0])
122 }
123 title := strings.TrimSpace(f.one("title"))
124 if title == "" {
125 return usagef("--title is required")
126 }
127 base := strings.TrimSpace(f.one("base"))
128 if base == "" {
129 base = "main"
130 }
131 head, err := client.ResolveHead(c, f.one("head"), noPush)
132 if err != nil {
133 return err
134 }
135 noteHead(head, c.Remote)
136 if client.NothingToReview(c, base, head.SHA) {
137 return fmt.Errorf("nothing to review: %s is already in %s", format.ID(head.SHA), base)
138 }
139 body, err := readBody(f)
140 if err != nil {
141 return err
142 }
143 if whoErr != nil {
144 return fmt.Errorf("cannot resolve your account for a change request: %w", whoErr)
145 }
146 r, err := metadata.NewRequest(title, body, author, base, head.SHA, time.Now())
147 if err != nil {
148 return err
149 }
150 r.HeadRef = head.Branch
151 // A linked issue is a promise that landing this request closes it, so the ids
152 // are checked when the request is written rather than at the merge: a typo
153 // should fail while the author is still looking at the command.
154 if r.Closes, err = resolveCloses(c, f.all("closes")); err != nil {
155 return err
156 }
157 reapply := func() error { return metadata.CreateRequest(c.Repo, r, author) }
158 if err := reapply(); err != nil {
159 return err
160 }
161 if err := c.CommitAndPush(noPush, reapply); err != nil {
162 return err
163 }
164 fmt.Printf("%s\t%s\t%s\t%s\n", r.ID, r.Base, format.ID(r.Head), r.Title)
165 for _, id := range r.Closes {
166 fmt.Fprintf(os.Stderr, "closes %s when merged\n", id)
167 }
168 label := ""
169 if nums, nerr := c.Numbers(); nerr == nil {
170 label = " as " + numberLabel(nums, r.ID)
171 }
172 if u := who.Page("changes", r.ID, ""); u != "" {
173 fmt.Fprintf(os.Stderr, "opened%s at %s\n", label, u)
174 } else {
175 fmt.Fprintf(os.Stderr, "opened%s\n", label)
176 }
177 return nil
178
179 case "comment", "edit-comment", "rm-comment":
180 if len(f.rest) == 0 {
181 return usagef("usage: vole change %s <id> ...", sub)
182 }
183 r, err := metadata.FindRequest(c.Repo, f.rest[0])
184 if err != nil {
185 return err
186 }
187 body := ""
188 if sub != "rm-comment" {
189 if body, err = readBody(f); err != nil {
190 return err
191 }
192 if strings.TrimSpace(body) == "" {
193 return usagef("a comment needs a body: use -m or -F")
194 }
195 }
196 var commentID string
197 if sub != "comment" {
198 if len(f.rest) != 2 {
199 return usagef("usage: vole change %s <id> <comment-id> [-m <text> | -F <file>]", sub)
200 }
201 if commentID, err = client.ResolveRequestCommentID(c, r.ID, f.rest[1]); err != nil {
202 return err
203 }
204 } else if len(f.rest) != 1 {
205 return usagef("usage: vole change comment <id> [-m <text> | -F <file>]")
206 }
207
208 if sub == "comment" {
209 // An anchor is all of path, line and side; a half anchor is a mistake.
210 path := strings.TrimSpace(f.one("path"))
211 anchor := (*metadata.Anchor)(nil)
212 if path != "" {
213 line, convErr := strconv.Atoi(f.one("line"))
214 if convErr != nil || line < 1 {
215 return usagef("--path needs --line <n> (the line the comment is about)")
216 }
217 side := f.one("side")
218 if side == "" {
219 side = "new"
220 }
221 if side != "new" && side != "old" {
222 return usagef("--side must be new (the head's file) or old (what it changed from)")
223 }
224 anchor, err = client.AnchorFor(c, r, path, line, side)
225 if err != nil {
226 return err
227 }
228 } else if f.given("line") || f.given("side") {
229 return usagef("--line and --side only mean something with --path")
230 }
231 replyTo := strings.TrimSpace(f.one("reply-to"))
232 if replyTo != "" && path != "" {
233 return usagef("--reply-to cannot be combined with --path: a reply belongs to its parent thread")
234 }
235 if replyTo != "" {
236 if replyTo, err = client.ResolveRequestCommentID(c, r.ID, replyTo); err != nil {
237 return err
238 }
239 }
240 cm, err := metadata.NewRequestComment(body, author, time.Now())
241 if err != nil {
242 return err
243 }
244 cm.Anchor = anchor
245 cm.ReplyTo = replyTo
246 reapply := func() error { return metadata.AddRequestComment(c.Repo, r.ID, cm, author) }
247 if err := reapply(); err != nil {
248 return err
249 }
250 if err := c.CommitAndPush(noPush, reapply); err != nil {
251 return err
252 }
253 note := "commented on " + r.ID
254 if u := who.Page("changes", r.ID, "c-"+cm.ID); u != "" {
255 note += " at " + u
256 }
257 fmt.Fprintln(os.Stderr, note)
258 fmt.Println(cm.ID)
259 return nil
260 }
261
262 reapply := func() error {
263 if sub == "edit-comment" {
264 _, err := metadata.EditRequestComment(c.Repo, r.ID, commentID, body, author)
265 return err
266 }
267 _, err := metadata.DeleteRequestComment(c.Repo, r.ID, commentID, author)
268 return err
269 }
270 if err := reapply(); err != nil {
271 return err
272 }
273 if err := c.CommitAndPush(noPush, reapply); err != nil {
274 return err
275 }
276 verb := "edited"
277 if sub == "rm-comment" {
278 verb = "deleted"
279 }
280 fmt.Fprintf(os.Stderr, "%s comment %s on %s\n", verb, format.ID(commentID), r.ID)
281 return nil
282
283 case "list":
284 if len(f.rest) > 0 {
285 return usagef("unexpected argument %q", f.rest[0])
286 }
287 list, err := metadata.LoadRequests(c.Repo, metadata.Ref)
288 if err != nil {
289 return err
290 }
291 want := f.one("state")
292 if want != "" && want != "open" && want != "closed" && want != "all" {
293 return usagef("--state must be open, closed or all")
294 }
295 format := f.one("format")
296 nums, err := c.Numbers()
297 if err != nil {
298 return err
299 }
300 n := 0
301 for _, r := range list {
302 if want != "" && want != "all" && r.State != want {
303 continue
304 }
305 n++
306 switch format {
307 case "json":
308 fmt.Printf(`{"id":%q,"number":%d,"title":%q,"state":%q,"base":%q,"head":%q,"author":%q,"approvals":%d}`+"\n",
309 r.ID, numberOf(nums, r.ID), r.Title, r.State, r.Base, r.Head, r.Author, len(r.Approvals))
310 default:
311 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)
312 }
313 }
314 if n == 0 {
315 fmt.Fprintln(os.Stderr, "no change requests")
316 }
317 return nil
318
319 case "show":
320 if len(f.rest) != 1 {
321 return usagef("usage: vole change show <id>")
322 }
323 r, err := metadata.FindRequest(c.Repo, f.rest[0])
324 if err != nil {
325 return err
326 }
327 nums, err := c.Numbers()
328 if err != nil {
329 return err
330 }
331 fmt.Printf("number: %s\n", numberLabel(nums, r.ID))
332 fmt.Printf("id: %s\n", r.ID)
333 fmt.Printf("title: %s\n", r.Title)
334 fmt.Printf("state: %s\n", r.State)
335 fmt.Printf("author: %s\n", r.Author)
336 fmt.Printf("base: %s\n", r.Base)
337 fmt.Printf("head: %s\n", r.Head)
338 if r.HeadRef != "" {
339 fmt.Printf("branch: %s\n", r.HeadRef)
340 }
341 if line := client.MergeLine(c, r); line != "" {
342 fmt.Printf("merge: %s\n", line)
343 }
344 if status, err := client.ChangeStatus(c.Repo.Dir, c.Remote, r.ID); err == nil {
345 for _, line := range strings.Split(status, "\n") {
346 if line = strings.TrimSpace(line); line != "" {
347 fmt.Println(line)
348 }
349 }
350 }
351 if len(r.Approvals) == 0 {
352 fmt.Printf("approved: (none)\n")
353 }
354 for _, a := range r.Approvals {
355 mark := "stale"
356 if a.Head == r.Head && a.Base == r.Base {
357 mark = "current"
358 }
359 fmt.Printf("approved: %s (%s, head %s)\n", a.Principal, mark, format.ID(a.Head))
360 }
361 if strings.TrimSpace(r.Description) != "" {
362 fmt.Printf("\n%s\n", strings.TrimRight(r.Description, "\n"))
363 }
364 for _, cm := range r.Comments {
365 body := strings.TrimRight(cm.Body, "\n")
366 if cm.Deleted {
367 body = "(comment deleted)"
368 }
369 fmt.Printf("\n--- %s %s (%s) ---\n%s", cm.ID, cm.Author,
370 cm.Created.Format(time.RFC3339), body+"\n")
371 }
372 return nil
373
374 case "update":
375 if len(f.rest) != 1 {
376 return usagef("usage: vole change update <id> [--base <branch>] [--head <rev>] [--closes <issue>]... [--no-closes]")
377 }
378 r, err := metadata.FindRequest(c.Repo, f.rest[0])
379 if err != nil {
380 return err
381 }
382 base, baseChanged := r.Base, false
383 if f.given("base") {
384 base = strings.TrimPrefix(strings.TrimSpace(f.one("base")), "refs/heads/")
385 if base == "" {
386 return usagef("--base needs a branch name")
387 }
388 baseChanged = base != r.Base
389 }
390 head, headRef := r.Head, r.HeadRef
391 headMoved := false
392 if v := f.one("head"); v != "" {
393 h, err := client.ResolveHead(c, v, noPush)
394 if err != nil {
395 return err
396 }
397 noteHead(h, c.Remote)
398 if client.NothingToReview(c, base, h.SHA) {
399 return fmt.Errorf("nothing to review: %s is already in %s", format.ID(h.SHA), base)
400 }
401 // A branch makes the request follow it; a bare commit pins it.
402 head, headRef = h.SHA, h.Branch
403 headMoved = head != r.Head || headRef != r.HeadRef
404 }
405 // A link can be added or corrected long after a request was opened, which is
406 // the case that made prose references useless: the issue is discovered later.
407 linksChanged := false
408 closes := r.Closes
409 switch {
410 case f.has("no-closes") && f.given("closes"):
411 return usagef("--closes and --no-closes say different things")
412 case f.has("no-closes"):
413 closes, linksChanged = nil, len(r.Closes) > 0
414 case f.given("closes"):
415 if closes, err = resolveCloses(c, f.all("closes")); err != nil {
416 return err
417 }
418 linksChanged = !equalIDs(closes, r.Closes)
419 }
420 if !baseChanged && !headMoved && !linksChanged {
421 return usagef("nothing to update: pass --base <branch>, --head <rev> or --closes <issue>")
422 }
423 var out *metadata.Request
424 reapply := func() error {
425 var uerr error
426 out = r
427 if baseChanged {
428 if out, uerr = metadata.SetRequestBase(c.Repo, r.ID, base, author); uerr != nil {
429 return uerr
430 }
431 }
432 if linksChanged {
433 if out, uerr = metadata.SetRequestCloses(c.Repo, r.ID, closes, author); uerr != nil {
434 return uerr
435 }
436 }
437 if headMoved {
438 out, uerr = metadata.SetRequestHead(c.Repo, r.ID, head, headRef, author)
439 }
440 return uerr
441 }
442 if err := reapply(); err != nil {
443 return err
444 }
445 fmt.Printf("%s\t%s\t%s\n", out.ID, format.ID(out.Head), out.Title)
446 for _, id := range out.Closes {
447 fmt.Fprintf(os.Stderr, "closes %s when merged\n", id)
448 }
449 if baseChanged {
450 fmt.Fprintf(os.Stderr, "note: existing approvals named the old base and no longer apply\n")
451 }
452 if headMoved && out.Head != r.Head {
453 fmt.Fprintf(os.Stderr, "note: existing approvals named the old head and no longer apply\n")
454 }
455 return c.CommitAndPush(noPush, reapply)
456
457 case "merge":
458 if len(f.rest) != 1 {
459 return usagef("usage: vole change merge <id>")
460 }
461 // The landing happens on the server: the ACL, the approvals and the run
462 // results are all there, and none of them are in this clone. The answer is
463 // printed as the server wrote it, because it names the commit that landed.
464 out, err := client.MergeChange(c.Repo.Dir, c.Remote, f.rest[0])
465 if err != nil {
466 return err
467 }
468 fmt.Println(out)
469 return nil
470
471 case "approve":
472 if len(f.rest) != 1 {
473 return usagef("usage: vole change approve <id> [--as <account>]")
474 }
475 principal := client.SanitizeAuthor(f.one("as"))
476 if principal == "" {
477 principal = author
478 }
479 if rosterErr != nil {
480 return rosterErr
481 }
482 if !ros.Has(principal) {
483 return fmt.Errorf("no active account %q in the instance roster (see `vole admin account list` on the server)", principal)
484 }
485 r, err := metadata.FindRequest(c.Repo, f.rest[0])
486 if err != nil {
487 return err
488 }
489 var out *metadata.Request
490 reapply := func() error {
491 var uerr error
492 // r.Base and r.Head are the target this command read and displayed.
493 // If a push moved the followed branch or the request was retargeted,
494 // the writer refuses instead of approving a target nobody looked at.
495 out, uerr = metadata.ApproveRequest(c.Repo, r.ID, principal, r.Base, r.Head, time.Now(), principal)
496 return uerr
497 }
498 if err := reapply(); err != nil {
499 return err
500 }
501 fmt.Fprintf(os.Stderr, "approved %s at head %s as %s\n", out.ID, format.ID(out.Head), principal)
502 return c.CommitAndPush(noPush, reapply)
503
504 case "close", "reopen":
505 if len(f.rest) != 1 {
506 return usagef("usage: vole change %s <id>", sub)
507 }
508 state := "closed"
509 if sub == "reopen" {
510 state = "open"
511 }
512 r, err := metadata.FindRequest(c.Repo, f.rest[0])
513 if err != nil {
514 return err
515 }
516 var out *metadata.Request
517 reapply := func() error {
518 var uerr error
519 out, uerr = metadata.SetRequestState(c.Repo, r.ID, state, author)
520 return uerr
521 }
522 if err := reapply(); err != nil {
523 return err
524 }
525 fmt.Printf("%s\t%s\t%s\n", out.ID, out.State, out.Title)
526 return c.CommitAndPush(noPush, reapply)
527 }
528 fmt.Fprintln(os.Stderr, changeUsage())
529 return usagef("unknown change subcommand %q", sub)
530}
531
532// noteHead explains a resolved head that is not what the local clone holds, so
533// the commit a request records is not a surprise.
534func noteHead(h client.Head, remote string) {
535 if h.Stale {
536 fmt.Fprintf(os.Stderr, "note: the local %s differs from %s; using the remote tip %s\n",
537 h.Branch, remote, format.ID(h.SHA))
538 }
539 if h.LocalOnly {
540 fmt.Fprintf(os.Stderr, "note: %s is not on %s; the request points at its local commit %s\n",
541 h.Branch, remote, format.ID(h.SHA))
542 }
543}
544
545// closesFlag normalises --closes, which is repeatable and may also carry a
546// comma-separated list: both habits are common and neither is worth a second flag.
547func closesFlag(values []string) []string {
548 var out []string
549 for _, v := range values {
550 for _, id := range strings.Split(v, ",") {
551 if id = strings.TrimSpace(id); id != "" {
552 out = append(out, id)
553 }
554 }
555 }
556 return out
557}
558
559// resolveCloses turns `#42` and `42` into the issue's ULID before the link is
560// written. A number that names a change request is refused here rather than at the
561// merge: a request cannot close a request, and the author is still looking.
562func resolveCloses(c *client.Branch, values []string) ([]string, error) {
563 ids := closesFlag(values)
564 var nums *metadata.Numbers
565 out := make([]string, 0, len(ids))
566 for _, id := range ids {
567 num, ok := metadata.ParseNumber(id)
568 if !ok {
569 out = append(out, metadata.TrimReference(id))
570 continue
571 }
572 if nums == nil {
573 var err error
574 if nums, err = c.Numbers(); err != nil {
575 return nil, err
576 }
577 }
578 found, kind, ok := nums.At(num)
579 if !ok {
580 return nil, fmt.Errorf("no issue or change request is #%d", num)
581 }
582 if kind != metadata.KindIssue {
583 return nil, fmt.Errorf("#%d is a change request, not an issue: %s", num, found)
584 }
585 out = append(out, found)
586 }
587 return out, nil
588}
589
590// numberOf is the alias as a number, for the JSON form: 0 says the branch has not
591// numbered this entity, which is what a consumer can test without parsing `#?`.
592func numberOf(nums *metadata.Numbers, id string) int {
593 num, _ := nums.Of(id)
594 return num
595}
596
597// equalIDs compares two id lists as sets after normalisation, which is what deciding
598// "did the links change" needs: order and duplicates are not edits.
599func equalIDs(a, b []string) bool {
600 norm := func(in []string) string {
601 seen := map[string]bool{}
602 out := make([]string, 0, len(in))
603 for _, v := range in {
604 if v = strings.TrimSpace(v); v != "" && !seen[v] {
605 seen[v] = true
606 out = append(out, v)
607 }
608 }
609 sort.Strings(out)
610 return strings.Join(out, ",")
611 }
612 return norm(a) == norm(b)
613}
614