repos / git-pr

a self-hosted git collaboration server
git clone https://github.com/picosh/git-pr.git

Eric Bower  ·  2025-03-28

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 != "accepted" {
470									continue
471								}
472
473								if onlyClosed && req.Status != "closed" {
474									continue
475								}
476
477								if onlyOpen && req.Status != "open" {
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						Action: func(cCtx *cli.Context) error {
607							args := cCtx.Args()
608							if !args.Present() {
609								return fmt.Errorf("must provide at least one patch request ID")
610							}
611
612							prIDs := args.Tail()
613							prIDs = append(prIDs, args.First())
614
615							var errs error
616							for _, prIDStr := range prIDs {
617								prID, err := strToInt(prIDStr)
618								if err != nil {
619									wish.Errorln(sesh, err)
620									continue
621								}
622
623								prq, err := pr.GetPatchRequestByID(prID)
624								if err != nil {
625									return err
626								}
627
628								user, err := pr.UpsertUser(pubkey, userName)
629								if err != nil {
630									return err
631								}
632
633								repo, err := pr.GetRepoByID(prq.RepoID)
634								if err != nil {
635									return err
636								}
637
638								acl := be.GetPatchRequestAcl(repo, prq, user)
639								if !acl.CanReview {
640									return fmt.Errorf("you are not authorized to accept a PR")
641								}
642
643								if prq.Status == "accepted" {
644									return fmt.Errorf("PR has already been accepted")
645								}
646
647								err = pr.UpdatePatchRequestStatus(prID, user.ID, "accepted")
648								if err != nil {
649									return err
650								}
651								wish.Printf(sesh, "Accepted PR %s (#%d)\n", prq.Name, prq.ID)
652								err = prSummary(be, pr, sesh, prID)
653								if err != nil {
654									errs = errors.Join(errs, err)
655								}
656								wish.Printf(sesh, "\n\n")
657							}
658
659							return errs
660						},
661					},
662					{
663						Name:      "close",
664						Usage:     "Close a PR",
665						Args:      true,
666						ArgsUsage: "[prID], [prID]...",
667						Action: func(cCtx *cli.Context) error {
668							args := cCtx.Args()
669							if !args.Present() {
670								return fmt.Errorf("must provide a patch request ID")
671							}
672
673							prIDs := args.Tail()
674							prIDs = append(prIDs, args.First())
675
676							var errs error
677							for _, prIDStr := range prIDs {
678								prID, err := strToInt(prIDStr)
679								if err != nil {
680									wish.Errorln(sesh, err)
681									continue
682								}
683
684								prq, err := pr.GetPatchRequestByID(prID)
685								if err != nil {
686									return err
687								}
688
689								patchUser, err := pr.GetUserByID(prq.UserID)
690								if err != nil {
691									return err
692								}
693
694								repo, err := pr.GetRepoByID(prq.RepoID)
695								if err != nil {
696									return err
697								}
698
699								acl := be.GetPatchRequestAcl(repo, prq, patchUser)
700								if !acl.CanModify {
701									return fmt.Errorf("you are not authorized to change PR status")
702								}
703
704								if prq.Status == "closed" {
705									return fmt.Errorf("PR has already been closed")
706								}
707
708								user, err := pr.UpsertUser(pubkey, userName)
709								if err != nil {
710									return err
711								}
712
713								err = pr.UpdatePatchRequestStatus(prID, user.ID, "closed")
714								if err != nil {
715									return err
716								}
717								wish.Printf(sesh, "Closed PR %s (#%d)\n", prq.Name, prq.ID)
718								err = prSummary(be, pr, sesh, prID)
719								if err != nil {
720									errs = errors.Join(errs, err)
721								}
722								wish.Printf(sesh, "\n\n")
723							}
724							return errs
725						},
726					},
727					{
728						Name:      "reopen",
729						Usage:     "Reopen a PR",
730						Args:      true,
731						ArgsUsage: "[prID]",
732						Action: func(cCtx *cli.Context) error {
733							args := cCtx.Args()
734							if !args.Present() {
735								return fmt.Errorf("must provide a patch request ID")
736							}
737
738							prID, err := strToInt(args.First())
739							if err != nil {
740								return err
741							}
742
743							prq, err := pr.GetPatchRequestByID(prID)
744							if err != nil {
745								return err
746							}
747
748							patchUser, err := pr.GetUserByID(prq.UserID)
749							if err != nil {
750								return err
751							}
752
753							repo, err := pr.GetRepoByID(prq.RepoID)
754							if err != nil {
755								return err
756							}
757
758							acl := be.GetPatchRequestAcl(repo, prq, patchUser)
759							if !acl.CanModify {
760								return fmt.Errorf("you are not authorized to change PR status")
761							}
762
763							if prq.Status == "open" {
764								return fmt.Errorf("PR is already open")
765							}
766
767							user, err := pr.UpsertUser(pubkey, userName)
768							if err != nil {
769								return err
770							}
771
772							err = pr.UpdatePatchRequestStatus(prID, user.ID, "open")
773							if err == nil {
774								wish.Printf(sesh, "Reopened PR %s (#%d)\n", prq.Name, prq.ID)
775							}
776							return prSummary(be, pr, sesh, prID)
777						},
778					},
779					{
780						Name:      "edit",
781						Usage:     "Edit PR title",
782						Args:      true,
783						ArgsUsage: "[prID] [title]",
784						Action: func(cCtx *cli.Context) error {
785							args := cCtx.Args()
786							if !args.Present() {
787								return fmt.Errorf("must provide a patch request ID")
788							}
789
790							prID, err := strToInt(args.First())
791							if err != nil {
792								return err
793							}
794							prq, err := pr.GetPatchRequestByID(prID)
795							if err != nil {
796								return err
797							}
798
799							user, err := pr.UpsertUser(pubkey, userName)
800							if err != nil {
801								return err
802							}
803
804							repo, err := pr.GetRepoByID(prq.RepoID)
805							if err != nil {
806								return err
807							}
808
809							acl := be.GetPatchRequestAcl(repo, prq, user)
810							if !acl.CanModify {
811								return fmt.Errorf("you are not authorized to change PR")
812							}
813
814							tail := cCtx.Args().Tail()
815							title := strings.Join(tail, " ")
816							if title == "" {
817								return fmt.Errorf("must provide title")
818							}
819
820							err = pr.UpdatePatchRequestName(
821								prID,
822								user.ID,
823								title,
824							)
825							if err == nil {
826								wish.Printf(sesh, "New title: %s (%d)\n", title, prq.ID)
827							}
828
829							return err
830						},
831					},
832					{
833						Name:      "add",
834						Usage:     "Add a new patchset to a PR",
835						Args:      true,
836						ArgsUsage: "[prID]",
837						Flags: []cli.Flag{
838							&cli.BoolFlag{
839								Name:  "review",
840								Usage: "submit patchset mark it as a review",
841							},
842							&cli.BoolFlag{
843								Name:  "accept",
844								Usage: "submit patchset and mark PR as accepted",
845							},
846							&cli.BoolFlag{
847								Name:  "close",
848								Usage: "submit patchset and mark PR as closed",
849							},
850						},
851						Action: func(cCtx *cli.Context) error {
852							args := cCtx.Args()
853							if !args.Present() {
854								return fmt.Errorf("must provide a patch request ID")
855							}
856
857							prID, err := strToInt(args.First())
858							if err != nil {
859								return err
860							}
861							prq, err := pr.GetPatchRequestByID(prID)
862							if err != nil {
863								return err
864							}
865
866							user, err := pr.UpsertUser(pubkey, userName)
867							if err != nil {
868								return err
869							}
870
871							isReview := cCtx.Bool("review")
872							isAccept := cCtx.Bool("accept")
873							isClose := cCtx.Bool("close")
874
875							repo, err := pr.GetRepoByID(prq.RepoID)
876							if err != nil {
877								return err
878							}
879
880							acl := be.GetPatchRequestAcl(repo, prq, user)
881							if !acl.CanAddPatchset {
882								return fmt.Errorf("you are not authorized to add patchsets to pr")
883							}
884
885							if isReview && !acl.CanReview {
886								return fmt.Errorf("you are not authorized to submit a review to pr")
887							}
888
889							op := OpNormal
890							nextStatus := "open"
891							if isReview {
892								wish.Println(sesh, "Marking patchset as a review")
893								op = OpReview
894							} else if isAccept {
895								wish.Println(sesh, "Marking PR as accepted")
896								nextStatus = "accepted"
897								op = OpAccept
898							} else if isClose {
899								wish.Println(sesh, "Marking PR as closed")
900								nextStatus = "closed"
901								op = OpClose
902							}
903
904							patches, err := pr.SubmitPatchset(prID, user.ID, op, sesh)
905							if err != nil {
906								return err
907							}
908
909							if len(patches) == 0 {
910								wish.Println(sesh, "Patches submitted! However none were saved, probably because they already exist in the system")
911								return nil
912							}
913
914							if prq.Status != nextStatus {
915								err = pr.UpdatePatchRequestStatus(prID, user.ID, nextStatus)
916								if err != nil {
917									return err
918								}
919							}
920
921							wish.Println(sesh, "Patches submitted!")
922							return prSummary(be, pr, sesh, prID)
923						},
924					},
925				},
926			},
927		},
928	}
929
930	return app
931}