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