Eric Bower
·
2026-02-25
cli.go
1package git
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "strconv"
8 "strings"
9 "text/tabwriter"
10
11 "github.com/picosh/pico/pkg/pssh"
12 "github.com/urfave/cli/v2"
13)
14
15func errNotExist(host, pubkey string) error {
16 return fmt.Errorf("User does not exist, run `ssh <username>@%s register` to create an account\nPubkey: %s", host, pubkey)
17}
18
19func NewTabWriter(out io.Writer) *tabwriter.Writer {
20 return tabwriter.NewWriter(out, 0, 0, 1, ' ', tabwriter.TabIndent)
21}
22
23func strToInt(str string) (int64, error) {
24 prID, err := strconv.ParseInt(str, 10, 64)
25 return prID, err
26}
27
28func getPatchsetFromOpt(patchsets []*Patchset, optPatchsetID string) (*Patchset, error) {
29 if optPatchsetID == "" {
30 return patchsets[len(patchsets)-1], nil
31 }
32
33 id, err := getPatchsetID(optPatchsetID)
34 if err != nil {
35 return nil, err
36 }
37
38 for _, ps := range patchsets {
39 if ps.ID == id {
40 return ps, nil
41 }
42 }
43
44 return nil, fmt.Errorf("cannot find patchset: %s", optPatchsetID)
45}
46
47func printPatches(sesh *pssh.SSHServerConnSession, patches []*Patch) {
48 if len(patches) == 1 {
49 sesh.Println(patches[0].RawText)
50 return
51 }
52
53 opatches := patches
54 for idx, patch := range opatches {
55 sesh.Println(patch.RawText)
56 if idx < len(patches)-1 {
57 sesh.Printf("\n\n\n")
58 }
59 }
60}
61
62func prSummary(be *Backend, pr GitPatchRequest, sesh *pssh.SSHServerConnSession, prID int64) error {
63 request, err := pr.GetPatchRequestByID(prID)
64 if err != nil {
65 return err
66 }
67
68 repo, err := pr.GetRepoByID(request.RepoID)
69 if err != nil {
70 return err
71 }
72
73 repoUser, err := pr.GetUserByID(repo.UserID)
74 if err != nil {
75 return err
76 }
77
78 sesh.Printf("Info\n====\n")
79 sesh.Printf("URL: https://%s/prs/%d\n", be.Cfg.Url, prID)
80 sesh.Printf("Repo: %s\n\n", be.CreateRepoNs(repoUser.Name, repo.Name))
81
82 writer := NewTabWriter(sesh)
83 _, _ = fmt.Fprintln(writer, "ID\tName\tStatus\tDate")
84 _, _ = fmt.Fprintf(
85 writer,
86 "%d\t%s\t[%s]\t%s\n",
87 request.ID, request.Name, request.Status, request.CreatedAt.Format(be.Cfg.TimeFormat),
88 )
89 _ = writer.Flush()
90
91 patchsets, err := pr.GetPatchsetsByPrID(prID)
92 if err != nil {
93 return err
94 }
95
96 sesh.Printf("\nPatchsets\n====\n")
97
98 writerSet := NewTabWriter(sesh)
99 _, _ = fmt.Fprintln(writerSet, "ID\tType\tUser\tDate")
100 for _, patchset := range patchsets {
101 user, err := pr.GetUserByID(patchset.UserID)
102 if err != nil {
103 be.Logger.Error("cannot find user for patchset", "err", err)
104 continue
105 }
106 isReview := ""
107 if patchset.Review {
108 isReview = "[review]"
109 }
110
111 _, _ = fmt.Fprintf(
112 writerSet,
113 "%s\t%s\t%s\t%s\n",
114 getFormattedPatchsetID(patchset.ID),
115 isReview,
116 user.Name,
117 patchset.CreatedAt.Format(be.Cfg.TimeFormat),
118 )
119 }
120 _ = writerSet.Flush()
121
122 latest, err := getPatchsetFromOpt(patchsets, "")
123 if err != nil {
124 return err
125 }
126
127 patches, err := pr.GetPatchesByPatchsetID(latest.ID)
128 if err != nil {
129 return err
130 }
131
132 sesh.Printf("\nPatches from latest patchset\n====\n")
133
134 opatches := patches
135 w := NewTabWriter(sesh)
136 _, _ = fmt.Fprintln(w, "Idx\tTitle\tCommit\tAuthor\tDate")
137 for idx, patch := range opatches {
138 timestamp := patch.AuthorDate.Format(be.Cfg.TimeFormat)
139 _, _ = fmt.Fprintf(
140 w,
141 "%d\t%s\t%s\t%s <%s>\t%s\n",
142 idx,
143 patch.Title,
144 truncateSha(patch.CommitSha),
145 patch.AuthorName,
146 patch.AuthorEmail,
147 timestamp,
148 )
149 }
150 _ = w.Flush()
151 return nil
152}
153
154func printPatchsetFromID(sesh *pssh.SSHServerConnSession, pr GitPatchRequest, psID int64) error {
155 patches, err := pr.GetPatchesByPatchsetID(psID)
156 if err != nil {
157 return err
158 }
159 printPatches(sesh, patches)
160 return nil
161}
162
163func printPatchsetFromPrID(sesh *pssh.SSHServerConnSession, pr GitPatchRequest, prID int64) error {
164 patchsets, err := pr.GetPatchsetsByPrID(prID)
165 if err != nil {
166 return err
167 }
168 ps := patchsets[len(patchsets)-1]
169 patches, err := pr.GetPatchesByPatchsetID(ps.ID)
170 if err != nil {
171 return err
172 }
173
174 printPatches(sesh, patches)
175 return nil
176}
177
178func NewCli(sesh *pssh.SSHServerConnSession, be *Backend, pr GitPatchRequest) *cli.App {
179 desc := fmt.Sprintf(`git-pr (v%s): A pastebin supercharged for git collaboration.
180
181Here's how it works:
182 - External contributor clones repo (git-clone)
183 - External contributor makes a code change (git-add & git-commit)
184 - External contributor generates patches (git-format-patch)
185 - External contributor submits a PR to SSH server
186 - Owner receives RSS notification that there's a new PR
187 - Owner applies patches locally (git-am) from SSH server
188 - Owner makes suggestions in code! (git-add & git-commit)
189 - Owner submits review by piping patch to SSH server (git-format-patch)
190 - External contributor receives RSS notification of the PR review
191 - External contributor re-applies patches (git-am)
192 - External contributor reviews and removes comments in code!
193 - External contributor submits another patch (git-format-patch)
194 - Owner applies patches locally (git-am)
195 - Owner marks PR as accepted and pushes code to main (git-push)
196
197To get started, submit a new patch request:
198 git format-patch main --stdout | ssh %s pr create {repo}
199`, GITPR_VERSION, be.Cfg.Url)
200
201 pubkey := be.Pubkey(sesh.PublicKey())
202 userName := sesh.User()
203 app := &cli.App{
204 Name: "ssh",
205 Description: desc,
206 Usage: "Collaborate with contributors for your git project",
207 Writer: sesh,
208 ErrWriter: sesh,
209 ExitErrHandler: func(cCtx *cli.Context, err error) {
210 if err != nil {
211 sesh.Fatal(fmt.Errorf("err: %w", err))
212 }
213 },
214 OnUsageError: func(cCtx *cli.Context, err error, isSubcommand bool) error {
215 if err != nil {
216 sesh.Fatal(fmt.Errorf("err: %w", err))
217 }
218 return nil
219 },
220 Commands: []*cli.Command{
221 {
222 Name: "logs",
223 Usage: "List event logs with filters",
224 Args: true,
225 Flags: []cli.Flag{
226 &cli.Int64Flag{
227 Name: "pr",
228 Usage: "show all events related to the provided patch request",
229 },
230 &cli.BoolFlag{
231 Name: "pubkey",
232 Usage: "show all events related to your pubkey",
233 },
234 &cli.StringFlag{
235 Name: "repo",
236 Usage: "show all events related to a repo",
237 },
238 },
239 Action: func(cCtx *cli.Context) error {
240 pubkey := be.Pubkey(sesh.PublicKey())
241 user, err := pr.GetUserByPubkey(pubkey)
242 if err != nil {
243 return errNotExist(be.Cfg.Host, pubkey)
244 }
245 isPubkey := cCtx.Bool("pubkey")
246 prID := cCtx.Int64("pr")
247 repoNs := cCtx.String("repo")
248 var eventLogs []*EventLog
249 if isPubkey {
250 eventLogs, err = pr.GetEventLogsByUserID(user.ID)
251 } else if prID != 0 {
252 eventLogs, err = pr.GetEventLogsByPrID(prID)
253 } else if repoNs != "" {
254 repoUsername, repoName := be.SplitRepoNs(repoNs)
255 var repoUser *User
256 repoUser, err = pr.GetUserByName(repoUsername)
257 if err != nil {
258 return nil
259 }
260 eventLogs, err = pr.GetEventLogsByRepoName(repoUser, repoName)
261 } else {
262 eventLogs, err = pr.GetEventLogs()
263 }
264 if err != nil {
265 return err
266 }
267
268 writer := NewTabWriter(sesh)
269 _, _ = fmt.Fprintln(writer, "RepoID\tPrID\tPatchsetID\tEvent\tCreated\tData")
270 for _, eventLog := range eventLogs {
271 repo, err := pr.GetRepoByID(eventLog.RepoID.Int64)
272 if err != nil {
273 be.Logger.Error("repo not found", "repo", repo, "err", err)
274 continue
275 }
276 repoUser, err := pr.GetUserByID(repo.UserID)
277 if err != nil {
278 be.Logger.Error("repo user not found", "repo", repo, "err", err)
279 continue
280 }
281 _, _ = fmt.Fprintf(
282 writer,
283 "%s\t%d\t%s\t%s\t%s\t%s\n",
284 be.CreateRepoNs(repoUser.Name, repo.Name),
285 eventLog.PatchRequestID.Int64,
286 getFormattedPatchsetID(eventLog.PatchsetID.Int64),
287 eventLog.Event,
288 eventLog.CreatedAt.Format(be.Cfg.TimeFormat),
289 eventLog.Data,
290 )
291 }
292 _ = writer.Flush()
293 return nil
294 },
295 },
296 {
297 Name: "register",
298 Usage: "Create an account",
299 Args: true,
300 Flags: []cli.Flag{},
301 Action: func(cCtx *cli.Context) error {
302 pubkey := be.Pubkey(sesh.PublicKey())
303 user, err := pr.RegisterUser(pubkey, userName)
304 if err != nil {
305 return err
306 }
307 sesh.Printf("User created successfully!\nUser: %s\nPubkey: %s\n", user.Name, pubkey)
308 return nil
309 },
310 },
311 {
312 Name: "ps",
313 Usage: "Mange patchsets",
314 Subcommands: []*cli.Command{
315 {
316 Name: "rm",
317 Usage: "Remove a patchset with its patches",
318 Args: true,
319 ArgsUsage: "[patchsetID]",
320 Action: func(cCtx *cli.Context) error {
321 args := cCtx.Args()
322 if !args.Present() {
323 return fmt.Errorf("must provide a patchset ID")
324 }
325
326 patchsetID, err := getPatchsetID(args.First())
327 if err != nil {
328 return err
329 }
330
331 patchset, err := pr.GetPatchsetByID(patchsetID)
332 if err != nil {
333 return err
334 }
335
336 user, err := pr.GetUserByID(patchset.UserID)
337 if err != nil {
338 return err
339 }
340
341 pk := sesh.PublicKey()
342 isAdmin := be.IsAdmin(pk)
343 isContrib := pubkey == user.Pubkey
344 if !isAdmin && !isContrib {
345 return fmt.Errorf("you are not authorized to delete a patchset")
346 }
347
348 err = pr.DeletePatchsetByID(user.ID, patchset.PatchRequestID, patchsetID)
349 if err != nil {
350 return err
351 }
352 sesh.Printf("successfully removed patchset: %d\n", patchsetID)
353 return nil
354 },
355 },
356 },
357 },
358 {
359 Name: "repo",
360 Usage: "Manage repos",
361 Subcommands: []*cli.Command{
362 {
363 Name: "create",
364 Usage: "Create a new repo",
365 Args: true,
366 ArgsUsage: "[repoName]",
367 Action: func(cCtx *cli.Context) error {
368 user, err := pr.GetUserByPubkey(pubkey)
369 if err != nil {
370 return errNotExist(be.Cfg.Host, pubkey)
371 }
372
373 args := cCtx.Args()
374 if !args.Present() {
375 return fmt.Errorf("need repo name argument")
376 }
377 repoName := args.First()
378 repo, _ := pr.GetRepoByName(user, repoName)
379 err = be.CanCreateRepo(repo, user)
380 if err != nil {
381 return err
382 }
383
384 if repo == nil {
385 repo, err = pr.CreateRepo(user, repoName)
386 if err != nil {
387 return err
388 }
389 }
390
391 sesh.Printf("repo created: %s/%s\n", user.Name, repo.Name)
392 return nil
393 },
394 },
395 {
396 Name: "rm",
397 Usage: "Delete repo and associated patch requests",
398 Args: true,
399 ArgsUsage: "[repoName]",
400 Flags: []cli.Flag{
401 &cli.BoolFlag{
402 Name: "write",
403 Usage: "Are you sure you want to delete the repo and all patch requests?",
404 },
405 },
406 Action: func(cCtx *cli.Context) error {
407 user, err := pr.GetUserByPubkey(pubkey)
408 if err != nil {
409 return errNotExist(be.Cfg.Host, pubkey)
410 }
411
412 args := cCtx.Args()
413 if !args.Present() {
414 return fmt.Errorf("need repo name argument")
415 }
416 rawRepoNs := args.First()
417 _, repoName := be.SplitRepoNs(rawRepoNs)
418 repo, _ := pr.GetRepoByName(user, repoName)
419 if repo == nil {
420 return fmt.Errorf("repo does not exist: %s/%s", user.Name, repoName)
421 }
422 err = be.CanCreateRepo(repo, user)
423 if err != nil {
424 return err
425 }
426
427 if cCtx.Bool("write") {
428 err = pr.DeleteRepo(user, repoName)
429 if err != nil {
430 return err
431 }
432 } else {
433 sesh.Println("Must provide `--write` flag to persist changes")
434 }
435
436 sesh.Printf("repo deleted: %s/%s\n", user.Name, repo.Name)
437 return nil
438 },
439 },
440 },
441 },
442 {
443 Name: "print",
444 Usage: "Print patches in a patchset",
445 Args: true,
446 ArgsUsage: "[pr-X] or [ps-X]",
447 Action: func(cCtx *cli.Context) error {
448 args := cCtx.Args()
449 raw := args.First()
450 split := strings.Split(raw, "-")
451 if len(split) < 2 {
452 return fmt.Errorf("must provide ID in format: pr-X, ps-X")
453 }
454
455 prefix := split[0]
456 id, err := strToInt(split[1])
457 if err != nil {
458 return err
459 }
460
461 switch prefix {
462 case "pr":
463 err = printPatchsetFromPrID(sesh, pr, id)
464 case "ps":
465 err = printPatchsetFromID(sesh, pr, id)
466 }
467
468 return err
469 },
470 },
471 {
472 Name: "pr",
473 Usage: "Manage Patch Requests (PR)",
474 Subcommands: []*cli.Command{
475 {
476 Name: "ls",
477 Usage: "List all PRs",
478 Args: true,
479 ArgsUsage: "[repoName]",
480 Flags: []cli.Flag{
481 &cli.BoolFlag{
482 Name: "open",
483 Usage: "only show open PRs",
484 },
485 &cli.BoolFlag{
486 Name: "closed",
487 Usage: "only show closed PRs",
488 },
489 &cli.BoolFlag{
490 Name: "accepted",
491 Usage: "only show accepted PRs",
492 },
493 &cli.BoolFlag{
494 Name: "mine",
495 Usage: "only show your own PRs",
496 },
497 },
498 Action: func(cCtx *cli.Context) error {
499 args := cCtx.Args()
500 rawRepoNs := args.First()
501 userName, repoName := be.SplitRepoNs(rawRepoNs)
502 var prs []*PatchRequest
503 var err error
504 if repoName == "" {
505 prs, err = pr.GetPatchRequests()
506 if err != nil {
507 return err
508 }
509 } else {
510 user, err := pr.GetUserByName(userName)
511 if err != nil {
512 return err
513 }
514 repo, err := pr.GetRepoByName(user, repoName)
515 if err != nil {
516 return err
517 }
518 prs, err = pr.GetPatchRequestsByRepoID(repo.ID)
519 if err != nil {
520 return err
521 }
522 }
523
524 onlyOpen := cCtx.Bool("open")
525 onlyAccepted := cCtx.Bool("accepted")
526 onlyClosed := cCtx.Bool("closed")
527 onlyMine := cCtx.Bool("mine")
528
529 writer := NewTabWriter(sesh)
530 _, _ = fmt.Fprintln(writer, "ID\tRepoID\tName\tStatus\tPatchsets\tUser\tDate")
531 for _, req := range prs {
532 if onlyAccepted && req.Status != StatusAccepted {
533 continue
534 }
535
536 if onlyClosed && req.Status != StatusClosed {
537 continue
538 }
539
540 if onlyOpen && req.Status != StatusOpen {
541 continue
542 }
543
544 user, err := pr.GetUserByID(req.UserID)
545 if err != nil {
546 be.Logger.Error("could not get user for pr", "err", err)
547 continue
548 }
549
550 if onlyMine && user.Name != userName {
551 continue
552 }
553
554 patchsets, err := pr.GetPatchsetsByPrID(req.ID)
555 if err != nil {
556 be.Logger.Error("could not get patchsets for pr", "err", err)
557 continue
558 }
559
560 repo, err := pr.GetRepoByID(req.RepoID)
561 if err != nil {
562 be.Logger.Error("could not get repo for pr", "err", err)
563 continue
564 }
565
566 repoUser, err := pr.GetUserByID(repo.UserID)
567 if err != nil {
568 be.Logger.Error("could not get repo user for pr", "err", err)
569 continue
570 }
571
572 _, _ = fmt.Fprintf(
573 writer,
574 "%d\t%s\t%s\t[%s]\t%d\t%s\t%s\n",
575 req.ID,
576 be.CreateRepoNs(repoUser.Name, repo.Name),
577 req.Name,
578 req.Status,
579 len(patchsets),
580 user.Name,
581 req.CreatedAt.Format(be.Cfg.TimeFormat),
582 )
583 }
584 _ = writer.Flush()
585 return nil
586 },
587 },
588 {
589 Name: "create",
590 Usage: "Submit a new PR",
591 Args: true,
592 ArgsUsage: "[repoName]",
593 Action: func(cCtx *cli.Context) error {
594 user, err := pr.GetUserByPubkey(pubkey)
595 if err != nil {
596 return errNotExist(be.Cfg.Host, pubkey)
597 }
598
599 args := cCtx.Args()
600 rawRepoNs := "bin"
601 if args.Present() {
602 rawRepoNs = args.First()
603 }
604 repoUsername, repoName := be.SplitRepoNs(rawRepoNs)
605 var repo *Repo
606 if repoUsername == "" {
607 if be.Cfg.CreateRepo == "admin" {
608 // single tenant default user to admin
609 repo, _ = pr.GetRepoByName(nil, repoName)
610 } else {
611 // multi tenant default user to contributor
612 repo, _ = pr.GetRepoByName(user, repoName)
613 }
614 } else {
615 repoUser, err := pr.GetUserByName(repoUsername)
616 if err != nil {
617 return err
618 }
619 repo, _ = pr.GetRepoByName(repoUser, repoName)
620 }
621
622 err = be.CanCreateRepo(repo, user)
623 if err != nil {
624 return err
625 }
626
627 if repo == nil {
628 repo, err = pr.CreateRepo(user, repoName)
629 if err != nil {
630 return err
631 }
632 }
633
634 prq, err := pr.SubmitPatchRequest(repo.ID, user.ID, sesh)
635 if err != nil {
636 return err
637 }
638 sesh.Println(
639 "PR submitted! Use the ID for interacting with this PR.",
640 )
641
642 return prSummary(be, pr, sesh, prq.ID)
643 },
644 },
645 {
646 Name: "summary",
647 Usage: "Display metadata related to a PR",
648 Args: true,
649 ArgsUsage: "[prID]",
650 Action: func(cCtx *cli.Context) error {
651 args := cCtx.Args()
652 if !args.Present() {
653 return fmt.Errorf("must provide a patch request ID")
654 }
655
656 prID, err := strToInt(args.First())
657 if err != nil {
658 return err
659 }
660 return prSummary(be, pr, sesh, prID)
661 },
662 },
663 {
664 Name: "accept",
665 Usage: "Accept a PR",
666 Args: true,
667 ArgsUsage: "[prID], [prID]...",
668 Flags: []cli.Flag{
669 &cli.BoolFlag{
670 Name: "comment",
671 Usage: "If this flag is provided, pass comment through stdin",
672 },
673 },
674 Action: func(cCtx *cli.Context) error {
675 args := cCtx.Args()
676 if !args.Present() {
677 return fmt.Errorf("must provide at least one patch request ID")
678 }
679
680 prIDs := args.Tail()
681 prIDs = append(prIDs, args.First())
682
683 var errs error
684 for _, prIDStr := range prIDs {
685 prID, err := strToInt(prIDStr)
686 if err != nil {
687 sesh.Errorln(err)
688 continue
689 }
690
691 prq, err := pr.GetPatchRequestByID(prID)
692 if err != nil {
693 return err
694 }
695
696 user, err := pr.GetUserByPubkey(pubkey)
697 if err != nil {
698 return errNotExist(be.Cfg.Host, pubkey)
699 }
700
701 repo, err := pr.GetRepoByID(prq.RepoID)
702 if err != nil {
703 return err
704 }
705
706 acl := be.GetPatchRequestAcl(repo, prq, user)
707 if !acl.CanReview {
708 return fmt.Errorf("you are not authorized to accept a PR")
709 }
710
711 if prq.Status == StatusAccepted {
712 return fmt.Errorf("PR has already been accepted")
713 }
714
715 comment := cCtx.Bool("comment")
716 var commentTxt []byte
717 if comment {
718 commentTxt, err = io.ReadAll(sesh)
719 if err != nil {
720 return fmt.Errorf("when comment flag enabled must provide it from stdin")
721 }
722 }
723
724 err = pr.UpdatePatchRequestStatus(prID, user.ID, StatusAccepted, string(commentTxt))
725 if err != nil {
726 return err
727 }
728 sesh.Printf("Accepted PR %s (#%d)\n", prq.Name, prq.ID)
729 err = prSummary(be, pr, sesh, prID)
730 if err != nil {
731 errs = errors.Join(errs, err)
732 }
733 sesh.Printf("\n\n")
734 }
735
736 return errs
737 },
738 },
739 {
740 Name: "close",
741 Usage: "Close a PR",
742 Args: true,
743 ArgsUsage: "[prID], [prID]...",
744 Flags: []cli.Flag{
745 &cli.BoolFlag{
746 Name: "comment",
747 Usage: "If this flag is provided, pass comment through stdin",
748 },
749 },
750 Action: func(cCtx *cli.Context) error {
751 args := cCtx.Args()
752 if !args.Present() {
753 return fmt.Errorf("must provide a patch request ID")
754 }
755
756 prIDs := args.Tail()
757 prIDs = append(prIDs, args.First())
758
759 var errs error
760 for _, prIDStr := range prIDs {
761 prID, err := strToInt(prIDStr)
762 if err != nil {
763 sesh.Errorln(err)
764 continue
765 }
766
767 prq, err := pr.GetPatchRequestByID(prID)
768 if err != nil {
769 return err
770 }
771
772 patchUser, err := pr.GetUserByID(prq.UserID)
773 if err != nil {
774 return err
775 }
776
777 repo, err := pr.GetRepoByID(prq.RepoID)
778 if err != nil {
779 return err
780 }
781
782 acl := be.GetPatchRequestAcl(repo, prq, patchUser)
783 if !acl.CanModify {
784 return fmt.Errorf("you are not authorized to change PR status")
785 }
786
787 if prq.Status == StatusClosed {
788 return fmt.Errorf("PR has already been closed")
789 }
790
791 user, err := pr.GetUserByPubkey(pubkey)
792 if err != nil {
793 return errNotExist(be.Cfg.Host, pubkey)
794 }
795
796 comment := cCtx.Bool("comment")
797 var commentTxt []byte
798 if comment {
799 commentTxt, err = io.ReadAll(sesh)
800 if err != nil {
801 return fmt.Errorf("when comment flag enabled must provide it from stdin")
802 }
803 }
804
805 err = pr.UpdatePatchRequestStatus(prID, user.ID, StatusClosed, string(commentTxt))
806 if err != nil {
807 return err
808 }
809 sesh.Printf("Closed PR %s (#%d)\n", prq.Name, prq.ID)
810 err = prSummary(be, pr, sesh, prID)
811 if err != nil {
812 errs = errors.Join(errs, err)
813 }
814 sesh.Printf("\n\n")
815 }
816 return errs
817 },
818 },
819 {
820 Name: "reopen",
821 Usage: "Reopen a PR",
822 Args: true,
823 ArgsUsage: "[prID]",
824 Flags: []cli.Flag{
825 &cli.BoolFlag{
826 Name: "comment",
827 Usage: "If this flag is provided, pass comment through stdin",
828 },
829 },
830 Action: func(cCtx *cli.Context) error {
831 args := cCtx.Args()
832 if !args.Present() {
833 return fmt.Errorf("must provide a patch request ID")
834 }
835
836 prID, err := strToInt(args.First())
837 if err != nil {
838 return err
839 }
840
841 prq, err := pr.GetPatchRequestByID(prID)
842 if err != nil {
843 return err
844 }
845
846 patchUser, err := pr.GetUserByID(prq.UserID)
847 if err != nil {
848 return err
849 }
850
851 repo, err := pr.GetRepoByID(prq.RepoID)
852 if err != nil {
853 return err
854 }
855
856 acl := be.GetPatchRequestAcl(repo, prq, patchUser)
857 if !acl.CanModify {
858 return fmt.Errorf("you are not authorized to change PR status")
859 }
860
861 if prq.Status == StatusOpen {
862 return fmt.Errorf("PR is already open")
863 }
864
865 user, err := pr.GetUserByPubkey(pubkey)
866 if err != nil {
867 return errNotExist(be.Cfg.Host, pubkey)
868 }
869
870 comment := cCtx.Bool("comment")
871 var commentTxt []byte
872 if comment {
873 commentTxt, err = io.ReadAll(sesh)
874 if err != nil {
875 return fmt.Errorf("when comment flag enabled must provide it from stdin")
876 }
877 }
878 err = pr.UpdatePatchRequestStatus(prID, user.ID, StatusOpen, string(commentTxt))
879 if err == nil {
880 sesh.Printf("Reopened PR %s (#%d)\n", prq.Name, prq.ID)
881 }
882 return prSummary(be, pr, sesh, prID)
883 },
884 },
885 {
886 Name: "edit",
887 Usage: "Edit PR title",
888 Args: true,
889 ArgsUsage: "[prID] [title]",
890 Action: func(cCtx *cli.Context) error {
891 args := cCtx.Args()
892 if !args.Present() {
893 return fmt.Errorf("must provide a patch request ID")
894 }
895
896 prID, err := strToInt(args.First())
897 if err != nil {
898 return err
899 }
900 prq, err := pr.GetPatchRequestByID(prID)
901 if err != nil {
902 return err
903 }
904
905 user, err := pr.GetUserByPubkey(pubkey)
906 if err != nil {
907 return errNotExist(be.Cfg.Host, pubkey)
908 }
909
910 repo, err := pr.GetRepoByID(prq.RepoID)
911 if err != nil {
912 return err
913 }
914
915 acl := be.GetPatchRequestAcl(repo, prq, user)
916 if !acl.CanModify {
917 return fmt.Errorf("you are not authorized to change PR")
918 }
919
920 tail := cCtx.Args().Tail()
921 title := strings.Join(tail, " ")
922 if title == "" {
923 return fmt.Errorf("must provide title")
924 }
925
926 err = pr.UpdatePatchRequestName(
927 prID,
928 user.ID,
929 title,
930 )
931 if err == nil {
932 sesh.Printf("New title: %s (%d)\n", title, prq.ID)
933 }
934
935 return err
936 },
937 },
938 {
939 Name: "add",
940 Usage: "Add a new patchset to a PR",
941 Args: true,
942 ArgsUsage: "[prID]",
943 Flags: []cli.Flag{
944 &cli.BoolFlag{
945 Name: "review",
946 Usage: "submit patchset mark it as a review",
947 },
948 &cli.BoolFlag{
949 Name: "accept",
950 Usage: "submit patchset and mark PR as accepted",
951 },
952 &cli.BoolFlag{
953 Name: "close",
954 Usage: "submit patchset and mark PR as closed",
955 },
956 &cli.StringFlag{
957 Name: "comment",
958 Usage: "add a comment to the patchset",
959 },
960 },
961 Action: func(cCtx *cli.Context) error {
962 args := cCtx.Args()
963 if !args.Present() {
964 return fmt.Errorf("must provide a patch request ID")
965 }
966
967 prID, err := strToInt(args.First())
968 if err != nil {
969 return err
970 }
971 prq, err := pr.GetPatchRequestByID(prID)
972 if err != nil {
973 return err
974 }
975
976 user, err := pr.GetUserByPubkey(pubkey)
977 if err != nil {
978 return errNotExist(be.Cfg.Host, pubkey)
979 }
980
981 isReview := cCtx.Bool("review")
982 isAccept := cCtx.Bool("accept")
983 isClose := cCtx.Bool("close")
984
985 repo, err := pr.GetRepoByID(prq.RepoID)
986 if err != nil {
987 return err
988 }
989
990 acl := be.GetPatchRequestAcl(repo, prq, user)
991 if !acl.CanAddPatchset {
992 return fmt.Errorf("you are not authorized to add patchsets to pr")
993 }
994
995 if isReview && !acl.CanReview {
996 return fmt.Errorf("you are not authorized to submit a review to pr")
997 }
998
999 op := OpNormal
1000 nextStatus := StatusOpen
1001 if isReview {
1002 sesh.Println("Marking patchset as a review")
1003 op = OpReview
1004 } else if isAccept {
1005 sesh.Println("Marking PR as accepted")
1006 nextStatus = StatusAccepted
1007 op = OpAccept
1008 } else if isClose {
1009 sesh.Println("Marking PR as closed")
1010 nextStatus = StatusClosed
1011 op = OpClose
1012 }
1013
1014 patches, err := pr.SubmitPatchset(prID, user.ID, op, sesh)
1015 if err != nil {
1016 return err
1017 }
1018
1019 if len(patches) == 0 {
1020 sesh.Println("Patches submitted! However none were saved, probably because they already exist in the system")
1021 return nil
1022 }
1023
1024 if prq.Status != nextStatus {
1025 err = pr.UpdatePatchRequestStatus(prID, user.ID, nextStatus, cCtx.String("comment"))
1026 if err != nil {
1027 return err
1028 }
1029 }
1030
1031 sesh.Println("Patches submitted!")
1032 return prSummary(be, pr, sesh, prID)
1033 },
1034 },
1035 },
1036 },
1037 },
1038 }
1039
1040 return app
1041}