main cli.go
Eric Bower  ·  2026-02-25
  1package patchbin
  2
  3import (
  4	"bytes"
  5	"fmt"
  6	"io"
  7	"strconv"
  8	"strings"
  9	"text/tabwriter"
 10	"time"
 11
 12	"github.com/picosh/pico/pkg/pssh"
 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
 25// readStdinLimited reads all of stdin, rejecting input over maxBytes rather
 26// than silently truncating it.
 27func readStdinLimited(r io.Reader, maxBytes int64) ([]byte, error) {
 28	limited := io.LimitReader(r, maxBytes+1)
 29	body, err := io.ReadAll(limited)
 30	if err != nil {
 31		return nil, err
 32	}
 33	if int64(len(body)) > maxBytes {
 34		return nil, fmt.Errorf("stdin exceeds max size of %d bytes", maxBytes)
 35	}
 36	return body, nil
 37}
 38
 39func getPatchsetFromOpt(patchsets []*Patchset, optPatchsetID string) (*Patchset, error) {
 40	if optPatchsetID == "" {
 41		return patchsets[len(patchsets)-1], nil
 42	}
 43
 44	id, err := getPatchsetID(optPatchsetID)
 45	if err != nil {
 46		return nil, err
 47	}
 48
 49	for _, ps := range patchsets {
 50		if ps.ID == id {
 51			return ps, nil
 52		}
 53	}
 54
 55	return nil, fmt.Errorf("cannot find patchset: %s", optPatchsetID)
 56}
 57
 58func prSummary(be *Backend, pr GitPatchRequest, sesh *pssh.SSHServerConnSession, prID int64) error {
 59	request, err := pr.GetPatchRequestByID(prID)
 60	if err != nil {
 61		return err
 62	}
 63
 64	sesh.Printf("Info\n====\n")
 65	sesh.Printf("URL: https://%s/prs/%d\n", be.Cfg.Url, prID)
 66	sesh.Printf("Repo: %s\n\n", request.RepoName)
 67
 68	writer := NewTabWriter(sesh)
 69	_, _ = fmt.Fprintln(writer, "ID\tName\tStatus\tDate")
 70	_, _ = fmt.Fprintf(
 71		writer,
 72		"%d\t%s\t[%s]\t%s\n",
 73		request.ID, request.Name, request.Status, request.CreatedAt.Format(be.Cfg.TimeFormat),
 74	)
 75	_ = writer.Flush()
 76
 77	patchsets, err := pr.GetPatchsetsByPrID(prID)
 78	if err != nil {
 79		return err
 80	}
 81
 82	sesh.Printf("\nPatchsets\n====\n")
 83
 84	writerSet := NewTabWriter(sesh)
 85	_, _ = fmt.Fprintln(writerSet, "ID\tUser\tDate")
 86	for _, patchset := range patchsets {
 87		user, err := pr.GetUserByID(patchset.UserID)
 88		if err != nil {
 89			be.Logger.Error("cannot find user for patchset", "err", err)
 90			continue
 91		}
 92		displayName := be.ComputeUserName(user.Pubkey)
 93
 94		_, _ = fmt.Fprintf(
 95			writerSet,
 96			"%s\t%s\t%s\n",
 97			getFormattedPatchsetID(patchset.ID),
 98			displayName,
 99			patchset.CreatedAt.Format(be.Cfg.TimeFormat),
100		)
101	}
102	_ = writerSet.Flush()
103
104	latest, err := getPatchsetFromOpt(patchsets, "")
105	if err != nil {
106		return err
107	}
108
109	patches, err := pr.GetPatchesByPatchsetID(latest.ID)
110	if err != nil {
111		return err
112	}
113
114	sesh.Printf("\nPatches from latest patchset\n====\n")
115
116	opatches := patches
117	w := NewTabWriter(sesh)
118	_, _ = fmt.Fprintln(w, "Idx\tTitle\tCommit\tAuthor\tDate")
119	for idx, patch := range opatches {
120		timestamp := patch.AuthorDate.Format(be.Cfg.TimeFormat)
121		_, _ = fmt.Fprintf(
122			w,
123			"%d\t%s\t%s\t%s <%s>\t%s\n",
124			idx,
125			patch.Title,
126			truncateSha(patch.CommitSha),
127			patch.AuthorName,
128			patch.AuthorEmail,
129			timestamp,
130		)
131	}
132	_ = w.Flush()
133	return nil
134}
135
136// printCoverLetterFromPrID prints patches with a cover letter and discussion.
137func printCoverLetterFromPrID(sesh *pssh.SSHServerConnSession, be *Backend, gpr GitPatchRequest, prID int64) error {
138	pr, err := gpr.GetPatchRequestByID(prID)
139	if err != nil {
140		return err
141	}
142
143	patchsets, err := gpr.GetPatchsetsByPrID(prID)
144	if err != nil {
145		return err
146	}
147	ps := patchsets[len(patchsets)-1]
148
149	patches, err := gpr.GetPatchesByPatchsetID(ps.ID)
150	if err != nil {
151		return err
152	}
153
154	events, err := gpr.GetEventLogsByPrID(prID)
155	if err != nil {
156		return err
157	}
158
159	users := resolveUsers(gpr, events)
160
161	mbox := GenerateMboxWithCoverLetter(pr, patches, events, users, be.Cfg.Url)
162	sesh.Println(mbox)
163	return nil
164}
165
166// printCoverLetterFromPsID prints patches with a cover letter and discussion.
167func printCoverLetterFromPsID(sesh *pssh.SSHServerConnSession, be *Backend, gpr GitPatchRequest, psID int64) error {
168	ps, err := gpr.GetPatchsetByID(psID)
169	if err != nil {
170		return err
171	}
172
173	pr, err := gpr.GetPatchRequestByID(ps.PatchRequestID)
174	if err != nil {
175		return err
176	}
177
178	patches, err := gpr.GetPatchesByPatchsetID(ps.ID)
179	if err != nil {
180		return err
181	}
182
183	events, err := gpr.GetEventLogsByPrID(ps.PatchRequestID)
184	if err != nil {
185		return err
186	}
187
188	users := resolveUsers(gpr, events)
189
190	mbox := GenerateMboxWithCoverLetter(pr, patches, events, users, be.Cfg.Url)
191	sesh.Println(mbox)
192	return nil
193}
194
195// resolveUsers loads user records for all user IDs referenced in events.
196func resolveUsers(gpr GitPatchRequest, events []*EventLog) map[int64]*User {
197	users := make(map[int64]*User)
198	for _, event := range events {
199		if _, ok := users[event.UserID]; !ok {
200			user, err := gpr.GetUserByID(event.UserID)
201			if err == nil {
202				users[event.UserID] = user
203			}
204		}
205	}
206	return users
207}
208
209func NewCli(sesh *pssh.SSHServerConnSession, be *Backend, pr GitPatchRequest) *cli.App {
210	url := be.Cfg.Url
211	desc := fmt.Sprintf(`patchbin (v%s): a pastebin for patches, supercharged for git collaboration.
212
213Contributions are anonymous: connect with an SSH key, no signup. A patch
214request works like a pull request, except both sides collaborate by
215sending rounds of patchsets -- as commits, not comments -- back and forth
216on top of each other. Reviewing means pulling the code down, not clicking
217through a diff viewer. An issue is just a patch request without any code
218attached yet, so anyone can follow up with a real patch request on top of it.
219
220There's no accept/reject step. A PR is either draft (visible only to you)
221or open (visible to everyone, appears in RSS). It goes inactive after 30
222days without activity; a reviewer who's happy just pulls it, merges it, and
223pushes upstream themselves.
224
225COMMANDS
226
227pr - manage patch requests
228
229  pr create {repo}
230    Submit a new PR from stdin (starts as draft).
231    git format-patch main --stdout | ssh %[2]s pr create {repo}
232
233  pr add {prID}
234    Add a new patchset to an existing PR from stdin.
235    git format-patch main --stdout | ssh %[2]s pr add {prID}
236
237  pr open {prID} [--comment]
238    Transition draft -> open, enables RSS notifications.
239    ssh %[2]s pr open {prID}
240
241  pr draft {prID} [--comment]
242    Transition open -> draft, disables RSS notifications.
243    ssh %[2]s pr draft {prID}
244
245  pr edit {prID} {title}
246    Rename a PR.
247    ssh %[2]s pr edit {prID} "new title"
248
249  pr summary {prID}
250    Show metadata, patchsets, and patches for a PR.
251    ssh %[2]s pr summary {prID}
252
253  pr ls [repo] [--draft|--open|--active|--inactive|--mine]
254    List PRs.
255    ssh %[2]s pr ls {repo} --open
256
257issue - text-only patch requests (no code required)
258
259  issue create {repo} [--title]
260    Submit a new issue from stdin (starts as open).
261    echo "steps to reproduce..." | ssh %[2]s issue create {repo} --title "bug: crash on startup"
262
263ps - manage patchsets
264
265  ps rm {patchsetID}
266    Remove a patchset and its patches (creator only).
267    ssh %[2]s ps rm ps-{patchsetID}
268
269print - print patches for checkout
270
271  print pr-{prID}
272    Print the latest patchset for a PR.
273    ssh %[2]s print pr-{prID} | git am -3
274
275  print ps-{patchsetID}
276    Print a specific patchset.
277    ssh %[2]s print ps-{patchsetID} | git am -3
278
279  Cover letters are stored as an empty commit. If you want to keep them
280  when applying, use "git am --keep-empty" (or set it globally with
281  "git config --global am.keepEmpty true").
282
283logs - event history
284
285  logs [--pr ID] [--pubkey]
286    List event logs, optionally filtered to a PR or your own activity.
287    ssh %[2]s logs --pr {prID}
288
289STDIN
290
291  pr create, pr add        expect the output of "git format-patch --stdout"
292  issue create              expects free-form text (the issue body)
293  pr open/draft --comment  expects free-form text (a comment to attach to the status change)
294
295GUARDS
296
297  To limit abuse, submissions (pr create, pr add, issue create) are capped
298  at %[3]d bytes of stdin, and globally rate limited to %[4]d submissions
299  per %[5]s across all users. Contact an admin if you hit these limits.
300
301  Admins with shell access to the host can ban a pubkey or IP address by
302  inserting a row directly into the "acl" table of the sqlite database:
303
304    sqlite3 data/pr.db "INSERT INTO acl (pubkey, permission) VALUES ('{pubkey}', 'banned')"
305    sqlite3 data/pr.db "INSERT INTO acl (ip_address, permission) VALUES ('{ip}', 'banned')"
306
307  Banned pubkeys/IPs are rejected at SSH auth time. There is currently no
308  SSH command for this; it requires direct database access.
309
310Self-host your own patchbin: https://github.com/picosh/patchbin
311`, GITPR_VERSION, url, be.Cfg.MaxStdinBytes, be.Cfg.RateLimitCount, be.Cfg.RateLimitInterval)
312
313	pubkey := be.Pubkey(sesh.PublicKey())
314	app := &cli.App{
315		Name:                  "ssh",
316		Description:           desc,
317		Usage:                 "A pastebin for patches, supercharged for git collaboration",
318		CustomAppHelpTemplate: "{{.Description}}\n",
319		Writer:                sesh,
320		ErrWriter:             sesh,
321		ExitErrHandler: func(cCtx *cli.Context, err error) {
322			if err != nil {
323				sesh.Fatal(fmt.Errorf("err: %w", err))
324			}
325		},
326		OnUsageError: func(cCtx *cli.Context, err error, isSubcommand bool) error {
327			if err != nil {
328				sesh.Fatal(fmt.Errorf("err: %w", err))
329			}
330			return nil
331		},
332		Commands: []*cli.Command{
333			{
334				Name:  "issue",
335				Usage: "Manage issues (text-only patch requests)",
336				Subcommands: []*cli.Command{
337					{
338						Name:      "create",
339						Usage:     "Submit a new issue (starts as open)",
340						Args:      true,
341						ArgsUsage: "repoName",
342						Flags: []cli.Flag{
343							&cli.StringFlag{
344								Name:  "title",
345								Usage: "issue title (default: first line of stdin)",
346							},
347						},
348						Action: func(cCtx *cli.Context) error {
349							if !be.Limiter.Allow() {
350								return be.Limiter.Error()
351							}
352
353							user, err := pr.UpsertUserByPubkey(pubkey)
354							if err != nil {
355								return err
356							}
357
358							args := cCtx.Args()
359							if !args.Present() {
360								return fmt.Errorf("must provide a repo name")
361							}
362							repoName := args.First()
363
364							body, err := readStdinLimited(sesh, be.Cfg.MaxStdinBytes)
365							if err != nil {
366								return fmt.Errorf("failed to read issue body from stdin: %w", err)
367							}
368							bodyStr := strings.TrimSpace(string(body))
369							if bodyStr == "" {
370								return fmt.Errorf("must provide issue body via stdin")
371							}
372
373							title := cCtx.String("title")
374							if title == "" {
375								// Use first line as title
376								lines := strings.SplitN(bodyStr, "\n", 2)
377								title = lines[0]
378								if len(lines) > 1 {
379									bodyStr = strings.TrimSpace(lines[1])
380								} else {
381									bodyStr = ""
382								}
383							}
384
385							prq, err := pr.SubmitIssue(user.ID, pubkey, repoName, title, bodyStr)
386							if err != nil {
387								return err
388							}
389
390							sesh.Printf("Issue created! #%d\n", prq.ID)
391							return prSummary(be, pr, sesh, prq.ID)
392						},
393					},
394				},
395			},
396			{
397				Name:  "logs",
398				Usage: "List event logs with filters",
399				Args:  true,
400				Flags: []cli.Flag{
401					&cli.Int64Flag{
402						Name:  "pr",
403						Usage: "show all events related to the provided patch request",
404					},
405					&cli.BoolFlag{
406						Name:  "pubkey",
407						Usage: "show all events related to your pubkey",
408					},
409				},
410				Action: func(cCtx *cli.Context) error {
411					user, err := pr.UpsertUserByPubkey(pubkey)
412					if err != nil {
413						return err
414					}
415					isPubkey := cCtx.Bool("pubkey")
416					prID := cCtx.Int64("pr")
417					var eventLogs []*EventLog
418					if isPubkey {
419						eventLogs, err = pr.GetEventLogsByUserID(user.ID)
420					} else if prID != 0 {
421						eventLogs, err = pr.GetEventLogsByPrID(prID)
422					} else {
423						eventLogs, err = pr.GetEventLogs()
424					}
425					if err != nil {
426						return err
427					}
428
429					writer := NewTabWriter(sesh)
430					_, _ = fmt.Fprintln(writer, "PrID\tPatchsetID\tEvent\tCreated\tData")
431					for _, eventLog := range eventLogs {
432						_, _ = fmt.Fprintf(
433							writer,
434							"%d\t%s\t%s\t%s\t%s\n",
435							eventLog.PatchRequestID.Int64,
436							getFormattedPatchsetID(eventLog.PatchsetID.Int64),
437							eventLog.Event,
438							eventLog.CreatedAt.Format(be.Cfg.TimeFormat),
439							eventLog.Data,
440						)
441					}
442					_ = writer.Flush()
443					return nil
444				},
445			},
446			{
447				Name:  "ps",
448				Usage: "Manage patchsets",
449				Subcommands: []*cli.Command{
450					{
451						Name:      "rm",
452						Usage:     "Remove a patchset and its patches",
453						Args:      true,
454						ArgsUsage: "[patchsetID]",
455						Action: func(cCtx *cli.Context) error {
456							args := cCtx.Args()
457							if !args.Present() {
458								return fmt.Errorf("must provide a patchset ID")
459							}
460
461							patchsetID, err := getPatchsetID(args.First())
462							if err != nil {
463								return err
464							}
465
466							patchset, err := pr.GetPatchsetByID(patchsetID)
467							if err != nil {
468								return err
469							}
470
471							user, err := pr.GetUserByID(patchset.UserID)
472							if err != nil {
473								return err
474							}
475
476							if pubkey != user.Pubkey {
477								return fmt.Errorf("you are not authorized to delete this patchset (only the creator can delete)")
478							}
479
480							err = pr.DeletePatchsetByID(user.ID, patchset.PatchRequestID, patchsetID)
481							if err != nil {
482								return err
483							}
484							sesh.Printf("successfully removed patchset: %d\n", patchsetID)
485							return nil
486						},
487					},
488				},
489			},
490			{
491				Name:      "print",
492				Usage:     "Print patches in a patchset",
493				Args:      true,
494				ArgsUsage: "[pr-X] or [ps-X]",
495				Action: func(cCtx *cli.Context) error {
496					args := cCtx.Args()
497					raw := args.First()
498					split := strings.Split(raw, "-")
499					if len(split) < 2 {
500						return fmt.Errorf("must provide ID in format: pr-X, ps-X")
501					}
502
503					prefix := split[0]
504					id, err := strToInt(split[1])
505					if err != nil {
506						return err
507					}
508
509					switch prefix {
510					case "pr":
511						err = printCoverLetterFromPrID(sesh, be, pr, id)
512					case "ps":
513						err = printCoverLetterFromPsID(sesh, be, pr, id)
514					default:
515						return fmt.Errorf("unknown prefix %q, must be one of: pr, ps", prefix)
516					}
517
518					return err
519				},
520			},
521			{
522				Name:  "pr",
523				Usage: "Manage patch requests (PR)",
524				Subcommands: []*cli.Command{
525					{
526						Name:      "ls",
527						Usage:     "List all PRs",
528						Args:      true,
529						ArgsUsage: "[repoName]",
530						Flags: []cli.Flag{
531							&cli.BoolFlag{
532								Name:  "draft",
533								Usage: "only show draft PRs",
534							},
535							&cli.BoolFlag{
536								Name:  "open",
537								Usage: "only show open PRs",
538							},
539							&cli.BoolFlag{
540								Name:  "active",
541								Usage: "only show active PRs (activity in last 30 days)",
542							},
543							&cli.BoolFlag{
544								Name:  "inactive",
545								Usage: "only show inactive PRs (no activity in 30 days)",
546							},
547							&cli.BoolFlag{
548								Name:  "mine",
549								Usage: "only show your own PRs",
550							},
551						},
552						Action: func(cCtx *cli.Context) error {
553							args := cCtx.Args()
554							repoName := args.First()
555							var prs []*PatchRequest
556							var err error
557							if repoName == "" {
558								prs, err = pr.GetPatchRequests()
559								if err != nil {
560									return err
561								}
562							} else {
563								prs, err = pr.GetPatchRequestsByRepoName(repoName)
564								if err != nil {
565									return err
566								}
567							}
568
569							onlyDraft := cCtx.Bool("draft")
570							onlyOpen := cCtx.Bool("open")
571							onlyActive := cCtx.Bool("active")
572							onlyInactive := cCtx.Bool("inactive")
573							onlyMine := cCtx.Bool("mine")
574							cutoff := time.Now().AddDate(0, 0, -30)
575
576							writer := NewTabWriter(sesh)
577							_, _ = fmt.Fprintln(writer, "ID\tRepo\tName\tStatus\tPatchsets\tUser\tLast Activity")
578							for _, req := range prs {
579								if onlyDraft && req.Status != StatusDraft {
580									continue
581								}
582
583								if onlyOpen && req.Status != StatusOpen {
584									continue
585								}
586
587								if onlyActive && req.LastActivity.Before(cutoff) {
588									continue
589								}
590
591								if onlyInactive && req.LastActivity.After(cutoff) {
592									continue
593								}
594
595								user, err := pr.GetUserByID(req.UserID)
596								if err != nil {
597									be.Logger.Error("could not get user for pr", "err", err)
598									continue
599								}
600
601								if onlyMine && user.Pubkey != pubkey {
602									continue
603								}
604
605								patchsets, err := pr.GetPatchsetsByPrID(req.ID)
606								if err != nil {
607									be.Logger.Error("could not get patchsets for pr", "err", err)
608									continue
609								}
610
611								displayName := be.ComputeUserName(user.Pubkey)
612
613								_, _ = fmt.Fprintf(
614									writer,
615									"%d\t%s\t%s\t[%s]\t%d\t%s\t%s\n",
616									req.ID,
617									req.RepoName,
618									req.Name,
619									req.Status,
620									len(patchsets),
621									displayName,
622									req.LastActivity.Format(be.Cfg.TimeFormat),
623								)
624							}
625							_ = writer.Flush()
626							return nil
627						},
628					},
629					{
630						Name:      "create",
631						Usage:     "Submit a new PR (starts as draft)",
632						Args:      true,
633						ArgsUsage: "repoName",
634						Action: func(cCtx *cli.Context) error {
635							if !be.Limiter.Allow() {
636								return be.Limiter.Error()
637							}
638
639							user, err := pr.UpsertUserByPubkey(pubkey)
640							if err != nil {
641								return err
642							}
643
644							args := cCtx.Args()
645							if !args.Present() {
646								return fmt.Errorf("must provide a repo name")
647							}
648							repoName := args.First()
649
650							body, err := readStdinLimited(sesh, be.Cfg.MaxStdinBytes)
651							if err != nil {
652								return fmt.Errorf("failed to read patchset from stdin: %w", err)
653							}
654
655							prq, err := pr.SubmitPatchRequest(user.ID, pubkey, repoName, bytes.NewReader(body))
656							if err != nil {
657								return err
658							}
659							sesh.Println(
660								"PR submitted as draft! Use `pr open <id>` to make it visible.",
661							)
662
663							return prSummary(be, pr, sesh, prq.ID)
664						},
665					},
666					{
667						Name:      "open",
668						Usage:     "Transition PR to open (enable RSS notifications)",
669						Args:      true,
670						ArgsUsage: "[prID]",
671						Flags: []cli.Flag{
672							&cli.BoolFlag{
673								Name:  "comment",
674								Usage: "If this flag is provided, pass comment through stdin",
675							},
676						},
677						Action: func(cCtx *cli.Context) error {
678							args := cCtx.Args()
679							if !args.Present() {
680								return fmt.Errorf("must provide a patch request ID")
681							}
682
683							prID, err := strToInt(args.First())
684							if err != nil {
685								return err
686							}
687
688							prq, err := pr.GetPatchRequestByID(prID)
689							if err != nil {
690								return err
691							}
692
693							if prq.Status == StatusOpen {
694								return fmt.Errorf("PR is already open")
695							}
696
697							comment := cCtx.Bool("comment")
698							var commentTxt []byte
699							if comment {
700								commentTxt, err = io.ReadAll(sesh)
701								if err != nil {
702									return fmt.Errorf("when comment flag enabled must provide it from stdin")
703								}
704							}
705
706							err = pr.UpdatePatchRequestStatus(prID, pubkey, StatusOpen, string(commentTxt))
707							if err != nil {
708								return err
709							}
710							sesh.Printf("Opened PR %s (#%d)\n", prq.Name, prq.ID)
711							return prSummary(be, pr, sesh, prID)
712						},
713					},
714					{
715						Name:      "draft",
716						Usage:     "Transition PR to draft (disable RSS notifications)",
717						Args:      true,
718						ArgsUsage: "[prID]",
719						Flags: []cli.Flag{
720							&cli.BoolFlag{
721								Name:  "comment",
722								Usage: "If this flag is provided, pass comment through stdin",
723							},
724						},
725						Action: func(cCtx *cli.Context) error {
726							args := cCtx.Args()
727							if !args.Present() {
728								return fmt.Errorf("must provide a patch request ID")
729							}
730
731							prID, err := strToInt(args.First())
732							if err != nil {
733								return err
734							}
735
736							prq, err := pr.GetPatchRequestByID(prID)
737							if err != nil {
738								return err
739							}
740
741							if prq.Status == StatusDraft {
742								return fmt.Errorf("PR is already a draft")
743							}
744
745							comment := cCtx.Bool("comment")
746							var commentTxt []byte
747							if comment {
748								commentTxt, err = io.ReadAll(sesh)
749								if err != nil {
750									return fmt.Errorf("when comment flag enabled must provide it from stdin")
751								}
752							}
753
754							err = pr.UpdatePatchRequestStatus(prID, pubkey, StatusDraft, string(commentTxt))
755							if err != nil {
756								return err
757							}
758							sesh.Printf("Drafted PR %s (#%d)\n", prq.Name, prq.ID)
759							return prSummary(be, pr, sesh, prID)
760						},
761					},
762					{
763						Name:      "summary",
764						Usage:     "Show metadata, patchsets, and patches for a PR",
765						Args:      true,
766						ArgsUsage: "[prID]",
767						Action: func(cCtx *cli.Context) error {
768							args := cCtx.Args()
769							if !args.Present() {
770								return fmt.Errorf("must provide a patch request ID")
771							}
772
773							prID, err := strToInt(args.First())
774							if err != nil {
775								return err
776							}
777							return prSummary(be, pr, sesh, prID)
778						},
779					},
780					{
781						Name:      "edit",
782						Usage:     "Edit a PR's title",
783						Args:      true,
784						ArgsUsage: "[prID] [title]",
785						Action: func(cCtx *cli.Context) error {
786							args := cCtx.Args()
787							if !args.Present() {
788								return fmt.Errorf("must provide a patch request ID")
789							}
790
791							prID, err := strToInt(args.First())
792							if err != nil {
793								return err
794							}
795							prq, err := pr.GetPatchRequestByID(prID)
796							if err != nil {
797								return err
798							}
799
800							tail := cCtx.Args().Tail()
801							title := strings.Join(tail, " ")
802							if title == "" {
803								return fmt.Errorf("must provide title")
804							}
805
806							err = pr.UpdatePatchRequestName(prID, pubkey, title)
807							if err != nil {
808								return err
809							}
810							sesh.Printf("New title: %s (%d)\n", title, prq.ID)
811
812							return err
813						},
814					},
815					{
816						Name:      "add",
817						Usage:     "Add a new patchset to a PR",
818						Args:      true,
819						ArgsUsage: "[prID]",
820						Action: func(cCtx *cli.Context) error {
821							if !be.Limiter.Allow() {
822								return be.Limiter.Error()
823							}
824
825							args := cCtx.Args()
826							if !args.Present() {
827								return fmt.Errorf("must provide a patch request ID")
828							}
829
830							prID, err := strToInt(args.First())
831							if err != nil {
832								return err
833							}
834							_, err = pr.GetPatchRequestByID(prID)
835							if err != nil {
836								return err
837							}
838
839							user, err := pr.UpsertUserByPubkey(pubkey)
840							if err != nil {
841								return err
842							}
843
844							body, err := readStdinLimited(sesh, be.Cfg.MaxStdinBytes)
845							if err != nil {
846								return fmt.Errorf("failed to read patchset from stdin: %w", err)
847							}
848
849							patches, err := pr.SubmitPatchset(prID, user.ID, OpNormal, bytes.NewReader(body))
850							if err != nil {
851								return err
852							}
853
854							if len(patches) == 0 {
855								sesh.Println("Patches submitted! However none were saved, probably because they already exist in the system")
856								return nil
857							}
858
859							sesh.Println("Patches submitted!")
860							return prSummary(be, pr, sesh, prID)
861						},
862					},
863				},
864			},
865		},
866	}
867
868	return app
869}