repos / git-pr

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

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}