patchbin
created pr with
patchbin/143.1
cmds
checkout latest patchset:
ssh pr.pico.sh pull patchbin:143 | git am -3checkout specific patchset revision:
ssh pr.pico.sh pull patchbin:143 [rev] | git am -3add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh patchbin:143add comment to patch request:
ssh pr.pico.sh comment patchbin:143 "lgtm!"
Patchset
patchbin/143.1
feat: pr comments
Eric Bower
2026-10-01T15:55:14ZWe 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
#
+4
-0
README.md
#
| ... | ... | @@ -50,6 +50,10 @@ ssh {url} help | |
| 50 | 50 | ``` | |
| 51 | 51 | ssh {url} pr edit {prID} "new title" | |
| 52 | 52 | ``` | |
| 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 | + | ``` | |
| 53 | 57 | - `pr summary {prID}` - show metadata, patchsets, and patches for a PR | |
| 54 | 58 | ``` | |
| 55 | 59 | ssh {url} pr summary {prID} |
+53
-2
cli.go
#
| ... | ... | @@ -227,6 +227,10 @@ pr - manage patch requests | |
| 227 | 227 | Rename a PR. | |
| 228 | 228 | ssh %[2]s pr edit {prID} "new title" | |
| 229 | 229 | ||
| 230 | + | pr comment {prID} | |
| 231 | + | Add a comment to a PR from stdin. | |
| 232 | + | echo "lgtm!" | ssh %[2]s pr comment {prID} | |
| 233 | + | ||
| 230 | 234 | pr summary {prID} | |
| 231 | 235 | Show metadata, patchsets, and patches for a PR. | |
| 232 | 236 | ssh %[2]s pr summary {prID} |
| ... | ... | @@ -270,11 +274,11 @@ logs - event history | |
| 270 | 274 | STDIN | |
| 271 | 275 | ||
| 272 | 276 | 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 | |
| 274 | 278 | ||
| 275 | 279 | GUARDS | |
| 276 | 280 | ||
| 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 | |
| 278 | 282 | at %[3]d bytes of stdin, and globally rate limited to %[4]d submissions | |
| 279 | 283 | per %[5]s across all users. Contact an admin if you hit these limits. | |
| 280 | 284 |
| ... | ... | @@ -756,6 +760,53 @@ Self-host your own patchbin: https://github.com/picosh/patchbin | |
| 756 | 760 | return prSummary(be, pr, sesh, prID) | |
| 757 | 761 | }, | |
| 758 | 762 | }, | |
| 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 | + | }, | |
| 759 | 810 | }, | |
| 760 | 811 | }, | |
| 761 | 812 | }, |
+17
-1
e2e_test.go
#
| ... | ... | @@ -76,6 +77,12 @@ func testMultiTenantE2E(t *testing.T) { | |
| 76 | 77 | t.Log("Admin adds patchset to user's PR (zero-trust: anyone can add)") | |
| 77 | 78 | suite.adminKey.MustCmd(suite.otherPatch, "pr add "+userPRID) | |
| 78 | 79 | ||
| 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 | + | ||
| 79 | 86 | t.Log("User creates another PR") | |
| 80 | 87 | suite.userKey.MustCmd(suite.patch, "pr create other-repo") | |
| 81 | 88 |
| ... | ... | @@ -83,7 +90,16 @@ func testMultiTenantE2E(t *testing.T) { | |
| 83 | 90 | suite.userKey.MustCmd(nil, "pr ls") | |
| 84 | 91 | ||
| 85 | 92 | 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 | + | } | |
| 87 | 103 | } | |
| 88 | 104 | ||
| 89 | 105 | type TestSuite struct { |
+41
-0
pr.go
#
| ... | ... | @@ -39,6 +40,7 @@ type GitPatchRequest interface { | |
| 39 | 40 | UpdatePatchRequestName(prID int64, userPubkey string, name string) error | |
| 40 | 41 | DeletePatchsetByID(userID, prID int64, patchsetID int64) error | |
| 41 | 42 | SubmitIssue(userID int64, userPubkey string, repoName, title, body string) (*PatchRequest, error) | |
| 43 | + | AddComment(prID, userID int64, comment string) error | |
| 42 | 44 | CreateEventLog(tx *sqlx.Tx, eventLog EventLog) error | |
| 43 | 45 | GetEventLogs() ([]*EventLog, error) | |
| 44 | 46 | GetEventLogsByPrID(prID int64) ([]*EventLog, error) |
| ... | ... | @@ -288,6 +290,45 @@ func (cmd PrCmd) UpdatePatchRequestName(prID int64, userPubkey string, name stri | |
| 288 | 290 | return cmd.updateLastActivity(prID) | |
| 289 | 291 | } | |
| 290 | 292 | ||
| 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 | + | ||
| 291 | 332 | func (cmd PrCmd) CreateEventLog(tx *sqlx.Tx, eventLog EventLog) error { | |
| 292 | 333 | _, err := tx.Exec( | |
| 293 | 334 | "INSERT INTO event_logs (user_id, patch_request_id, patchset_id, event, data) VALUES (?, ?, ?, ?, ?)", |
+4
-1
static/patchbin.css
#
| ... | ... | @@ -527,10 +527,13 @@ summary { | |
| 527 | 527 | background-color: var(--link-color); | |
| 528 | 528 | } | |
| 529 | 529 | ||
| 530 | - | .status-change-comment { | |
| 530 | + | .status-change-comment, | |
| 531 | + | .timeline-comment { | |
| 531 | 532 | background-color: var(--blockquote-bg); | |
| 532 | 533 | padding: var(--grid-height); | |
| 533 | 534 | margin-top: var(--grid-height); | |
| 535 | + | white-space: pre-wrap; | |
| 536 | + | word-break: break-word; | |
| 534 | 537 | } | |
| 535 | 538 | ||
| 536 | 539 | .pill-status-accepted { |
+53
-0
template_render_test.go
#
| ... | ... | @@ -66,3 +67,55 @@ func TestPatchFileTemplateRendersLineDiffOnly(t *testing.T) { | |
| 66 | 67 | t.Errorf("expected hunk anchor in rendered line diff") | |
| 67 | 68 | } | |
| 68 | 69 | } | |
| 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 @@ | |
| 46 | 46 | </div> | |
| 47 | 47 | </details> | |
| 48 | 48 | ||
| 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> · by {{template "user-pill" .UserData}}</span> | |
| 53 | + | </div> | |
| 54 | + | {{if .Data.Comment}} | |
| 55 | + | <div class="timeline-comment">{{.Data.Comment}}</div> | |
| 56 | + | {{end}} | |
| 57 | + | ||
| 49 | 58 | {{else}} | |
| 50 | 59 | <div> | |
| 51 | 60 | <span class="font-bold"> |
| ... | ... | @@ -79,6 +88,9 @@ | |
| 79 | 88 | ||
| 80 | 89 | add changes to patch request: | |
| 81 | 90 | <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> | |
| 82 | 94 | </div> | |
| 83 | 95 | </details> | |
| 84 | 96 | </div> |