patchbin

created pr with patchbin/143.1 on 2026-10-01T23:54:37Z · by c8ef7d19
cmds
checkout latest patchset:
ssh pr.pico.sh pull patchbin:143 | git am -3
checkout specific patchset revision:
ssh pr.pico.sh pull patchbin:143 [rev] | git am -3
add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh patchbin:143
add comment to patch request:
ssh pr.pico.sh comment patchbin:143 "lgtm!"

Patchset patchbin/143.1 on 2026-10-01T23:54:37Z · commit 4f69d3a

feat: pr comments
Eric Bower 2026-10-01T15:55:14Z
We now support the ability to send ad-hoc pr comments for quick +1 or small
comments on an issue or pr.

`echo "lgtm" | ssh pr pr comment {prID}`
Semantic diff summary
2 added, 8 modified, 0 signature changed, 0 removed across 4 analyzed files (4 files skipped: unsupported file type)
+6 -0 CHANGELOG.md #
......@@ -2,6 +2,12 @@
22
33 Use spec: https://common-changelog.org/
44
5+## Staged
6+
7+### Added
8+
9+- `pr comment {prID}` command to add comments to a PR from stdin (renders in web timeline, bumps PR activity, and appears in cover letter discussion)
10+
511 ## v2026-08-31
612
713 ### Changed
+4 -0 README.md #
......@@ -50,6 +50,10 @@ ssh {url} help
5050 ```
5151 ssh {url} pr edit {prID} "new title"
5252 ```
53+- `pr comment {prID}` - add a comment to a PR from stdin
54+ ```
55+ echo "looks good to me!" | ssh {url} pr comment {prID}
56+ ```
5357 - `pr summary {prID}` - show metadata, patchsets, and patches for a PR
5458 ```
5559 ssh {url} pr summary {prID}
+53 -2 cli.go #
......@@ -227,6 +227,10 @@ pr - manage patch requests
227227 Rename a PR.
228228 ssh %[2]s pr edit {prID} "new title"
229229
230+ pr comment {prID}
231+ Add a comment to a PR from stdin.
232+ echo "lgtm!" | ssh %[2]s pr comment {prID}
233+
230234 pr summary {prID}
231235 Show metadata, patchsets, and patches for a PR.
232236 ssh %[2]s pr summary {prID}
......@@ -270,11 +274,11 @@ logs - event history
270274 STDIN
271275
272276 pr create, pr add expect the output of "git format-patch --stdout"
273- issue create expects free-form text (the issue body)
277+ issue create, pr comment expect free-form text
274278
275279 GUARDS
276280
277- To limit abuse, submissions (pr create, pr add, issue create) are capped
281+ To limit abuse, submissions (pr create, pr add, issue create, pr comment) are capped
278282 at %[3]d bytes of stdin, and globally rate limited to %[4]d submissions
279283 per %[5]s across all users. Contact an admin if you hit these limits.
280284
......@@ -756,6 +760,53 @@ Self-host your own patchbin: https://github.com/picosh/patchbin
756760 return prSummary(be, pr, sesh, prID)
757761 },
758762 },
763+ {
764+ Name: "comment",
765+ Usage: "Add a comment to a PR",
766+ Args: true,
767+ ArgsUsage: "[prID]",
768+ Action: func(cCtx *cli.Context) error {
769+ if !be.Limiter.Allow() {
770+ return be.Limiter.Error()
771+ }
772+
773+ args := cCtx.Args()
774+ if !args.Present() {
775+ return fmt.Errorf("must provide a patch request ID")
776+ }
777+
778+ prID, err := strToInt(args.First())
779+ if err != nil {
780+ return err
781+ }
782+ _, err = pr.GetPatchRequestByID(prID)
783+ if err != nil {
784+ return err
785+ }
786+
787+ user, err := pr.UpsertUserByPubkey(pubkey)
788+ if err != nil {
789+ return err
790+ }
791+
792+ body, err := readStdinLimited(sesh, be.Cfg.MaxStdinBytes)
793+ if err != nil {
794+ return fmt.Errorf("failed to read comment from stdin: %w", err)
795+ }
796+ comment := strings.TrimSpace(string(body))
797+ if comment == "" {
798+ return fmt.Errorf("must provide comment via stdin")
799+ }
800+
801+ err = pr.AddComment(prID, user.ID, comment)
802+ if err != nil {
803+ return err
804+ }
805+
806+ sesh.Printf("%s✔ Comment added to PR #%d!%s\n\n", ansiGreen, prID, ansiReset)
807+ return nil
808+ },
809+ },
759810 },
760811 },
761812 },
+17 -1 e2e_test.go #
......@@ -4,6 +4,7 @@ import (
44 "context"
55 "log/slog"
66 "os"
7+ "strings"
78 "testing"
89 "time"
910
......@@ -76,6 +77,12 @@ func testMultiTenantE2E(t *testing.T) {
7677 t.Log("Admin adds patchset to user's PR (zero-trust: anyone can add)")
7778 suite.adminKey.MustCmd(suite.otherPatch, "pr add "+userPRID)
7879
80+ t.Log("Admin comments on user's PR")
81+ commentOutput := suite.adminKey.MustCmd([]byte("LGTM! Great work on this PR.\n"), "pr comment "+userPRID)
82+ if !strings.Contains(commentOutput, "Comment added to PR #"+userPRID) {
83+ t.Fatalf("unexpected comment output: %s", commentOutput)
84+ }
85+
7986 t.Log("User creates another PR")
8087 suite.userKey.MustCmd(suite.patch, "pr create other-repo")
8188
......@@ -83,7 +90,16 @@ func testMultiTenantE2E(t *testing.T) {
8390 suite.userKey.MustCmd(nil, "pr ls")
8491
8592 t.Log("View event logs")
86- suite.userKey.MustCmd(nil, "logs")
93+ logsOutput := suite.userKey.MustCmd(nil, "logs")
94+ if !strings.Contains(logsOutput, "pr_commented") {
95+ t.Fatalf("expected pr_commented in event logs, got: %s", logsOutput)
96+ }
97+
98+ t.Log("View PR cover letter to verify comment in discussion")
99+ printOutput := suite.userKey.MustCmd(nil, "print "+userPRID)
100+ if !strings.Contains(printOutput, "LGTM! Great work on this PR.") {
101+ t.Fatalf("expected comment in cover letter discussion, got: %s", printOutput)
102+ }
87103 }
88104
89105 type TestSuite struct {
+41 -0 pr.go #
......@@ -5,6 +5,7 @@ import (
55 "errors"
66 "fmt"
77 "io"
8+ "strings"
89 "time"
910
1011 "github.com/jmoiron/sqlx"
......@@ -39,6 +40,7 @@ type GitPatchRequest interface {
3940 UpdatePatchRequestName(prID int64, userPubkey string, name string) error
4041 DeletePatchsetByID(userID, prID int64, patchsetID int64) error
4142 SubmitIssue(userID int64, userPubkey string, repoName, title, body string) (*PatchRequest, error)
43+ AddComment(prID, userID int64, comment string) error
4244 CreateEventLog(tx *sqlx.Tx, eventLog EventLog) error
4345 GetEventLogs() ([]*EventLog, error)
4446 GetEventLogsByPrID(prID int64) ([]*EventLog, error)
......@@ -288,6 +290,45 @@ func (cmd PrCmd) UpdatePatchRequestName(prID int64, userPubkey string, name stri
288290 return cmd.updateLastActivity(prID)
289291 }
290292
293+func (cmd PrCmd) AddComment(prID, userID int64, comment string) error {
294+ comment = strings.TrimSpace(comment)
295+ if comment == "" {
296+ return fmt.Errorf("comment cannot be empty")
297+ }
298+
299+ _, err := cmd.GetPatchRequestByID(prID)
300+ if err != nil {
301+ return err
302+ }
303+
304+ tx, err := cmd.Backend.DB.Beginx()
305+ if err != nil {
306+ return err
307+ }
308+ defer func() {
309+ _ = tx.Rollback()
310+ }()
311+
312+ err = cmd.CreateEventLog(tx, EventLog{
313+ UserID: userID,
314+ PatchRequestID: sql.NullInt64{Int64: prID, Valid: true},
315+ Event: "pr_commented",
316+ Data: EventData{
317+ Comment: comment,
318+ },
319+ })
320+ if err != nil {
321+ return err
322+ }
323+
324+ err = tx.Commit()
325+ if err != nil {
326+ return err
327+ }
328+
329+ return cmd.updateLastActivity(prID)
330+}
331+
291332 func (cmd PrCmd) CreateEventLog(tx *sqlx.Tx, eventLog EventLog) error {
292333 _, err := tx.Exec(
293334 "INSERT INTO event_logs (user_id, patch_request_id, patchset_id, event, data) VALUES (?, ?, ?, ?, ?)",
+4 -1 static/patchbin.css #
......@@ -527,10 +527,13 @@ summary {
527527 background-color: var(--link-color);
528528 }
529529
530-.status-change-comment {
530+.status-change-comment,
531+.timeline-comment {
531532 background-color: var(--blockquote-bg);
532533 padding: var(--grid-height);
533534 margin-top: var(--grid-height);
535+ white-space: pre-wrap;
536+ word-break: break-word;
534537 }
535538
536539 .pill-status-accepted {
+53 -0 template_render_test.go #
......@@ -3,6 +3,7 @@ package patchbin
33 import (
44 "bytes"
55 "html/template"
6+ "strings"
67 "testing"
78 )
89
......@@ -66,3 +67,55 @@ func TestPatchFileTemplateRendersLineDiffOnly(t *testing.T) {
6667 t.Errorf("expected hunk anchor in rendered line diff")
6768 }
6869 }
70+
71+func TestPRTimelineRendersComment(t *testing.T) {
72+ tmpl := getTemplate("pr.html")
73+ if tmpl == nil {
74+ t.Fatalf("getTemplate returned nil")
75+ }
76+
77+ data := PrDetailData{
78+ Page: "pr",
79+ RepoName: "test-repo",
80+ Branch: "main",
81+ Logs: []EventLogData{
82+ {
83+ EventLog: &EventLog{
84+ Event: "pr_commented",
85+ Data: EventData{Comment: "Looks great to me!"},
86+ },
87+ UserData: UserData{
88+ Name: "alice",
89+ },
90+ Date: "2026-10-01",
91+ },
92+ },
93+ Pr: PrData{
94+ ID: 1,
95+ Title: "Test PR",
96+ },
97+ MetaData: MetaData{
98+ URL: "example.com",
99+ },
100+ Patchset: &Patchset{},
101+ Patch: &PatchData{
102+ Patch: &Patch{},
103+ },
104+ }
105+
106+ var buf bytes.Buffer
107+ if err := tmpl.Execute(&buf, data); err != nil {
108+ t.Fatalf("execute: %v", err)
109+ }
110+
111+ out := buf.String()
112+ if !strings.Contains(out, "commented") {
113+ t.Errorf("expected 'commented' in rendered timeline, got:\n%s", out)
114+ }
115+ if !strings.Contains(out, "Looks great to me!") {
116+ t.Errorf("expected comment text in rendered timeline, got:\n%s", out)
117+ }
118+ if !strings.Contains(out, "timeline-comment") {
119+ t.Errorf("expected 'timeline-comment' class in rendered timeline, got:\n%s", out)
120+ }
121+}
+12 -0 tmpl/pages/pr.html #
......@@ -46,6 +46,15 @@
4646 </div>
4747 </details>
4848
49+ {{else if eq .Event "pr_commented"}}
50+ <div>
51+ <span class="font-bold">commented</span>
52+ <span class="event-meta">on <date>{{.Date}}</date> &middot; by {{template "user-pill" .UserData}}</span>
53+ </div>
54+ {{if .Data.Comment}}
55+ <div class="timeline-comment">{{.Data.Comment}}</div>
56+ {{end}}
57+
4958 {{else}}
5059 <div>
5160 <span class="font-bold">
......@@ -79,6 +88,9 @@
7988
8089 add changes to patch request:
8190 <pre class="m-0">git format-patch {{.Branch}} --stdout | ssh {{.MetaData.URL}} pr add {{.Pr.ID}}</pre>
91+
92+ add comment to patch request:
93+ <pre class="m-0">echo "lgtm!" | ssh {{.MetaData.URL}} pr comment {{.Pr.ID}}</pre>
8294 </div>
8395 </details>
8496 </div>
Back to top