repos / git-pr

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

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}