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}