patchbin

created pr with patchbin/144.1 on 2026-10-01T23:55:25Z · by c8ef7d19
added patchbin/144.2 on 2026-10-01T23:56:49Z · by c8ef7d19
1: 2335ab9 ! 1: 0352404 refactor: user-provided repo:slug instead of referencing prIDs
cmds
checkout latest patchset:
ssh pr.pico.sh pull patchbin:144 | git am -3
checkout specific patchset revision:
ssh pr.pico.sh pull patchbin:144 [rev] | git am -3
add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh patchbin:144
add comment to patch request:
ssh pr.pico.sh comment patchbin:144 "lgtm!"

Patchset patchbin/144.1 on 2026-10-01T23:55:25Z · commit 2335ab9

This changes how users create and add to a patchset.  Previously a user would
first create a patchset `git format-patch | ssh pr pr create {repo}` and then
when there was another patch series to upload they would `git format-patch |
ssh pr pr add {prID}`

This asymmetry is not ideal.  Further many of the common ssh commands were behind
the `pr` sub-command, also not ideal.

The main change was making the end-user provide the unique id: {repo}:{id}

For example, this pr: `git format-patch | ssh pr patchbin:user-slug`

Every patch series sent to this namespace is the exact same command.
Semantic diff summary
13 added, 27 modified, 2 signature changed, 3 removed across 13 analyzed files (9 files skipped: unsupported file type)
+46 -35 README.md #
......@@ -10,22 +10,24 @@ There's no accept or reject step. A patch request is simply active or inactive:
1010
1111 ## quickstart
1212
13-Submit a patch request:
13+Submit a patch request (new or follow-up):
1414
1515 ```
16-git format-patch main --stdout | ssh {url} pr create {repo}
16+git format-patch main --stdout | ssh {url} {repo}:{slug}
1717 ```
1818
1919 Checkout the latest patchset from a patch request:
2020
2121 ```
22-ssh {url} print {prID} | git am -3
22+ssh {url} pull {repo}:{slug} | git am -3
23+# or shorthand:
24+ssh {url} {repo}:{slug}.patch | git am -3
2325 ```
2426
25-Add a follow-up patchset (e.g. after addressing review comments):
27+View PR metadata and discussion:
2628
2729 ```
28-git format-patch main --stdout | ssh {url} pr add {prID}
30+ssh {url} show {repo}:{slug}
2931 ```
3032
3133 Help guide:
......@@ -36,63 +38,72 @@ ssh {url} help
3638
3739 ## commands
3840
39-### pr - manage patch requests
41+### {repo}:{slug} - submit a patchset
4042
41-- `pr create {repo}` - submit a new PR from stdin
42- ```
43- git format-patch main --stdout | ssh {url} pr create {repo}
44- ```
45-- `pr add {prID}` - add a new patchset to an existing PR from stdin
46- ```
47- git format-patch main --stdout | ssh {url} pr add {prID}
48- ```
49-- `pr edit {prID} {title}` - rename a PR
43+Submit a new PR or follow-up patchset from stdin:
44+```
45+git format-patch main --stdout | ssh {url} {repo}:{slug}
46+```
47+
48+### pull - print patches for checkout
49+
50+- `pull {repo}:{slug} [rev]` - print mbox patchset for checkout (pipes to git am)
5051 ```
51- ssh {url} pr edit {prID} "new title"
52+ ssh {url} pull {repo}:{slug} | git am -3
5253 ```
53-- `pr comment {prID}` - add a comment to a PR from stdin
54+- `{repo}:{slug}.patch` - shorthand to pull latest patchset
5455 ```
55- echo "looks good to me!" | ssh {url} pr comment {prID}
56+ ssh {url} {repo}:{slug}.patch | git am -3
5657 ```
57-- `pr summary {prID}` - show metadata, patchsets, and patches for a PR
58+
59+### show - view PR summary
60+
61+- `show {repo}:{slug}` - show metadata, patchsets, and patches for a PR
5862 ```
59- ssh {url} pr summary {prID}
63+ ssh {url} show {repo}:{slug}
6064 ```
61-- `pr ls [repo] [--active|--inactive|--mine]` - list PRs
65+
66+### ls - list patch requests
67+
68+- `ls [repo] [--active|--inactive|--mine]` - list PRs
6269 ```
63- ssh {url} pr ls {repo}
70+ ssh {url} ls {repo}
6471 ```
6572
66-### issue - text-only patch requests
73+### comment - add a comment
6774
68-- `issue create {repo} [--title]` - submit a new issue from stdin
75+- `comment {repo}:{slug} [message]` - add a comment via argument or stdin
6976 ```
70- echo "steps to reproduce..." | ssh {url} issue create {repo} --title "bug: crash on startup"
77+ ssh {url} comment {repo}:{slug} "looks good to me!"
78+ echo "looks good to me!" | ssh {url} comment {repo}:{slug}
7179 ```
7280
73-### ps - manage patchsets
81+### edit - rename a PR
7482
75-- `ps rm {prID}.{rev}` - remove a patchset and its patches (creator only)
83+- `edit {repo}:{slug} {title}` - rename a PR (creator only)
7684 ```
77- ssh {url} ps rm {prID}.{rev}
85+ ssh {url} edit {repo}:{slug} "new title"
7886 ```
7987
80-### print - print patches for checkout
88+### rm - remove a patchset
8189
82-- `print {prID}` - print the latest patchset for a PR
90+- `rm {repo}:{slug}.{rev}` - remove a patchset and its patches (creator only)
8391 ```
84- ssh {url} print {prID} | git am -3
92+ ssh {url} rm {repo}:{slug}.2
8593 ```
86-- `print {prID}.{rev}` - print a specific patchset revision
94+
95+### issue - text-only patch requests
96+
97+- `issue {repo}:{slug} [title] [body]` - submit a new issue
8798 ```
88- ssh {url} print {prID}.{rev} | git am -3
99+ ssh {url} issue {repo}:{slug} "bug: crash on startup" "steps to reproduce..."
89100 ```
90101
91102 ### logs - event history
92103
93-- `logs [--pr ID] [--pubkey]` - list event logs, optionally filtered to a PR or your own activity
104+- `logs [--pr {repo}:{slug}] [--pubkey]` - list event logs, optionally filtered to a PR or your own activity
94105 ```
95- ssh {url} logs --pr {prID}
106+ ssh {url} logs --pr {repo}:{slug}
96107 ```
97108
98109 ## self-hosting
+457 -515 cli.go #
......@@ -41,15 +41,6 @@ func formatTable(sesh io.Writer, render func(w io.Writer)) {
4141 }
4242 }
4343
44-func NewTabWriter(out io.Writer) *tabwriter.Writer {
45- return tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
46-}
47-
48-func strToInt(str string) (int64, error) {
49- prID, err := strconv.ParseInt(str, 10, 64)
50- return prID, err
51-}
52-
5344 // readStdinLimited reads all of stdin, rejecting input over maxBytes rather
5445 // than silently truncating it.
5546 func readStdinLimited(r io.Reader, maxBytes int64) ([]byte, error) {
......@@ -64,46 +55,22 @@ func readStdinLimited(r io.Reader, maxBytes int64) ([]byte, error) {
6455 return body, nil
6556 }
6657
67-func getPatchsetFromOpt(patchsets []*Patchset, optPatchsetID string) (*Patchset, error) {
68- if len(patchsets) == 0 {
69- return nil, fmt.Errorf("no patchsets found")
70- }
71- if optPatchsetID == "" {
72- return patchsets[len(patchsets)-1], nil
73- }
74-
75- parsed, err := ParseID(optPatchsetID)
76- if err != nil {
77- return nil, err
78- }
79-
80- if parsed.Rev > 0 && parsed.Rev <= len(patchsets) {
81- return patchsets[parsed.Rev-1], nil
82- }
83-
84- return nil, fmt.Errorf("cannot find patchset: %s", optPatchsetID)
85-}
86-
87-func prSummary(be *Backend, pr GitPatchRequest, sesh *pssh.SSHServerConnSession, prID int64) error {
88- request, err := pr.GetPatchRequestByID(prID)
89- if err != nil {
90- return err
91- }
92-
93- sesh.Printf("%s● PR #%d%s %s%s%s\n", ansiBold, request.ID, ansiReset, ansiBold, request.Name, ansiReset)
58+func prSummary(be *Backend, pr GitPatchRequest, sesh *pssh.SSHServerConnSession, request *PatchRequest) error {
59+ sesh.Printf("%s● PR %s:%s%s %s%s%s\n", ansiBold, request.RepoName, request.Slug, ansiReset, ansiBold, request.Name, ansiReset)
9460 sesh.Printf(" %sRepo:%s %s\n", ansiGray, ansiReset, request.RepoName)
95- sesh.Printf(" %sURL:%s https://%s/prs/%d\n", ansiGray, ansiReset, be.Cfg.Url, prID)
61+ sesh.Printf(" %sSlug:%s %s\n", ansiGray, ansiReset, request.Slug)
62+ sesh.Printf(" %sURL:%s https://%s/%s/%s\n", ansiGray, ansiReset, be.Cfg.Url, request.RepoName, request.Slug)
9663 sesh.Printf(" %sDate:%s %s\n", ansiGray, ansiReset, request.CreatedAt.Format(be.Cfg.TimeFormat))
9764 sesh.Printf("\n")
9865
99- patchsets, err := pr.GetPatchsetsByPrID(prID)
66+ patchsets, err := pr.GetPatchsetsByPrID(request.ID)
10067 if err != nil {
10168 return err
10269 }
10370
10471 sesh.Printf("%s▸ Patchsets%s %s(%d total)%s\n", ansiBold, ansiReset, ansiGray, len(patchsets), ansiReset)
10572 formatTable(sesh, func(w io.Writer) {
106- _, _ = fmt.Fprintln(w, " ID\tUser\tDate")
73+ _, _ = fmt.Fprintln(w, " Rev\tTarget\tUser\tDate")
10774 for idx, patchset := range patchsets {
10875 user, err := pr.GetUserByID(patchset.UserID)
10976 if err != nil {
......@@ -114,27 +81,24 @@ func prSummary(be *Backend, pr GitPatchRequest, sesh *pssh.SSHServerConnSession,
11481
11582 _, _ = fmt.Fprintf(
11683 w,
117- " %s\t%s\t%s\n",
118- getFormattedPatchsetID(request.ID, idx+1),
84+ " v%d\t%s:%s.%d\t%s\t%s\n",
85+ idx+1,
86+ request.RepoName,
87+ request.Slug,
88+ idx+1,
11989 displayName,
12090 patchset.CreatedAt.Format(be.Cfg.TimeFormat),
12191 )
12292 }
12393 })
12494
125- latest, err := getPatchsetFromOpt(patchsets, "")
126- if err != nil {
127- return err
128- }
129-
95+ latest := patchsets[len(patchsets)-1]
13096 patches, err := pr.GetPatchesByPatchsetID(latest.ID)
13197 if err != nil {
13298 return err
13399 }
134100
135- latestPsID := getFormattedPatchsetID(request.ID, len(patchsets))
136- sesh.Printf("\n%s▸ Patches%s %s(latest: %s)%s\n", ansiBold, ansiReset, ansiGray, latestPsID, ansiReset)
137-
101+ sesh.Printf("\n%s▸ Patches%s %s(latest: v%d)%s\n", ansiBold, ansiReset, ansiGray, len(patchsets), ansiReset)
138102 formatTable(sesh, func(w io.Writer) {
139103 _, _ = fmt.Fprintln(w, " #\tCommit\tAuthor\tDate\tTitle")
140104 for idx, patch := range patches {
......@@ -146,7 +110,7 @@ func prSummary(be *Backend, pr GitPatchRequest, sesh *pssh.SSHServerConnSession,
146110 _, _ = fmt.Fprintf(
147111 w,
148112 " %d\t%s\t%s\t%s\t%s\n",
149- idx,
113+ idx+1,
150114 truncateSha(patch.CommitSha),
151115 author,
152116 timestamp,
......@@ -211,85 +175,62 @@ or inactive: active ones go inactive after 14 days without activity.
211175 When a reviewer is happy with the code, they pull it, merge it, and
212176 push upstream themselves; there's nothing to manage here beyond that.
213177
214-COMMANDS
178+QUICKSTART
215179
216-pr - manage patch requests
180+ Submit a patch request (new or follow-up):
181+ git format-patch main --stdout | ssh %[2]s {repo}:{slug}
217182
218- pr create {repo}
219- Submit a new PR from stdin.
220- git format-patch main --stdout | ssh %[2]s pr create {repo}
183+ Pull the latest patchset for checkout (pipes to git am):
184+ ssh %[2]s pull {repo}:{slug} | git am -3
185+ ssh %[2]s {repo}:{slug}.patch | git am -3
221186
222- pr add {prID}
223- Add a new patchset to an existing PR from stdin.
224- git format-patch main --stdout | ssh %[2]s pr add {prID}
187+ View PR metadata and discussion:
188+ ssh %[2]s show {repo}:{slug}
225189
226- pr edit {prID} {title}
227- Rename a PR.
228- ssh %[2]s pr edit {prID} "new title"
190+COMMANDS
229191
230- pr comment {prID}
231- Add a comment to a PR from stdin.
232- echo "lgtm!" | ssh %[2]s pr comment {prID}
192+ {repo}:{slug}
193+ Submit a patchset from stdin (creates PR if new, appends if exists).
194+ git format-patch main --stdout | ssh %[2]s {repo}:{slug}
233195
234- pr summary {prID}
235- Show metadata, patchsets, and patches for a PR.
236- ssh %[2]s pr summary {prID}
196+ pull {repo}:{slug} [rev]
197+ Print mbox patchset for checkout (pipes to git am).
198+ ssh %[2]s pull {repo}:{slug} | git am -3
199+ ssh %[2]s {repo}:{slug}.patch | git am -3
237200
238- pr ls [repo] [--active|--inactive|--mine]
239- List PRs.
240- ssh %[2]s pr ls {repo}
201+ show {repo}:{slug}
202+ Show metadata, patchsets, and patches for a PR.
203+ ssh %[2]s show {repo}:{slug}
241204
242-issue - text-only patch requests (no code required)
205+ ls [repo] [--active|--inactive|--mine]
206+ List patch requests.
207+ ssh %[2]s ls {repo}
243208
244- issue create {repo} [--title]
245- Submit a new issue from stdin.
246- echo "steps to reproduce..." | ssh %[2]s issue create {repo} --title "bug: crash on startup"
209+ comment {repo}:{slug} [msg]
210+ Add a comment to a PR (via argument or stdin).
211+ ssh %[2]s comment {repo}:{slug} "looks good!"
212+ echo "looks good!" | ssh %[2]s comment {repo}:{slug}
247213
248-ps - manage patchsets
214+ edit {repo}:{slug} {title}
215+ Rename a PR (creator only).
216+ ssh %[2]s edit {repo}:{slug} "new title"
249217
250- ps rm {prID.rev}
218+ rm {repo}:{slug} [rev]
251219 Remove a patchset and its patches (creator only).
252- ssh %[2]s ps rm {prID}.{rev}
253-
254-print - print patches for checkout
255-
256- print {prID}
257- Print the latest patchset for a PR.
258- ssh %[2]s print {prID} | git am -3
220+ ssh %[2]s rm {repo}:{slug}.2
259221
260- print {prID}.{rev}
261- Print a specific patchset revision.
262- ssh %[2]s print {prID}.{rev} | git am -3
222+ issue {repo}:{slug} [title] [body]
223+ Submit a new issue (text-only patch request).
224+ ssh %[2]s issue {repo}:{slug} "crash on boot" "repro steps..."
263225
264- Cover letters are stored as an empty commit. If you want to keep them
265- when applying, use "git am --keep-empty" (or set it globally with
266- "git config --global am.keepEmpty true").
267-
268-logs - event history
269-
270- logs [--pr ID] [--pubkey]
271- List event logs, optionally filtered to a PR or your own activity.
272- ssh %[2]s logs --pr {prID}
273-
274-STDIN
275-
276- pr create, pr add expect the output of "git format-patch --stdout"
277- issue create, pr comment expect free-form text
226+ logs [--pr {repo}:{slug}] [--pubkey]
227+ List event logs with filters.
228+ ssh %[2]s logs --pr {repo}:{slug}
278229
279230 GUARDS
280231
281- To limit abuse, submissions (pr create, pr add, issue create, pr comment) are capped
282- at %[3]d bytes of stdin, and globally rate limited to %[4]d submissions
283- per %[5]s across all users. Contact an admin if you hit these limits.
284-
285- Admins with shell access to the host can ban a pubkey or IP address by
286- inserting a row directly into the "acl" table of the sqlite database:
287-
288- sqlite3 data/pr.db "INSERT INTO acl (pubkey, permission) VALUES ('{pubkey}', 'banned')"
289- sqlite3 data/pr.db "INSERT INTO acl (ip_address, permission) VALUES ('{ip}', 'banned')"
290-
291- Banned pubkeys/IPs are rejected at SSH auth time. There is currently no
292- SSH command for this; it requires direct database access.
232+ To limit abuse, submissions are capped at %[3]d bytes of stdin, and
233+ globally rate limited to %[4]d submissions per %[5]s across all users.
293234
294235 Self-host your own patchbin: https://github.com/picosh/patchbin
295236 `, GITPR_VERSION, url, be.Cfg.MaxStdinBytes, be.Cfg.RateLimitCount, be.Cfg.RateLimitInterval)
......@@ -315,134 +256,206 @@ Self-host your own patchbin: https://github.com/picosh/patchbin
315256 },
316257 Commands: []*cli.Command{
317258 {
318- Name: "issue",
319- Usage: "Manage issues (text-only patch requests)",
320- Subcommands: []*cli.Command{
321- {
322- Name: "create",
323- Usage: "Submit a new issue (starts as open)",
324- Args: true,
325- ArgsUsage: "repoName",
326- Flags: []cli.Flag{
327- &cli.StringFlag{
328- Name: "title",
329- Usage: "issue title (default: first line of stdin)",
330- },
331- },
332- Action: func(cCtx *cli.Context) error {
333- if !be.Limiter.Allow() {
334- return be.Limiter.Error()
335- }
259+ Name: "push",
260+ Usage: "Submit a patchset to <repo>:<slug> (creates PR if new, appends if exists)",
261+ Args: true,
262+ ArgsUsage: "<repo>:<slug>",
263+ Action: func(cCtx *cli.Context) error {
264+ if !be.Limiter.Allow() {
265+ return be.Limiter.Error()
266+ }
336267
337- user, err := pr.UpsertUserByPubkey(pubkey)
338- if err != nil {
339- return err
340- }
268+ args := cCtx.Args()
269+ if !args.Present() {
270+ return fmt.Errorf("must provide target in format <repo>:<slug> (e.g. pico:feat/login)")
271+ }
341272
342- args := cCtx.Args()
343- if !args.Present() {
344- return fmt.Errorf("must provide a repo name")
345- }
346- repoName := args.First()
273+ target, err := ParseTarget(args.First())
274+ if err != nil {
275+ return err
276+ }
347277
348- body, err := readStdinLimited(sesh, be.Cfg.MaxStdinBytes)
349- if err != nil {
350- return fmt.Errorf("failed to read issue body from stdin: %w", err)
351- }
352- bodyStr := strings.TrimSpace(string(body))
353- if bodyStr == "" {
354- return fmt.Errorf("must provide issue body via stdin")
355- }
278+ user, err := pr.UpsertUserByPubkey(pubkey)
279+ if err != nil {
280+ return err
281+ }
356282
357- title := cCtx.String("title")
358- if title == "" {
359- // Use first line as title
360- lines := strings.SplitN(bodyStr, "\n", 2)
361- title = lines[0]
362- if len(lines) > 1 {
363- bodyStr = strings.TrimSpace(lines[1])
364- } else {
365- bodyStr = ""
366- }
367- }
283+ body, err := readStdinLimited(sesh, be.Cfg.MaxStdinBytes)
284+ if err != nil {
285+ return fmt.Errorf("failed to read patchset from stdin: %w", err)
286+ }
287+ if len(strings.TrimSpace(string(body))) == 0 {
288+ return fmt.Errorf("no patch data received on stdin\n\nTo submit a patch:\n git format-patch main --stdout | ssh %s %s:%s\n\nTo view this PR:\n ssh %s show %s:%s\n\nTo pull this patchset:\n ssh %s pull %s:%s | git am -3", url, target.Repo, target.Slug, url, target.Repo, target.Slug, url, target.Repo, target.Slug)
289+ }
368290
369- prq, err := pr.SubmitIssue(user.ID, pubkey, repoName, title, bodyStr)
370- if err != nil {
371- return err
372- }
291+ prq, err := pr.GetPatchRequestByRepoAndSlug(target.Repo, target.Slug)
292+ if err != nil {
293+ // Create new PR
294+ prq, err = pr.SubmitPatchRequest(user.ID, pubkey, target.Repo, target.Slug, bytes.NewReader(body))
295+ if err != nil {
296+ return err
297+ }
298+ sesh.Printf("%s✔ PR %s:%s created!%s\n\n", ansiGreen, target.Repo, target.Slug, ansiReset)
299+ return prSummary(be, pr, sesh, prq)
300+ }
373301
374- sesh.Printf("%s✔ Issue #%d created!%s\n\n", ansiGreen, prq.ID, ansiReset)
375- return prSummary(be, pr, sesh, prq.ID)
376- },
377- },
302+ // Append patchset to existing PR
303+ patches, err := pr.SubmitPatchset(prq.ID, user.ID, OpNormal, bytes.NewReader(body))
304+ if err != nil {
305+ return err
306+ }
307+
308+ if len(patches) == 0 {
309+ sesh.Printf("%sPatches submitted!%s However none were saved, probably because they already exist in the system.\n\n", ansiYellow, ansiReset)
310+ return nil
311+ }
312+
313+ sesh.Printf("%s✔ Submitted new patchset for PR %s:%s!%s\n\n", ansiGreen, target.Repo, target.Slug, ansiReset)
314+ return prSummary(be, pr, sesh, prq)
378315 },
379316 },
380317 {
381- Name: "logs",
382- Usage: "List event logs with filters",
383- Args: true,
318+ Name: "pull",
319+ Usage: "Print patches in a patchset for git am checkout",
320+ Args: true,
321+ ArgsUsage: "<repo>:<slug>[.rev] or <repo>:<slug> [rev]",
322+ Action: func(cCtx *cli.Context) error {
323+ args := cCtx.Args()
324+ if !args.Present() {
325+ return fmt.Errorf("must provide target in format <repo>:<slug> (e.g. pico:feat/login)")
326+ }
327+
328+ raw := args.First()
329+ if args.Len() > 1 {
330+ if rev, err := strconv.Atoi(args.Get(1)); err == nil && rev > 0 {
331+ raw = fmt.Sprintf("%s.%d", strings.TrimSuffix(raw, ".patch"), rev)
332+ }
333+ }
334+
335+ _, ps, err := ResolveTarget(pr, raw)
336+ if err != nil {
337+ return err
338+ }
339+
340+ return printCoverLetterForPatchset(sesh, be, pr, ps)
341+ },
342+ },
343+ {
344+ Name: "show",
345+ Usage: "Show metadata, patchsets, and patches for a PR",
346+ Args: true,
347+ ArgsUsage: "<repo>:<slug>",
348+ Action: func(cCtx *cli.Context) error {
349+ args := cCtx.Args()
350+ if !args.Present() {
351+ return fmt.Errorf("must provide target in format <repo>:<slug> (e.g. pico:feat/login)")
352+ }
353+
354+ target, err := ParseTarget(args.First())
355+ if err != nil {
356+ return err
357+ }
358+
359+ prq, err := pr.GetPatchRequestByRepoAndSlug(target.Repo, target.Slug)
360+ if err != nil {
361+ return fmt.Errorf("cannot find PR %s:%s", target.Repo, target.Slug)
362+ }
363+
364+ return prSummary(be, pr, sesh, prq)
365+ },
366+ },
367+ {
368+ Name: "ls",
369+ Usage: "List patch requests",
370+ Args: true,
371+ ArgsUsage: "[repo]",
384372 Flags: []cli.Flag{
385- &cli.Int64Flag{
386- Name: "pr",
387- Usage: "show all events related to the provided patch request",
373+ &cli.BoolFlag{
374+ Name: "active",
375+ Usage: "only show active PRs (activity in last 14 days)",
388376 },
389377 &cli.BoolFlag{
390- Name: "pubkey",
391- Usage: "show all events related to your pubkey",
378+ Name: "inactive",
379+ Usage: "only show inactive PRs (no activity in 14 days)",
380+ },
381+ &cli.BoolFlag{
382+ Name: "mine",
383+ Usage: "only show your own PRs",
392384 },
393385 },
394386 Action: func(cCtx *cli.Context) error {
395- user, err := pr.UpsertUserByPubkey(pubkey)
387+ args := cCtx.Args()
388+ repoName := args.First()
389+ var prs []*PatchRequest
390+ var err error
391+ if repoName == "" {
392+ prs, err = pr.GetPatchRequests()
393+ } else {
394+ prs, err = pr.GetPatchRequestsByRepoName(repoName)
395+ }
396396 if err != nil {
397397 return err
398398 }
399- isPubkey := cCtx.Bool("pubkey")
400- prID := cCtx.Int64("pr")
401- var eventLogs []*EventLog
402- if isPubkey {
403- eventLogs, err = pr.GetEventLogsByUserID(user.ID)
404- } else if prID != 0 {
405- eventLogs, err = pr.GetEventLogsByPrID(prID)
399+
400+ onlyActive := cCtx.Bool("active")
401+ onlyInactive := cCtx.Bool("inactive")
402+ onlyMine := cCtx.Bool("mine")
403+ cutoff := time.Now().AddDate(0, 0, -14)
404+
405+ if repoName == "" {
406+ sesh.Printf("%s▸ Patch Requests%s\n\n", ansiBold, ansiReset)
406407 } else {
407- eventLogs, err = pr.GetEventLogs()
408+ sesh.Printf("%s▸ Patch Requests%s %s(%s)%s\n\n", ansiBold, ansiReset, ansiGray, repoName, ansiReset)
408409 }
409- if err != nil {
410- return err
410+
411+ var matching []*PatchRequest
412+ for _, req := range prs {
413+ if onlyActive && req.LastActivity.Before(cutoff) {
414+ continue
415+ }
416+ if onlyInactive && req.LastActivity.After(cutoff) {
417+ continue
418+ }
419+
420+ user, err := pr.GetUserByID(req.UserID)
421+ if err != nil {
422+ be.Logger.Error("could not get user for pr", "err", err)
423+ continue
424+ }
425+
426+ if onlyMine && user.Pubkey != pubkey {
427+ continue
428+ }
429+
430+ matching = append(matching, req)
411431 }
412432
413- sesh.Printf("%s▸ Event Logs%s\n\n", ansiBold, ansiReset)
414- if len(eventLogs) == 0 {
415- sesh.Printf(" %s(No event logs found)%s\n", ansiGray, ansiReset)
433+ if len(matching) == 0 {
434+ sesh.Printf(" %s(No patch requests found)%s\n", ansiGray, ansiReset)
416435 return nil
417436 }
418437
419438 formatTable(sesh, func(w io.Writer) {
420- _, _ = fmt.Fprintln(w, " PrID\tPatchsetID\tEvent\tCreated\tData")
421- for _, eventLog := range eventLogs {
422- prIDStr := "-"
423- if eventLog.PatchRequestID.Valid && eventLog.PatchRequestID.Int64 > 0 {
424- prIDStr = fmt.Sprintf("%d", eventLog.PatchRequestID.Int64)
439+ _, _ = fmt.Fprintln(w, " Target\tPatchsets\tUser\tLast Activity\tTitle")
440+ for _, req := range matching {
441+ user, _ := pr.GetUserByID(req.UserID)
442+ patchsets, err := pr.GetPatchsetsByPrID(req.ID)
443+ if err != nil {
444+ be.Logger.Error("could not get patchsets for pr", "err", err)
445+ continue
425446 }
426447
427- psIDStr := "-"
428- if eventLog.PatchsetID.Valid && eventLog.PatchsetID.Int64 > 0 {
429- ps, err := pr.GetPatchsetByID(eventLog.PatchsetID.Int64)
430- if err == nil {
431- rev := getPatchsetRev(pr, ps)
432- psIDStr = getFormattedPatchsetID(ps.PatchRequestID, rev)
433- } else {
434- psIDStr = fmt.Sprintf("v%d", eventLog.PatchsetID.Int64)
435- }
436- }
448+ displayName := be.ComputeUserName(user.Pubkey)
437449
438450 _, _ = fmt.Fprintf(
439451 w,
440- " %s\t%s\t%s\t%s\t%s\n",
441- prIDStr,
442- psIDStr,
443- eventLog.Event,
444- eventLog.CreatedAt.Format(be.Cfg.TimeFormat),
445- eventLog.Data,
452+ " %s:%s\t%d\t%s\t%s\t%s\n",
453+ req.RepoName,
454+ req.Slug,
455+ len(patchsets),
456+ displayName,
457+ req.LastActivity.Format(be.Cfg.TimeFormat),
458+ req.Name,
446459 )
447460 }
448461 })
......@@ -450,363 +463,292 @@ Self-host your own patchbin: https://github.com/picosh/patchbin
450463 },
451464 },
452465 {
453- Name: "ps",
454- Usage: "Manage patchsets",
455- Subcommands: []*cli.Command{
456- {
457- Name: "rm",
458- Usage: "Remove a patchset and its patches",
459- Args: true,
460- ArgsUsage: "[X.Y]",
461- Action: func(cCtx *cli.Context) error {
462- args := cCtx.Args()
463- if !args.Present() {
464- return fmt.Errorf("must provide a patchset ID (e.g. 1.2)")
465- }
466+ Name: "comment",
467+ Usage: "Add a comment to a PR",
468+ Args: true,
469+ ArgsUsage: "<repo>:<slug> [message]",
470+ Action: func(cCtx *cli.Context) error {
471+ if !be.Limiter.Allow() {
472+ return be.Limiter.Error()
473+ }
466474
467- parsed, err := ParseID(args.First())
468- if err != nil {
469- return err
470- }
475+ args := cCtx.Args()
476+ if !args.Present() {
477+ return fmt.Errorf("must provide target in format <repo>:<slug> (e.g. pico:feat/login)")
478+ }
471479
472- patchset, err := GetPatchsetByParsedID(pr, parsed)
473- if err != nil {
474- return err
475- }
480+ target, err := ParseTarget(args.First())
481+ if err != nil {
482+ return err
483+ }
476484
477- user, err := pr.GetUserByID(patchset.UserID)
478- if err != nil {
479- return err
480- }
485+ prq, err := pr.GetPatchRequestByRepoAndSlug(target.Repo, target.Slug)
486+ if err != nil {
487+ return fmt.Errorf("cannot find PR %s:%s", target.Repo, target.Slug)
488+ }
481489
482- if pubkey != user.Pubkey {
483- return fmt.Errorf("you are not authorized to delete this patchset (only the creator can delete)")
484- }
490+ user, err := pr.UpsertUserByPubkey(pubkey)
491+ if err != nil {
492+ return err
493+ }
485494
486- err = pr.DeletePatchsetByID(user.ID, patchset.PatchRequestID, patchset.ID)
487- if err != nil {
488- return err
489- }
495+ var comment string
496+ if args.Len() > 1 {
497+ comment = strings.TrimSpace(strings.Join(args.Slice()[1:], " "))
498+ } else {
499+ body, err := readStdinLimited(sesh, be.Cfg.MaxStdinBytes)
500+ if err != nil {
501+ return fmt.Errorf("failed to read comment from stdin: %w", err)
502+ }
503+ comment = strings.TrimSpace(string(body))
504+ }
490505
491- rev := getPatchsetRev(pr, patchset)
492- sesh.Printf("%s✔ Removed patchset %d.%d.%s\n", ansiGreen, patchset.PatchRequestID, rev, ansiReset)
493- return nil
494- },
495- },
506+ if comment == "" {
507+ return fmt.Errorf("must provide comment via argument or stdin")
508+ }
509+
510+ err = pr.AddComment(prq.ID, user.ID, comment)
511+ if err != nil {
512+ return err
513+ }
514+
515+ sesh.Printf("%s✔ Comment added to PR %s:%s!%s\n\n", ansiGreen, prq.RepoName, prq.Slug, ansiReset)
516+ return nil
496517 },
497518 },
498519 {
499- Name: "print",
500- Usage: "Print patches in a patchset",
520+ Name: "edit",
521+ Usage: "Edit a PR's title (creator only)",
501522 Args: true,
502- ArgsUsage: "[X] or [X.Y]",
523+ ArgsUsage: "<repo>:<slug> <title>",
503524 Action: func(cCtx *cli.Context) error {
504525 args := cCtx.Args()
505526 if !args.Present() {
506- return fmt.Errorf("must provide ID in format: X or X.Y")
527+ return fmt.Errorf("must provide target in format <repo>:<slug> (e.g. pico:feat/login)")
507528 }
508529
509- parsed, err := ParseID(args.First())
530+ target, err := ParseTarget(args.First())
510531 if err != nil {
511532 return err
512533 }
513534
514- patchset, err := GetPatchsetByParsedID(pr, parsed)
535+ prq, err := pr.GetPatchRequestByRepoAndSlug(target.Repo, target.Slug)
536+ if err != nil {
537+ return fmt.Errorf("cannot find PR %s:%s", target.Repo, target.Slug)
538+ }
539+
540+ if args.Len() < 2 {
541+ return fmt.Errorf("must provide new title")
542+ }
543+ title := strings.TrimSpace(strings.Join(args.Slice()[1:], " "))
544+ if title == "" {
545+ return fmt.Errorf("must provide new title")
546+ }
547+
548+ err = pr.UpdatePatchRequestName(prq.ID, pubkey, title)
515549 if err != nil {
516550 return err
517551 }
518552
519- return printCoverLetterForPatchset(sesh, be, pr, patchset)
553+ sesh.Printf("%s✔ Updated PR %s:%s title to: %s%s\n\n", ansiGreen, prq.RepoName, prq.Slug, title, ansiReset)
554+ return nil
520555 },
521556 },
522557 {
523- Name: "pr",
524- Usage: "Manage patch requests (PR)",
525- Subcommands: []*cli.Command{
526- {
527- Name: "ls",
528- Usage: "List all PRs",
529- Args: true,
530- ArgsUsage: "[repoName]",
531- Flags: []cli.Flag{
532- &cli.BoolFlag{
533- Name: "active",
534- Usage: "only show active PRs (activity in last 30 days)",
535- },
536- &cli.BoolFlag{
537- Name: "inactive",
538- Usage: "only show inactive PRs (no activity in 30 days)",
539- },
540- &cli.BoolFlag{
541- Name: "mine",
542- Usage: "only show your own PRs",
543- },
544- },
545- Action: func(cCtx *cli.Context) error {
546- args := cCtx.Args()
547- repoName := args.First()
548- var prs []*PatchRequest
549- var err error
550- if repoName == "" {
551- prs, err = pr.GetPatchRequests()
552- if err != nil {
553- return err
554- }
555- } else {
556- prs, err = pr.GetPatchRequestsByRepoName(repoName)
557- if err != nil {
558- return err
559- }
560- }
561-
562- onlyActive := cCtx.Bool("active")
563- onlyInactive := cCtx.Bool("inactive")
564- onlyMine := cCtx.Bool("mine")
565- cutoff := time.Now().AddDate(0, 0, -30)
566-
567- if repoName == "" {
568- sesh.Printf("%s▸ Patch Requests%s\n\n", ansiBold, ansiReset)
569- } else {
570- sesh.Printf("%s▸ Patch Requests%s %s(%s)%s\n\n", ansiBold, ansiReset, ansiGray, repoName, ansiReset)
571- }
572-
573- var matching []*PatchRequest
574- for _, req := range prs {
575- if onlyActive && req.LastActivity.Before(cutoff) {
576- continue
577- }
558+ Name: "rm",
559+ Usage: "Remove a patchset and its patches (creator only)",
560+ Args: true,
561+ ArgsUsage: "<repo>:<slug>.<rev> or <repo>:<slug> [rev]",
562+ Action: func(cCtx *cli.Context) error {
563+ args := cCtx.Args()
564+ if !args.Present() {
565+ return fmt.Errorf("must provide target in format <repo>:<slug>.<rev> (e.g. pico:feat/login.2)")
566+ }
578567
579- if onlyInactive && req.LastActivity.After(cutoff) {
580- continue
581- }
568+ raw := args.First()
569+ if args.Len() > 1 {
570+ if rev, err := strconv.Atoi(args.Get(1)); err == nil && rev > 0 {
571+ raw = fmt.Sprintf("%s.%d", raw, rev)
572+ }
573+ }
582574
583- user, err := pr.GetUserByID(req.UserID)
584- if err != nil {
585- be.Logger.Error("could not get user for pr", "err", err)
586- continue
587- }
575+ prq, patchset, err := ResolveTarget(pr, raw)
576+ if err != nil {
577+ return err
578+ }
588579
589- if onlyMine && user.Pubkey != pubkey {
590- continue
591- }
580+ user, err := pr.GetUserByID(patchset.UserID)
581+ if err != nil {
582+ return err
583+ }
592584
593- matching = append(matching, req)
594- }
585+ if pubkey != user.Pubkey {
586+ return fmt.Errorf("you are not authorized to delete this patchset (only the creator can delete)")
587+ }
595588
596- if len(matching) == 0 {
597- sesh.Printf(" %s(No patch requests found)%s\n", ansiGray, ansiReset)
598- return nil
599- }
589+ rev := getPatchsetRev(pr, patchset)
590+ err = pr.DeletePatchsetByID(user.ID, prq.ID, patchset.ID)
591+ if err != nil {
592+ return err
593+ }
600594
601- formatTable(sesh, func(w io.Writer) {
602- _, _ = fmt.Fprintln(w, " ID\tRepo\tPatchsets\tUser\tLast Activity\tTitle")
603- for _, req := range matching {
604- user, _ := pr.GetUserByID(req.UserID)
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- w,
615- " %d\t%s\t%d\t%s\t%s\t%s\n",
616- req.ID,
617- req.RepoName,
618- len(patchsets),
619- displayName,
620- req.LastActivity.Format(be.Cfg.TimeFormat),
621- req.Name,
622- )
623- }
624- })
625- return nil
626- },
595+ sesh.Printf("%s✔ Removed patchset %s:%s.%d.%s\n", ansiGreen, prq.RepoName, prq.Slug, rev, ansiReset)
596+ return nil
597+ },
598+ },
599+ {
600+ Name: "issue",
601+ Usage: "Submit a new issue (text-only patch request)",
602+ Args: true,
603+ ArgsUsage: "<repo>:<slug> [title] [body]",
604+ Flags: []cli.Flag{
605+ &cli.StringFlag{
606+ Name: "title",
607+ Usage: "issue title (default: first line of stdin or 2nd argument)",
627608 },
628- {
629- Name: "create",
630- Usage: "Submit a new PR",
631- Args: true,
632- ArgsUsage: "repoName",
633- Action: func(cCtx *cli.Context) error {
634- if !be.Limiter.Allow() {
635- return be.Limiter.Error()
636- }
637-
638- user, err := pr.UpsertUserByPubkey(pubkey)
639- if err != nil {
640- return err
641- }
609+ },
610+ Action: func(cCtx *cli.Context) error {
611+ if !be.Limiter.Allow() {
612+ return be.Limiter.Error()
613+ }
642614
643- args := cCtx.Args()
644- if !args.Present() {
645- return fmt.Errorf("must provide a repo name")
646- }
647- repoName := args.First()
615+ user, err := pr.UpsertUserByPubkey(pubkey)
616+ if err != nil {
617+ return err
618+ }
648619
649- body, err := readStdinLimited(sesh, be.Cfg.MaxStdinBytes)
650- if err != nil {
651- return fmt.Errorf("failed to read patchset from stdin: %w", err)
652- }
620+ args := cCtx.Args()
621+ if !args.Present() {
622+ return fmt.Errorf("must provide target in format <repo>:<slug> (e.g. pico:issue-1)")
623+ }
653624
654- prq, err := pr.SubmitPatchRequest(user.ID, pubkey, repoName, bytes.NewReader(body))
655- if err != nil {
656- return err
657- }
625+ target, err := ParseTarget(args.First())
626+ if err != nil {
627+ return err
628+ }
658629
659- return prSummary(be, pr, sesh, prq.ID)
660- },
661- },
662- {
663- Name: "summary",
664- Usage: "Show metadata, patchsets, and patches for a PR",
665- Args: true,
666- ArgsUsage: "[prID]",
667- Action: func(cCtx *cli.Context) error {
668- args := cCtx.Args()
669- if !args.Present() {
670- return fmt.Errorf("must provide a patch request ID")
671- }
630+ title := cCtx.String("title")
631+ var bodyStr string
672632
673- prID, err := strToInt(args.First())
674- if err != nil {
675- return err
676- }
677- return prSummary(be, pr, sesh, prID)
678- },
679- },
680- {
681- Name: "edit",
682- Usage: "Edit a PR's title",
683- Args: true,
684- ArgsUsage: "[prID] [title]",
685- Action: func(cCtx *cli.Context) error {
686- args := cCtx.Args()
687- if !args.Present() {
688- return fmt.Errorf("must provide a patch request ID")
633+ if args.Len() > 1 {
634+ if title == "" {
635+ title = args.Get(1)
636+ if args.Len() > 2 {
637+ bodyStr = strings.Join(args.Slice()[2:], " ")
689638 }
639+ } else {
640+ bodyStr = strings.Join(args.Slice()[1:], " ")
641+ }
642+ }
690643
691- prID, err := strToInt(args.First())
692- if err != nil {
693- return err
694- }
695- prq, err := pr.GetPatchRequestByID(prID)
696- if err != nil {
697- return err
698- }
644+ if bodyStr == "" {
645+ body, err := readStdinLimited(sesh, be.Cfg.MaxStdinBytes)
646+ if err != nil {
647+ return fmt.Errorf("failed to read issue body from stdin: %w", err)
648+ }
649+ bodyStr = strings.TrimSpace(string(body))
650+ }
699651
700- tail := cCtx.Args().Tail()
701- title := strings.Join(tail, " ")
702- if title == "" {
703- return fmt.Errorf("must provide title")
704- }
652+ if title == "" {
653+ if bodyStr == "" {
654+ return fmt.Errorf("must provide issue title or body")
655+ }
656+ lines := strings.SplitN(bodyStr, "\n", 2)
657+ title = lines[0]
658+ if len(lines) > 1 {
659+ bodyStr = strings.TrimSpace(lines[1])
660+ } else {
661+ bodyStr = ""
662+ }
663+ }
705664
706- err = pr.UpdatePatchRequestName(prID, pubkey, title)
707- if err != nil {
708- return err
709- }
710- sesh.Printf("%s✔ Updated PR #%d title to: %s%s\n\n", ansiGreen, prq.ID, title, ansiReset)
665+ prq, err := pr.SubmitIssue(user.ID, pubkey, target.Repo, target.Slug, title, bodyStr)
666+ if err != nil {
667+ return err
668+ }
711669
712- return err
713- },
670+ sesh.Printf("%s✔ Issue %s:%s created!%s\n\n", ansiGreen, prq.RepoName, prq.Slug, ansiReset)
671+ return prSummary(be, pr, sesh, prq)
672+ },
673+ },
674+ {
675+ Name: "logs",
676+ Usage: "List event logs with filters",
677+ Flags: []cli.Flag{
678+ &cli.StringFlag{
679+ Name: "pr",
680+ Usage: "show all events related to the provided PR (<repo>:<slug>)",
714681 },
715- {
716- Name: "add",
717- Usage: "Add a new patchset to a PR",
718- Args: true,
719- ArgsUsage: "[prID]",
720- Action: func(cCtx *cli.Context) error {
721- if !be.Limiter.Allow() {
722- return be.Limiter.Error()
723- }
724-
725- args := cCtx.Args()
726- if !args.Present() {
727- return fmt.Errorf("must provide a patch request ID")
728- }
729-
730- prID, err := strToInt(args.First())
731- if err != nil {
732- return err
733- }
734- _, err = pr.GetPatchRequestByID(prID)
735- if err != nil {
736- return err
737- }
738-
739- user, err := pr.UpsertUserByPubkey(pubkey)
740- if err != nil {
741- return err
742- }
743-
744- body, err := readStdinLimited(sesh, be.Cfg.MaxStdinBytes)
745- if err != nil {
746- return fmt.Errorf("failed to read patchset from stdin: %w", err)
747- }
748-
749- patches, err := pr.SubmitPatchset(prID, user.ID, OpNormal, bytes.NewReader(body))
750- if err != nil {
751- return err
752- }
753-
754- if len(patches) == 0 {
755- sesh.Printf("%sPatches submitted!%s However none were saved, probably because they already exist in the system.\n\n", ansiYellow, ansiReset)
756- return nil
757- }
758-
759- sesh.Printf("%s✔ Submitted new patchset for PR #%d!%s\n\n", ansiGreen, prID, ansiReset)
760- return prSummary(be, pr, sesh, prID)
761- },
682+ &cli.BoolFlag{
683+ Name: "pubkey",
684+ Usage: "show all events related to your pubkey",
762685 },
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- }
686+ },
687+ Action: func(cCtx *cli.Context) error {
688+ user, err := pr.UpsertUserByPubkey(pubkey)
689+ if err != nil {
690+ return err
691+ }
692+ isPubkey := cCtx.Bool("pubkey")
693+ prTarget := cCtx.String("pr")
694+ var eventLogs []*EventLog
695+ if isPubkey {
696+ eventLogs, err = pr.GetEventLogsByUserID(user.ID)
697+ } else if prTarget != "" {
698+ prq, _, err := ResolveTarget(pr, prTarget)
699+ if err != nil {
700+ return err
701+ }
702+ eventLogs, err = pr.GetEventLogsByPrID(prq.ID)
703+ } else {
704+ eventLogs, err = pr.GetEventLogs()
705+ }
706+ if err != nil {
707+ return err
708+ }
786709
787- user, err := pr.UpsertUserByPubkey(pubkey)
788- if err != nil {
789- return err
790- }
710+ sesh.Printf("%s▸ Event Logs%s\n\n", ansiBold, ansiReset)
711+ if len(eventLogs) == 0 {
712+ sesh.Printf(" %s(No event logs found)%s\n", ansiGray, ansiReset)
713+ return nil
714+ }
791715
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")
716+ formatTable(sesh, func(w io.Writer) {
717+ _, _ = fmt.Fprintln(w, " Target\tPatchset\tEvent\tCreated\tData")
718+ for _, eventLog := range eventLogs {
719+ targetStr := "-"
720+ if eventLog.PatchRequestID.Valid && eventLog.PatchRequestID.Int64 > 0 {
721+ prq, err := pr.GetPatchRequestByID(eventLog.PatchRequestID.Int64)
722+ if err == nil {
723+ targetStr = fmt.Sprintf("%s:%s", prq.RepoName, prq.Slug)
724+ } else {
725+ targetStr = fmt.Sprintf("#%d", eventLog.PatchRequestID.Int64)
726+ }
799727 }
800728
801- err = pr.AddComment(prID, user.ID, comment)
802- if err != nil {
803- return err
729+ psIDStr := "-"
730+ if eventLog.PatchsetID.Valid && eventLog.PatchsetID.Int64 > 0 {
731+ ps, err := pr.GetPatchsetByID(eventLog.PatchsetID.Int64)
732+ if err == nil {
733+ rev := getPatchsetRev(pr, ps)
734+ psIDStr = fmt.Sprintf("v%d", rev)
735+ } else {
736+ psIDStr = fmt.Sprintf("v%d", eventLog.PatchsetID.Int64)
737+ }
804738 }
805739
806- sesh.Printf("%s✔ Comment added to PR #%d!%s\n\n", ansiGreen, prID, ansiReset)
807- return nil
808- },
809- },
740+ _, _ = fmt.Fprintf(
741+ w,
742+ " %s\t%s\t%s\t%s\t%s\n",
743+ targetStr,
744+ psIDStr,
745+ eventLog.Event,
746+ eventLog.CreatedAt.Format(be.Cfg.TimeFormat),
747+ eventLog.Data,
748+ )
749+ }
750+ })
751+ return nil
810752 },
811753 },
812754 },
+8 -8 contrib/dev/main.go #
......@@ -73,18 +73,18 @@ func main() {
7373 }
7474
7575 // PR with title edited
76- userKey.MustCmd(patch, "pr create test")
77- userKey.MustCmd(nil, "pr edit 1 Simple PR")
76+ userKey.MustCmd(patch, "push test:simple-pr")
77+ userKey.MustCmd(nil, "edit test:simple-pr Simple PR")
7878
7979 // PR with patchset added by another user
80- userKey.MustCmd(patch, "pr create test")
81- userKey.MustCmd(nil, "pr edit 2 Collaborative PR")
82- adminKey.MustCmd(otherPatch, `pr add 2`)
80+ userKey.MustCmd(patch, "push test:collab-pr")
81+ userKey.MustCmd(nil, "edit test:collab-pr Collaborative PR")
82+ adminKey.MustCmd(otherPatch, "push test:collab-pr")
8383
8484 // Range Diff PR
85- userKey.MustCmd(rd1, "pr create test")
86- userKey.MustCmd(nil, "pr edit 3 Range Diff")
87- userKey.MustCmd(rd2, "pr add 3")
85+ userKey.MustCmd(rd1, "push test:range-diff")
86+ userKey.MustCmd(nil, "edit test:range-diff Range Diff")
87+ userKey.MustCmd(rd2, "push test:range-diff")
8888
8989 fmt.Println("time to do some testing...")
9090 ch := make(chan os.Signal, 1)
+25 -17 e2e_test.go #
......@@ -35,13 +35,13 @@ func testSingleTenantE2E(t *testing.T) {
3535
3636 // Users are auto-created on first use, no registration needed
3737 t.Log("User should be able to create a PR")
38- suite.userKey.MustCmd(suite.patch, "pr create test")
38+ suite.userKey.MustCmd(suite.patch, "push test:single-tenant")
3939
40- t.Log("Admin should also be able to create a PR")
41- suite.adminKey.MustCmd(suite.patch, "pr create test")
40+ t.Log("Admin should also be able to create a PR via shorthand")
41+ suite.adminKey.MustCmd(suite.patch, "test:admin-single-tenant")
4242
4343 t.Log("List PRs")
44- suite.userKey.MustCmd(nil, "pr ls")
44+ suite.userKey.MustCmd(nil, "ls")
4545 }
4646
4747 func testMultiTenantE2E(t *testing.T) {
......@@ -65,29 +65,31 @@ func testMultiTenantE2E(t *testing.T) {
6565 // Anyone can create PRs in any repo
6666
6767 t.Log("User creates PR")
68- output := suite.userKey.MustCmd(suite.patch, "pr create test")
69- userPRID := util.ParsePRID(output)
68+ output := suite.userKey.MustCmd(suite.patch, "push test:my-pr")
69+ if !strings.Contains(output, "PR test:my-pr created") {
70+ t.Fatalf("unexpected create output: %s", output)
71+ }
7072
7173 t.Log("User edits PR title (only creator can edit)")
72- suite.userKey.MustCmd(nil, "pr edit "+userPRID+" Updated title")
74+ suite.userKey.MustCmd(nil, "edit test:my-pr Updated title")
7375
7476 t.Log("Admin creates PR")
75- suite.adminKey.MustCmd(suite.patch, "pr create admin-repo")
77+ suite.adminKey.MustCmd(suite.patch, "push admin-repo:admin-pr")
7678
7779 t.Log("Admin adds patchset to user's PR (zero-trust: anyone can add)")
78- suite.adminKey.MustCmd(suite.otherPatch, "pr add "+userPRID)
80+ suite.adminKey.MustCmd(suite.otherPatch, "push test:my-pr")
7981
8082 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+ commentOutput := suite.adminKey.MustCmd([]byte("LGTM! Great work on this PR.\n"), "comment test:my-pr")
84+ if !strings.Contains(commentOutput, "Comment added to PR test:my-pr") {
8385 t.Fatalf("unexpected comment output: %s", commentOutput)
8486 }
8587
8688 t.Log("User creates another PR")
87- suite.userKey.MustCmd(suite.patch, "pr create other-repo")
89+ suite.userKey.MustCmd(suite.patch, "push other-repo:other-pr")
8890
8991 t.Log("List PRs")
90- suite.userKey.MustCmd(nil, "pr ls")
92+ suite.userKey.MustCmd(nil, "ls")
9193
9294 t.Log("View event logs")
9395 logsOutput := suite.userKey.MustCmd(nil, "logs")
......@@ -95,10 +97,16 @@ func testMultiTenantE2E(t *testing.T) {
9597 t.Fatalf("expected pr_commented in event logs, got: %s", logsOutput)
9698 }
9799
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)
100+ t.Log("View PR cover letter to verify comment in discussion via pull")
101+ pullOutput := suite.userKey.MustCmd(nil, "pull test:my-pr")
102+ if !strings.Contains(pullOutput, "LGTM! Great work on this PR.") {
103+ t.Fatalf("expected comment in cover letter discussion, got: %s", pullOutput)
104+ }
105+
106+ t.Log("Verify shorthand pull works")
107+ shorthandPull := suite.userKey.MustCmd(nil, "test:my-pr.patch")
108+ if !strings.Contains(shorthandPull, "LGTM! Great work on this PR.") {
109+ t.Fatalf("expected comment in shorthand pull, got: %s", shorthandPull)
102110 }
103111 }
104112
+11 -0 mdw.go #
......@@ -2,6 +2,7 @@ package patchbin
22
33 import (
44 "fmt"
5+ "strings"
56
67 "github.com/picosh/pico/pkg/pssh"
78 )
......@@ -10,6 +11,16 @@ func GitPatchRequestMiddleware(be *Backend, pr GitPatchRequest) pssh.SSHServerMi
1011 return func(next pssh.SSHServerHandler) pssh.SSHServerHandler {
1112 return func(sesh *pssh.SSHServerConnSession) error {
1213 args := sesh.Command()
14+ if len(args) > 0 {
15+ first := args[0]
16+ if strings.Contains(first, ":") {
17+ if strings.HasSuffix(first, ".patch") {
18+ args = append([]string{"pull", strings.TrimSuffix(first, ".patch")}, args[1:]...)
19+ } else {
20+ args = append([]string{"push", first}, args[1:]...)
21+ }
22+ }
23+ }
1324 cli := NewCli(sesh, be, pr)
1425 margs := append([]string{"git"}, args...)
1526 be.Logger.Info("ssh args", "args", args)
+1 -0 models.go #
......@@ -33,6 +33,7 @@ type PatchRequest struct {
3333 ID int64 `db:"id"`
3434 UserID int64 `db:"user_id"`
3535 RepoName string `db:"repo_name"` // Plain string namespace
36+ Slug string `db:"slug"`
3637 Name string `db:"name"`
3738 Text string `db:"text"`
3839 CreatedAt time.Time `db:"created_at"`
+20 -6 pr.go #
......@@ -27,9 +27,10 @@ type GitPatchRequest interface {
2727 GetUserByPubkey(pubkey string) (*User, error)
2828 UpsertUserByPubkey(pubkey string) (*User, error)
2929 IsBanned(pubkey, ipAddress string) error
30- SubmitPatchRequest(userID int64, userPubkey string, repoName string, patchset io.Reader) (*PatchRequest, error)
30+ SubmitPatchRequest(userID int64, userPubkey string, repoName, slug string, patchset io.Reader) (*PatchRequest, error)
3131 SubmitPatchset(prID, userID int64, op PatchsetOp, patchset io.Reader) ([]*Patch, error)
3232 GetPatchRequestByID(prID int64) (*PatchRequest, error)
33+ GetPatchRequestByRepoAndSlug(repoName, slug string) (*PatchRequest, error)
3334 GetPatchRequests() ([]*PatchRequest, error)
3435 GetPatchRequestsByRepoName(repoName string) ([]*PatchRequest, error)
3536 GetPatchRequestsByPubkey(pubkey string) ([]*PatchRequest, error)
......@@ -39,7 +40,7 @@ type GitPatchRequest interface {
3940 GetPatchesByPatchsetID(patchsetID int64) ([]*Patch, error)
4041 UpdatePatchRequestName(prID int64, userPubkey string, name string) error
4142 DeletePatchsetByID(userID, prID int64, patchsetID int64) error
42- SubmitIssue(userID int64, userPubkey string, repoName, title, body string) (*PatchRequest, error)
43+ SubmitIssue(userID int64, userPubkey string, repoName, slug, title, body string) (*PatchRequest, error)
4344 AddComment(prID, userID int64, comment string) error
4445 CreateEventLog(tx *sqlx.Tx, eventLog EventLog) error
4546 GetEventLogs() ([]*EventLog, error)
......@@ -224,6 +225,17 @@ func (cmd PrCmd) GetPatchRequestByID(prID int64) (*PatchRequest, error) {
224225 return &pr, err
225226 }
226227
228+func (cmd PrCmd) GetPatchRequestByRepoAndSlug(repoName, slug string) (*PatchRequest, error) {
229+ pr := PatchRequest{}
230+ err := cmd.Backend.DB.Get(
231+ &pr,
232+ "SELECT * FROM patch_requests WHERE repo_name=? AND slug=? ORDER BY created_at DESC",
233+ repoName,
234+ slug,
235+ )
236+ return &pr, err
237+}
238+
227239 func (cmd PrCmd) updateLastActivity(prID int64) error {
228240 _, err := cmd.Backend.DB.Exec(
229241 "UPDATE patch_requests SET last_activity=? WHERE id=?",
......@@ -381,7 +393,7 @@ func (cmd PrCmd) createPatch(tx *sqlx.Tx, patch *Patch) (int64, error) {
381393 }
382394
383395 // SubmitPatchRequest creates a new patch request.
384-func (cmd PrCmd) SubmitPatchRequest(userID int64, userPubkey string, repoName string, patchset io.Reader) (*PatchRequest, error) {
396+func (cmd PrCmd) SubmitPatchRequest(userID int64, userPubkey string, repoName, slug string, patchset io.Reader) (*PatchRequest, error) {
385397 tx, err := cmd.Backend.DB.Beginx()
386398 if err != nil {
387399 return nil, err
......@@ -410,9 +422,10 @@ func (cmd PrCmd) SubmitPatchRequest(userID int64, userPubkey string, repoName st
410422 now := time.Now()
411423 var prID int64
412424 row := tx.QueryRow(
413- "INSERT INTO patch_requests (user_id, repo_name, name, text, updated_at, last_activity) VALUES(?, ?, ?, ?, ?, ?) RETURNING id",
425+ "INSERT INTO patch_requests (user_id, repo_name, slug, name, text, updated_at, last_activity) VALUES(?, ?, ?, ?, ?, ?, ?) RETURNING id",
414426 userID,
415427 repoName,
428+ slug,
416429 prName,
417430 prText,
418431 now,
......@@ -471,7 +484,7 @@ func (cmd PrCmd) SubmitPatchRequest(userID int64, userPubkey string, repoName st
471484
472485 // SubmitIssue creates a new patch request as an issue (text-only, no patches).
473486 // The title is the issue subject, body is the full description.
474-func (cmd PrCmd) SubmitIssue(userID int64, userPubkey string, repoName, title, body string) (*PatchRequest, error) {
487+func (cmd PrCmd) SubmitIssue(userID int64, userPubkey string, repoName, slug, title, body string) (*PatchRequest, error) {
475488 if title == "" {
476489 return nil, fmt.Errorf("must provide a title for the issue")
477490 }
......@@ -488,9 +501,10 @@ func (cmd PrCmd) SubmitIssue(userID int64, userPubkey string, repoName, title, b
488501 now := time.Now()
489502 var prID int64
490503 row := tx.QueryRow(
491- "INSERT INTO patch_requests (user_id, repo_name, name, text, updated_at, last_activity) VALUES(?, ?, ?, ?, ?, ?) RETURNING id",
504+ "INSERT INTO patch_requests (user_id, repo_name, slug, name, text, updated_at, last_activity) VALUES(?, ?, ?, ?, ?, ?, ?) RETURNING id",
492505 userID,
493506 repoName,
507+ slug,
494508 title,
495509 body,
496510 now,
+7 -0 sqlite.go #
......@@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS patch_requests (
2929 id INTEGER PRIMARY KEY AUTOINCREMENT,
3030 user_id INTEGER NOT NULL,
3131 repo_name TEXT NOT NULL DEFAULT '',
32+ slug TEXT NOT NULL DEFAULT '',
3233 name TEXT NOT NULL,
3334 text TEXT NOT NULL,
3435 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
......@@ -40,6 +41,8 @@ CREATE TABLE IF NOT EXISTS patch_requests (
4041 ON UPDATE CASCADE
4142 );
4243
44+CREATE UNIQUE INDEX IF NOT EXISTS patch_requests_repo_slug_idx ON patch_requests (repo_name, slug);
45+
4346 CREATE TABLE IF NOT EXISTS patchsets (
4447 id INTEGER PRIMARY KEY AUTOINCREMENT,
4548 user_id INTEGER NOT NULL,
......@@ -285,6 +288,10 @@ var sqliteMigrations = []string{
285288 ALTER TABLE tmp_patch_requests_v3 RENAME TO patch_requests;
286289 CREATE INDEX IF NOT EXISTS idx_patch_requests_last_activity ON patch_requests(last_activity);
287290 DELETE FROM event_logs WHERE event = 'pr_status_changed';`,
291+ // Migration 21: Add slug column to patch_requests, backfill with id, and create unique index
292+ `ALTER TABLE patch_requests ADD COLUMN slug TEXT NOT NULL DEFAULT '';
293+ UPDATE patch_requests SET slug = CAST(id AS TEXT) WHERE slug = '';
294+ CREATE UNIQUE INDEX IF NOT EXISTS patch_requests_repo_slug_idx ON patch_requests (repo_name, slug);`,
288295 }
289296
290297 // Open opens a database connection.
+120 -0 sqlite_test.go #
......@@ -0,0 +1,120 @@
1+package patchbin
2+
3+import (
4+ "io"
5+ "log/slog"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+
10+ "github.com/picosh/patchbin/util"
11+)
12+
13+func TestSqliteFreshSchema(t *testing.T) {
14+ dataDir := util.CreateTmpDir()
15+ defer func() {
16+ _ = os.RemoveAll(dataDir)
17+ }()
18+
19+ dbPath := filepath.Join(dataDir, "test.db")
20+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
21+
22+ db, err := SqliteOpen("file:"+dbPath+"?_fk=on", logger)
23+ if err != nil {
24+ t.Fatalf("SqliteOpen failed: %v", err)
25+ }
26+ defer func() {
27+ _ = db.Close()
28+ }()
29+
30+ var userVersion int
31+ err = db.QueryRow("PRAGMA user_version;").Scan(&userVersion)
32+ if err != nil {
33+ t.Fatalf("failed to query user_version: %v", err)
34+ }
35+ if userVersion != 21 {
36+ t.Errorf("expected user_version 21, got %d", userVersion)
37+ }
38+}
39+
40+func TestSqliteMigrationV21(t *testing.T) {
41+ // Check if data/pr.db exists
42+ if _, err := os.Stat("data/pr.db"); os.IsNotExist(err) {
43+ t.Skip("data/pr.db does not exist, skipping migration test")
44+ }
45+
46+ dataDir := util.CreateTmpDir()
47+ defer func() {
48+ _ = os.RemoveAll(dataDir)
49+ }()
50+
51+ dbPath := filepath.Join(dataDir, "migrated.db")
52+
53+ // Copy data/pr.db to temp dir
54+ src, err := os.Open("data/pr.db")
55+ if err != nil {
56+ t.Fatalf("failed to open source db: %v", err)
57+ }
58+ defer func() {
59+ _ = src.Close()
60+ }()
61+
62+ dst, err := os.Create(dbPath)
63+ if err != nil {
64+ t.Fatalf("failed to create dest db: %v", err)
65+ }
66+ if _, err := io.Copy(dst, src); err != nil {
67+ _ = dst.Close()
68+ t.Fatalf("failed to copy db: %v", err)
69+ }
70+ _ = dst.Close()
71+
72+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
73+ db, err := SqliteOpen("file:"+dbPath+"?_fk=on", logger)
74+ if err != nil {
75+ t.Fatalf("SqliteOpen failed: %v", err)
76+ }
77+ defer func() {
78+ _ = db.Close()
79+ }()
80+
81+ var userVersion int
82+ err = db.QueryRow("PRAGMA user_version;").Scan(&userVersion)
83+ if err != nil {
84+ t.Fatalf("failed to query user_version: %v", err)
85+ }
86+ if userVersion != 21 {
87+ t.Errorf("expected user_version 21, got %d", userVersion)
88+ }
89+
90+ // Verify existing PRs got slug backfilled to CAST(id AS TEXT)
91+ rows, err := db.Query("SELECT id, slug FROM patch_requests LIMIT 10;")
92+ if err != nil {
93+ t.Fatalf("failed to query patch_requests: %v", err)
94+ }
95+ defer func() {
96+ _ = rows.Close()
97+ }()
98+
99+ count := 0
100+ for rows.Next() {
101+ var id int64
102+ var slug string
103+ if err := rows.Scan(&id, &slug); err != nil {
104+ t.Fatalf("failed to scan row: %v", err)
105+ }
106+ count++
107+ if slug == "" {
108+ t.Errorf("PR #%d has empty slug", id)
109+ }
110+ }
111+ if count == 0 {
112+ t.Errorf("expected at least 1 PR row, got 0")
113+ }
114+
115+ // Verify unique constraint on (repo_name, slug)
116+ _, err = db.Exec("INSERT INTO patch_requests (user_id, repo_name, slug, name) VALUES (1, 'pico', '2', 'duplicate');")
117+ if err == nil {
118+ t.Errorf("expected unique constraint violation on (repo_name, slug), got nil")
119+ }
120+}
+2 -2 tmpl/base.html #
......@@ -22,8 +22,8 @@
2222 <img src="/static/logo-patchbin.svg" alt="patchbin logo" width="20" height="20" />
2323 <span style="margin-left: 3px;">patchbin</span>
2424 </a>
25- <a class="btn-nav {{if eq .Tab "active"}}btn-active{{end}}" href="/prs/active">active</a>
26- <a class="btn-nav {{if eq .Tab "inactive"}}btn-active{{end}}" href="/prs/inactive">inactive</a>
25+ <a class="btn-nav {{if eq .Tab "active"}}btn-active{{end}}" href="/active">active</a>
26+ <a class="btn-nav {{if eq .Tab "inactive"}}btn-active{{end}}" href="/inactive">inactive</a>
2727 <a class="btn-nav flex items-center" href="/rss">rss</a>
2828 </nav>
2929 </div>
+1 -1 tmpl/components/commit-list.html #
......@@ -3,7 +3,7 @@
33 {{$selectedID := .Patch.ID}}
44 {{range $idx, $patch := .Patches}}
55 <a class="commit-list-item flex justify-between items-center gap{{if eq $patch.ID $selectedID}} commit-list-item-active{{end}}"
6- href="/prs/{{$.FormattedPatchsetID}}/patches/{{$patch.ID}}">
6+ href="/{{$.FormattedPatchsetID}}/patches/{{$patch.ID}}">
77 <span class="mono truncate" style="flex: 1;">
88 {{if eq $patch.ID $selectedID}}<span class="text-md" style="color: var(--success);">&rarr;</span> {{end}}{{$patch.Title}}
99 </span>
+10 -7 tmpl/components/pr-header.html #
......@@ -2,9 +2,9 @@
22 <header id="top">
33 <h1 class="text-2xl mb">
44 <a href="/">dashboard</a>
5- <span> / <a href="{{.Repo.Url}}">{{.Repo.Text}}</a></span>
6- <span> / {{.Pr.Title}} <a href="/prs/{{.Pr.ID}}"><code>#{{.Pr.ID}}</code></a></span>
7- <a class="text-sm" href="/prs/{{.Pr.ID}}/rss">rss</a>
5+ <span> / <a href="/{{.RepoName}}">{{.RepoName}}</a></span>
6+ <span> / {{.Pr.Title}} <a href="/{{.RepoName}}/{{.Pr.Slug}}"><code>{{.RepoName}}:{{.Pr.Slug}}</code></a></span>
7+ <a class="text-sm" href="/{{.RepoName}}/{{.Pr.Slug}}/rss">rss</a>
88 </h1>
99
1010 <div class="mb">
......@@ -16,13 +16,16 @@
1616 <summary>Help</summary>
1717 <div class="group">
1818 checkout latest patchset:
19- <pre class="m-0">ssh {{.MetaData.URL}} print {{.Pr.ID}} | git am -3</pre>
19+ <pre class="m-0">ssh {{.MetaData.URL}} pull {{.RepoName}}:{{.Pr.Slug}} | git am -3</pre>
2020
21- checkout any patchset in a patch request:
22- <pre class="m-0">ssh {{.MetaData.URL}} print {{.Pr.ID}}.[rev] | git am -3</pre>
21+ checkout specific patchset revision:
22+ <pre class="m-0">ssh {{.MetaData.URL}} pull {{.RepoName}}:{{.Pr.Slug}} [rev] | git am -3</pre>
2323
2424 add changes to patch request:
25- <pre class="m-0">git format-patch {{.Branch}} --stdout | ssh {{.MetaData.URL}} pr add {{.Pr.ID}}</pre>
25+ <pre class="m-0">git format-patch {{.Branch}} --stdout | ssh {{.MetaData.URL}} {{.RepoName}}:{{.Pr.Slug}}</pre>
26+
27+ add comment to patch request:
28+ <pre class="m-0">ssh {{.MetaData.URL}} comment {{.RepoName}}:{{.Pr.Slug}} "lgtm!"</pre>
2629 </div>
2730 </details>
2831 </header>
+2 -2 tmpl/components/pr-tabs.html #
......@@ -1,6 +1,6 @@
11 {{define "pr-tabs"}}
22 <div class="pr-tabs">
3- <a href="/prs/{{.Pr.ID}}" class="pr-tab{{if eq .Tab "timeline"}} pr-tab-active{{end}}">Timeline</a>
4- <a href="/prs/{{.FormattedPatchsetID}}" class="pr-tab{{if eq .Tab "patchsets"}} pr-tab-active{{end}}">Patchsets</a>
3+ <a href="/{{.RepoName}}/{{.Pr.Slug}}" class="pr-tab{{if eq .Tab "timeline"}} pr-tab-active{{end}}">Timeline</a>
4+ <a href="/{{.FormattedPatchsetID}}" class="pr-tab{{if eq .Tab "patchsets"}} pr-tab-active{{end}}">Patchsets</a>
55 </div>
66 {{end}}
+55 -29 tmpl/pages/index.html #
......@@ -49,15 +49,17 @@
4949 <h2>Quickstart</h2>
5050
5151 <div>
52- Submit a patch request:
52+ Submit a patch request (new or follow-up):
5353 </div>
54- <pre class="m-0">git format-patch main --stdout | ssh {{.MetaData.URL}} pr create {repo}</pre>
54+ <pre class="m-0">git format-patch main --stdout | ssh {{.MetaData.URL}} {repo}:{slug}</pre>
5555
5656 <div>Checkout the latest patchset from a patch request:</div>
57- <pre class="m-0">ssh {{.MetaData.URL}} print {prID} | git am -3</pre>
57+ <pre class="m-0">ssh {{.MetaData.URL}} pull {repo}:{slug} | git am -3
58+# or shorthand:
59+ssh {{.MetaData.URL}} {repo}:{slug}.patch | git am -3</pre>
5860
59- <div>Add a follow-up patchset (e.g. after addressing review comments):</div>
60- <pre class="m-0">git format-patch main --stdout | ssh {{.MetaData.URL}} pr add {prID}</pre>
61+ <div>View PR metadata and discussion:</div>
62+ <pre class="m-0">ssh {{.MetaData.URL}} show {repo}:{slug}</pre>
6163
6264 <div>Help guide:</div>
6365 <pre class="m-0">ssh {{.MetaData.URL}} help</pre>
......@@ -67,61 +69,85 @@
6769 <h2>Commands</h2>
6870
6971 <details class="details-min">
70- <summary class="font-bold">pr - manage patch requests</summary>
72+ <summary class="font-bold">{repo}:{slug} - submit a patchset</summary>
7173 <div class="group my">
7274 <div>
73- <code>pr create {repo}</code> - submit a new PR from stdin
74- <pre class="m-0">git format-patch main --stdout | ssh {{.MetaData.URL}} pr create {repo}</pre>
75+ <code>{repo}:{slug}</code> - submit a patchset from stdin (creates PR if new, appends if exists)
76+ <pre class="m-0">git format-patch main --stdout | ssh {{.MetaData.URL}} {repo}:{slug}</pre>
7577 </div>
78+ </div>
79+ </details>
80+
81+ <details class="details-min">
82+ <summary class="font-bold">pull - print patches for checkout</summary>
83+ <div class="group my">
7684 <div>
77- <code>pr add {prID}</code> - add a new patchset to an existing PR from stdin
78- <pre class="m-0">git format-patch main --stdout | ssh {{.MetaData.URL}} pr add {prID}</pre>
85+ <code>pull {repo}:{slug} [rev]</code> - print mbox patchset for checkout (pipes to git am)
86+ <pre class="m-0">ssh {{.MetaData.URL}} pull {repo}:{slug} | git am -3</pre>
7987 </div>
8088 <div>
81- <code>pr edit {prID} {title}</code> - rename a PR
82- <pre class="m-0">ssh {{.MetaData.URL}} pr edit {prID} "new title"</pre>
89+ <code>{repo}:{slug}.patch</code> - shorthand to pull latest patchset
90+ <pre class="m-0">ssh {{.MetaData.URL}} {repo}:{slug}.patch | git am -3</pre>
8391 </div>
92+ </div>
93+ </details>
94+
95+ <details class="details-min">
96+ <summary class="font-bold">show - view PR summary</summary>
97+ <div class="group my">
8498 <div>
85- <code>pr summary {prID}</code> - show metadata, patchsets, and patches for a PR
86- <pre class="m-0">ssh {{.MetaData.URL}} pr summary {prID}</pre>
99+ <code>show {repo}:{slug}</code> - show metadata, patchsets, and patches for a PR
100+ <pre class="m-0">ssh {{.MetaData.URL}} show {repo}:{slug}</pre>
87101 </div>
102+ </div>
103+ </details>
104+
105+ <details class="details-min">
106+ <summary class="font-bold">ls - list patch requests</summary>
107+ <div class="group my">
88108 <div>
89- <code>pr ls [repo] [--active|--inactive|--mine]</code> - list PRs
90- <pre class="m-0">ssh {{.MetaData.URL}} pr ls {repo}</pre>
109+ <code>ls [repo] [--active|--inactive|--mine]</code> - list PRs
110+ <pre class="m-0">ssh {{.MetaData.URL}} ls {repo}</pre>
91111 </div>
92112 </div>
93113 </details>
94114
95115 <details class="details-min">
96- <summary class="font-bold">issue - text-only patch requests (no code required)</summary>
116+ <summary class="font-bold">comment - add a comment</summary>
97117 <div class="group my">
98118 <div>
99- <code>issue create {repo} [--title]</code> - submit a new issue from stdin
100- <pre class="m-0">echo "steps to reproduce..." | ssh {{.MetaData.URL}} issue create {repo} --title "bug: crash on startup"</pre>
119+ <code>comment {repo}:{slug} [msg]</code> - add a comment via argument or stdin
120+ <pre class="m-0">ssh {{.MetaData.URL}} comment {repo}:{slug} "looks good!"</pre>
101121 </div>
102122 </div>
103123 </details>
104124
105125 <details class="details-min">
106- <summary class="font-bold">ps - manage patchsets</summary>
126+ <summary class="font-bold">edit - rename a PR</summary>
107127 <div class="group my">
108128 <div>
109- <code>ps rm {prID.rev}</code> - remove a patchset and its patches (creator only)
110- <pre class="m-0">ssh {{.MetaData.URL}} ps rm {prID}.{rev}</pre>
129+ <code>edit {repo}:{slug} {title}</code> - rename a PR (creator only)
130+ <pre class="m-0">ssh {{.MetaData.URL}} edit {repo}:{slug} "new title"</pre>
111131 </div>
112132 </div>
113133 </details>
114134
115135 <details class="details-min">
116- <summary class="font-bold">print - print patches for checkout</summary>
136+ <summary class="font-bold">rm - remove a patchset</summary>
117137 <div class="group my">
118138 <div>
119- <code>print {prID}</code> - print the latest patchset for a PR
120- <pre class="m-0">ssh {{.MetaData.URL}} print {prID} | git am -3</pre>
139+ <code>rm {repo}:{slug}.{rev}</code> - remove a patchset and its patches (creator only)
140+ <pre class="m-0">ssh {{.MetaData.URL}} rm {repo}:{slug}.2</pre>
121141 </div>
142+ </div>
143+ </details>
144+
145+ <details class="details-min">
146+ <summary class="font-bold">issue - text-only patch requests (no code required)</summary>
147+ <div class="group my">
122148 <div>
123- <code>print {prID}.{rev}</code> - print a specific patchset revision
124- <pre class="m-0">ssh {{.MetaData.URL}} print {prID}.{rev} | git am -3</pre>
149+ <code>issue {repo}:{slug} [title] [body]</code> - submit a new issue
150+ <pre class="m-0">ssh {{.MetaData.URL}} issue {repo}:{slug} "crash on startup" "steps to reproduce..."</pre>
125151 </div>
126152 </div>
127153 </details>
......@@ -130,8 +156,8 @@
130156 <summary class="font-bold">logs - event history</summary>
131157 <div class="group my">
132158 <div>
133- <code>logs [--pr ID] [--pubkey]</code> - list event logs, optionally filtered to a PR or your own activity
134- <pre class="m-0">ssh {{.MetaData.URL}} logs --pr {prID}</pre>
159+ <code>logs [--pr {repo}:{slug}] [--pubkey]</code> - list event logs, optionally filtered to a PR or your own activity
160+ <pre class="m-0">ssh {{.MetaData.URL}} logs --pr {repo}:{slug}</pre>
135161 </div>
136162 </div>
137163 </details>
+15 -15 tmpl/pages/pr.html #
......@@ -1,13 +1,13 @@
1- {{template "base" .}}
1+{{template "base" .}}
22
33 {{define "title"}}{{.Pr.Title}} - pr summary{{end}}
44
55 {{define "meta"}}
66 <link rel="alternate" type="application/atom+xml"
7- title="RSS feed for git collaboration server"
8- href="/prs/{{.Pr.ID}}/rss" />
7+ title="RSS feed for {{.RepoName}}:{{.Pr.Slug}}"
8+ href="/{{.RepoName}}/{{.Pr.Slug}}/rss" />
99 <meta property="og:title" content="{{.Pr.Title}}" />
10-<meta property="og:url" content="https://{{.MetaData.URL}}/prs/{{.Pr.ID}}" />
10+<meta property="og:url" content="https://{{.MetaData.URL}}/{{.RepoName}}/{{.Pr.Slug}}" />
1111 <meta property="og:type" content="object" />
1212 <meta property="og:site_name" content="{{.MetaData.URL}}" />
1313 {{end}}
......@@ -16,11 +16,11 @@
1616 <main class="group">
1717 <div class="flex justify-center items-center">
1818 <div class="box container w-full">
19- <h1 class="text-xl">{{.RepoName}}</h1>
19+ <h1 class="text-xl"><a href="/{{.RepoName}}">{{.RepoName}}</a></h1>
2020
2121 <div class="flex items-center gap border-visited flex-wrap">
22- <a href="/prs/{{.Pr.ID}}">{{.Pr.Title}}</a>
23- <a class="btn-nav flex items-center" href="/prs/{{.Pr.ID}}/rss" style="padding: 0.025rem 0.3rem;">
22+ <a href="/{{.RepoName}}/{{.Pr.Slug}}">{{.Pr.Title}}</a>
23+ <a class="btn-nav flex items-center" href="/{{.RepoName}}/{{.Pr.Slug}}/rss" style="padding: 0.025rem 0.3rem;">
2424 rss
2525 </a>
2626 </div>
......@@ -31,7 +31,7 @@
3131 {{if eq .Event "pr_patchset_added"}}
3232 <details class="mb" style="border: 0; padding: 0;">
3333 <summary>
34- <span class="font-bold">added <a href="/prs/{{.FormattedPatchsetID}}"><code{{if eq .Patchset.ID $.Patchset.ID}} class="border-visited"{{end}}>{{.FormattedPatchsetID}}</code></a></span>
34+ <span class="font-bold">added <a href="/{{.FormattedPatchsetID}}"><code{{if eq .Patchset.ID $.Patchset.ID}} class="border-visited"{{end}}>{{.FormattedPatchsetID}}</code></a></span>
3535 <span class="event-meta">on <date>{{.Date}}</date> &middot; by {{template "user-pill" .UserData}}</span>
3636 </summary>
3737
......@@ -59,7 +59,7 @@
5959 <div>
6060 <span class="font-bold">
6161 {{if eq .Event "pr_created"}}
62- created pr with <a href="/prs/{{.FormattedPatchsetID}}"><code{{if eq .Patchset.ID $.Patchset.ID}} class="border-visited"{{end}}>{{.FormattedPatchsetID}}</code></a>
62+ created pr with <a href="/{{.FormattedPatchsetID}}"><code{{if eq .Patchset.ID $.Patchset.ID}} class="border-visited"{{end}}>{{.FormattedPatchsetID}}</code></a>
6363 {{else if eq .Event "pr_patchset_deleted"}}
6464 deleted <code>{{.FormattedPatchsetID}}</code>
6565 {{else if eq .Event "pr_patchset_replaced"}}
......@@ -81,16 +81,16 @@
8181 <summary>cmds</summary>
8282 <div class="group my">
8383 checkout latest patchset:
84- <pre class="m-0">ssh {{.MetaData.URL}} print {{.Pr.ID}} | git am -3</pre>
84+ <pre class="m-0">ssh {{.MetaData.URL}} pull {{.RepoName}}:{{.Pr.Slug}} | git am -3</pre>
8585
86- checkout any patchset in a patch request:
87- <pre class="m-0">ssh {{.MetaData.URL}} print {{.Pr.ID}}.[rev] | git am -3</pre>
86+ checkout specific patchset revision:
87+ <pre class="m-0">ssh {{.MetaData.URL}} pull {{.RepoName}}:{{.Pr.Slug}} [rev] | git am -3</pre>
8888
8989 add changes to patch request:
90- <pre class="m-0">git format-patch {{.Branch}} --stdout | ssh {{.MetaData.URL}} pr add {{.Pr.ID}}</pre>
90+ <pre class="m-0">git format-patch {{.Branch}} --stdout | ssh {{.MetaData.URL}} {{.RepoName}}:{{.Pr.Slug}}</pre>
9191
9292 add comment to patch request:
93- <pre class="m-0">echo "lgtm!" | ssh {{.MetaData.URL}} pr comment {{.Pr.ID}}</pre>
93+ <pre class="m-0">ssh {{.MetaData.URL}} comment {{.RepoName}}:{{.Pr.Slug}} "lgtm!"</pre>
9494 </div>
9595 </details>
9696 </div>
......@@ -100,7 +100,7 @@
100100 <div class="patchset-split">
101101 <h3 class="text-md mb">
102102 Patchset
103- <a href="/prs/{{.FormattedPatchsetID}}"><code class="border-visited">{{.FormattedPatchsetID}}</code></a>
103+ <a href="/{{.FormattedPatchsetID}}"><code class="border-visited">{{.FormattedPatchsetID}}</code></a>
104104 <span class="event-meta">on <date>{{.PatchsetDate}}</date> &middot; commit {{sha .Patch.CommitSha}}</span>
105105 </h3>
106106
+3 -3 tmpl/pages/prs.html #
......@@ -14,12 +14,12 @@
1414 {{range .PRs}}
1515 <div class="flex items-center justify-between collapse gap py border-b">
1616 <div class="flex items-center gap-2 flex-1" style="min-width: 0;">
17- <span class="pr-repo-col truncate mono text-sm" title="{{.RepoName}}">[{{.RepoName}}]</span>
18- <a href="/prs/{{.ID}}" class="flex-1 truncate">{{if .Name}}{{.Name}}{{else}}<em>(no title)</em>{{end}}</a>
17+ <a href="/{{.RepoName}}" class="pr-repo-col truncate mono text-sm" title="{{.RepoName}}">[{{.RepoName}}]</a>
18+ <a href="/{{.RepoName}}/{{.Slug}}" class="flex-1 truncate">{{if .Name}}{{.Name}}{{else}}<em>(no title)</em>{{end}}</a>
1919 {{if gt .NumPatchsets 1}}<code class="text-sm">{{.NumPatchsets}}</code>{{end}}
2020 </div>
2121 <div class="flex items-center gap mono text-sm">
22- <code class="text-sm">#{{.ID}}</code>
22+ <code class="text-sm">{{.RepoName}}:{{.Slug}}</code>
2323 <span>{{.FormattedDate}}</span>
2424 </div>
2525 </div>
+23 -8 tmpl/pages/repo.html #
......@@ -4,31 +4,46 @@
44
55 {{define "meta"}}
66 <link rel="alternate" type="application/atom+xml"
7- title="RSS feed for git collaboration server"
8- href="/r/{{.Username}}/{{.Name}}/rss" />
7+ title="RSS feed for {{.Name}}"
8+ href="/{{.Name}}/rss" />
99 {{end}}
1010
1111 {{define "body"}}
1212 <header>
13- <h1 class="text-2xl mb"><a href="/">dashboard</a> / <a href="/r/{{.Username}}">{{.Username}}</a> / {{.Name}}</h1>
13+ <h1 class="text-2xl mb"><a href="/">dashboard</a> / {{.Name}}</h1>
1414 <div class="group">
1515 <details>
1616 <summary>Help</summary>
1717 <div class="group">
18- <pre class="m-0"># submit a new patch request
19-git format-patch {{.Branch}} --stdout | ssh {{.MetaData.URL}} pr create {{.Username}}/{{.Name}}</pre>
18+ <pre class="m-0"># submit a patch request
19+git format-patch main --stdout | ssh {{.MetaData.URL}} {{.Name}}:{slug}</pre>
2020 <pre class="m-0"># list prs for repo
21-ssh {{.MetaData.URL}} pr ls {{.Username}}/{{.Name}}</pre>
21+ssh {{.MetaData.URL}} ls {{.Name}}</pre>
2222 </div>
2323 </details>
2424 </div>
2525 </header>
2626
2727 <main class="group">
28- {{template "pr-table" .Prs}}
28+ <div class="box-sm">
29+ {{range .PRs}}
30+ <div class="flex items-center justify-between collapse gap py border-b">
31+ <div class="flex items-center gap-2 flex-1" style="min-width: 0;">
32+ <a href="/{{.RepoName}}/{{.Slug}}" class="flex-1 truncate">{{if .Name}}{{.Name}}{{else}}<em>(no title)</em>{{end}}</a>
33+ {{if gt .NumPatchsets 1}}<code class="text-sm">{{.NumPatchsets}}</code>{{end}}
34+ </div>
35+ <div class="flex items-center gap mono text-sm">
36+ <code class="text-sm">{{.Slug}}</code>
37+ <span>{{.FormattedDate}}</span>
38+ </div>
39+ </div>
40+ {{else}}
41+ <p>No patch requests found for repo {{.Name}}.</p>
42+ {{end}}
43+ </div>
2944 </main>
3045
3146 <footer class="mt">
32- <a href="/r/{{.Username}}/{{.Name}}/rss">rss</a>
47+ <a href="/{{.Name}}/rss">rss</a>
3348 </footer>
3449 {{end}}
+93 -0 util.go #
......@@ -46,6 +46,99 @@ func GetAuthorizedKeys(pubkeys []string) ([]ssh.PublicKey, error) {
4646 return keys, nil
4747 }
4848
49+type Target struct {
50+ Repo string
51+ Slug string
52+ Rev int // 1-indexed revision number, or 0 if latest
53+}
54+
55+func (t Target) String() string {
56+ if t.Rev > 0 {
57+ return fmt.Sprintf("%s:%s.%d", t.Repo, t.Slug, t.Rev)
58+ }
59+ return fmt.Sprintf("%s:%s", t.Repo, t.Slug)
60+}
61+
62+// ParseTarget parses a colon-delimited target like "repo:slug", "repo:slug.2", "repo:slug.patch", etc.
63+func ParseTarget(raw string) (Target, error) {
64+ raw = strings.TrimSpace(raw)
65+ if raw == "" {
66+ return Target{}, fmt.Errorf("must provide target in format: <repo>:<slug>")
67+ }
68+ if strings.ContainsAny(raw, " \t\r\n") {
69+ return Target{}, fmt.Errorf("invalid target %q: cannot contain whitespace", raw)
70+ }
71+ raw = strings.TrimSuffix(raw, ".patch")
72+
73+ parts := strings.SplitN(raw, ":", 2)
74+ if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
75+ return Target{}, fmt.Errorf("invalid target %q: must be in format <repo>:<slug> (e.g. pico:feat/login)", raw)
76+ }
77+
78+ repo := parts[0]
79+ slugPart := parts[1]
80+
81+ rev := 0
82+ if lastDot := strings.LastIndex(slugPart, "."); lastDot != -1 {
83+ revStr := strings.TrimPrefix(slugPart[lastDot+1:], "v")
84+ if r, err := strconv.Atoi(revStr); err == nil && r > 0 {
85+ rev = r
86+ slugPart = slugPart[:lastDot]
87+ }
88+ }
89+
90+ if slugPart == "" {
91+ return Target{}, fmt.Errorf("invalid target %q: slug cannot be empty", raw)
92+ }
93+
94+ return Target{
95+ Repo: repo,
96+ Slug: slugPart,
97+ Rev: rev,
98+ }, nil
99+}
100+
101+// ResolveTarget finds a PatchRequest and its Patchset matching the raw target string.
102+func ResolveTarget(gpr GitPatchRequest, raw string) (*PatchRequest, *Patchset, error) {
103+ target, err := ParseTarget(raw)
104+ if err != nil {
105+ return nil, nil, err
106+ }
107+
108+ // 1. Try to find PR with the exact full slug first (handles branch names with dots, e.g. release-1.0)
109+ rawParts := strings.SplitN(strings.TrimSuffix(strings.TrimSpace(raw), ".patch"), ":", 2)
110+ fullSlug := rawParts[1]
111+ pr, err := gpr.GetPatchRequestByRepoAndSlug(target.Repo, fullSlug)
112+ if err == nil {
113+ patchsets, err := gpr.GetPatchsetsByPrID(pr.ID)
114+ if err != nil || len(patchsets) == 0 {
115+ return nil, nil, fmt.Errorf("no patchsets found for PR %s:%s", pr.RepoName, pr.Slug)
116+ }
117+ return pr, patchsets[len(patchsets)-1], nil
118+ }
119+
120+ // 2. Try with target.Slug (if revision was parsed)
121+ pr, err = gpr.GetPatchRequestByRepoAndSlug(target.Repo, target.Slug)
122+ if err != nil {
123+ return nil, nil, fmt.Errorf("cannot find PR %s:%s", target.Repo, target.Slug)
124+ }
125+
126+ patchsets, err := gpr.GetPatchsetsByPrID(pr.ID)
127+ if err != nil || len(patchsets) == 0 {
128+ return nil, nil, fmt.Errorf("no patchsets found for PR %s:%s", pr.RepoName, pr.Slug)
129+ }
130+
131+ if target.Rev == 0 {
132+ return pr, patchsets[len(patchsets)-1], nil
133+ }
134+
135+ if target.Rev < 1 || target.Rev > len(patchsets) {
136+ return nil, nil, fmt.Errorf("revision %d does not exist for PR %s:%s (PR has %d revision(s))", target.Rev, pr.RepoName, pr.Slug, len(patchsets))
137+ }
138+
139+ return pr, patchsets[target.Rev-1], nil
140+}
141+
49142 type ParsedID struct {
50143 PrID int64
51144 Rev int // 1-indexed revision number within PR, or 0 if latest
+37 -0 util_test.go #
......@@ -130,3 +130,40 @@ func TestGetFormattedPatchsetID(t *testing.T) {
130130 t.Errorf("expected empty string, got %q", got)
131131 }
132132 }
133+
134+func TestParseTarget(t *testing.T) {
135+ tests := []struct {
136+ input string
137+ expected Target
138+ wantErr bool
139+ }{
140+ {input: "pico:feat/login", expected: Target{Repo: "pico", Slug: "feat/login", Rev: 0}},
141+ {input: "pico:feat/login.2", expected: Target{Repo: "pico", Slug: "feat/login", Rev: 2}},
142+ {input: "pico:feat/login.v3", expected: Target{Repo: "pico", Slug: "feat/login", Rev: 3}},
143+ {input: "pico:feat/login.patch", expected: Target{Repo: "pico", Slug: "feat/login", Rev: 0}},
144+ {input: "pico:feat/login.2.patch", expected: Target{Repo: "pico", Slug: "feat/login", Rev: 2}},
145+ {input: "my-repo:123", expected: Target{Repo: "my-repo", Slug: "123", Rev: 0}},
146+ {input: "my-repo:123.4", expected: Target{Repo: "my-repo", Slug: "123", Rev: 4}},
147+ {input: "no-colon", wantErr: true},
148+ {input: ":no-repo", wantErr: true},
149+ {input: "no-slug:", wantErr: true},
150+ {input: "repo:slug with spaces", wantErr: true},
151+ {input: "", wantErr: true},
152+ }
153+
154+ for _, tt := range tests {
155+ got, err := ParseTarget(tt.input)
156+ if tt.wantErr {
157+ if err == nil {
158+ t.Errorf("ParseTarget(%q) expected error, got nil", tt.input)
159+ }
160+ } else {
161+ if err != nil {
162+ t.Errorf("ParseTarget(%q) unexpected error: %v", tt.input, err)
163+ }
164+ if got != tt.expected {
165+ t.Errorf("ParseTarget(%q) = %+v, expected %+v", tt.input, got, tt.expected)
166+ }
167+ }
168+ }
169+}
+248 -12 web.go #
......@@ -12,6 +12,8 @@ import (
1212 "net/http"
1313 "os"
1414 "path/filepath"
15+ "strconv"
16+ "strings"
1517 "time"
1618
1719 "github.com/alecthomas/chroma/v2"
......@@ -29,6 +31,7 @@ var (
2931 indexTmpl = getTemplate("index.html")
3032 prTmpl = getTemplate("pr.html")
3133 prsListTmpl = getTemplate("prs.html")
34+ repoTmpl = getTemplate("repo.html")
3235 )
3336
3437 type BasicData struct {
......@@ -43,6 +46,7 @@ type MetaData struct {
4346
4447 type PrListItem struct {
4548 ID int64
49+ Slug string
4650 Name string
4751 RepoName string
4852 FormattedDate string
......@@ -154,6 +158,7 @@ func createPrListHandler(tab TabStatus) http.HandlerFunc {
154158 }
155159 prItems = append(prItems, PrListItem{
156160 ID: pr.ID,
161+ Slug: pr.Slug,
157162 Name: pr.Name,
158163 RepoName: pr.RepoName,
159164 FormattedDate: pr.CreatedAt.Format(web.Backend.Cfg.TimeFormat),
......@@ -183,6 +188,233 @@ func shaFn(sha string) string {
183188 return truncateSha(sha)
184189 }
185190
191+func createRepoPrListHandler(w http.ResponseWriter, r *http.Request) {
192+ web, err := getWebCtx(r)
193+ if err != nil {
194+ w.WriteHeader(http.StatusInternalServerError)
195+ return
196+ }
197+
198+ repoName := r.PathValue("repo")
199+ if repoName == "prs" {
200+ http.Redirect(w, r, "/active", http.StatusMovedPermanently)
201+ return
202+ }
203+ prs, err := web.Pr.GetPatchRequestsByRepoName(repoName)
204+ if err != nil {
205+ w.WriteHeader(http.StatusInternalServerError)
206+ return
207+ }
208+
209+ prItems := []PrListItem{}
210+ for _, pr := range prs {
211+ patchsets, _ := web.Pr.GetPatchsetsByPrID(pr.ID)
212+ prItems = append(prItems, PrListItem{
213+ ID: pr.ID,
214+ Slug: pr.Slug,
215+ Name: pr.Name,
216+ RepoName: pr.RepoName,
217+ FormattedDate: pr.CreatedAt.Format(web.Backend.Cfg.TimeFormat),
218+ NumPatchsets: len(patchsets),
219+ })
220+ }
221+
222+ w.Header().Set("content-type", "text/html")
223+ err = repoTmpl.Execute(w, struct {
224+ Name string
225+ Branch string
226+ PRs []PrListItem
227+ MetaData
228+ }{
229+ Name: repoName,
230+ Branch: "main",
231+ PRs: prItems,
232+ MetaData: MetaData{
233+ URL: web.Backend.Cfg.Url,
234+ Desc: template.HTML(web.Backend.Cfg.Desc),
235+ },
236+ })
237+ if err != nil {
238+ web.Backend.Logger.Error("cannot execute template", "err", err)
239+ }
240+}
241+
242+func repoRssHandler(w http.ResponseWriter, r *http.Request) {
243+ web, err := getWebCtx(r)
244+ if err != nil {
245+ w.WriteHeader(http.StatusUnprocessableEntity)
246+ return
247+ }
248+
249+ repoName := r.PathValue("repo")
250+ if repoName == "prs" {
251+ http.Redirect(w, r, "/rss", http.StatusMovedPermanently)
252+ return
253+ }
254+ prs, err := web.Pr.GetPatchRequestsByRepoName(repoName)
255+ if err != nil {
256+ w.WriteHeader(http.StatusInternalServerError)
257+ return
258+ }
259+
260+ desc := fmt.Sprintf("Events related to repo %s on %s", repoName, web.Backend.Cfg.Url)
261+ feed := &feeds.Feed{
262+ Title: fmt.Sprintf("%s repo events", repoName),
263+ Link: &feeds.Link{Href: fmt.Sprintf("https://%s/%s", web.Backend.Cfg.Url, repoName)},
264+ Description: desc,
265+ Author: &feeds.Author{Name: "git collaboration server"},
266+ Created: time.Now(),
267+ }
268+
269+ var feedItems []*feeds.Item
270+ for _, pr := range prs {
271+ eventLogs, err := web.Pr.GetEventLogsByPrID(pr.ID)
272+ if err != nil {
273+ continue
274+ }
275+ for _, eventLog := range eventLogs {
276+ user, err := web.Pr.GetUserByID(eventLog.UserID)
277+ if err != nil {
278+ continue
279+ }
280+ displayName := web.Backend.ComputeUserName(user.Pubkey)
281+ realUrl := fmt.Sprintf("https://%s/%s/%s", web.Backend.Cfg.Url, pr.RepoName, pr.Slug)
282+ content := fmt.Sprintf(
283+ "<div><div>Repo: %s</div><div>Slug: %s</div><div>Event: %s</div><div>Created: %s</div><div>Data: %s</div></div>",
284+ pr.RepoName, pr.Slug, eventLog.Event, eventLog.CreatedAt.Format(time.RFC3339Nano), eventLog.Data,
285+ )
286+ title := fmt.Sprintf(`%s in %s for PR "%s" (%s:%s)`, eventLog.Event, pr.RepoName, pr.Name, pr.RepoName, pr.Slug)
287+ item := &feeds.Item{
288+ Id: fmt.Sprintf("%d", eventLog.ID),
289+ Title: title,
290+ Link: &feeds.Link{Href: realUrl},
291+ Content: content,
292+ Created: eventLog.CreatedAt,
293+ Description: title,
294+ Author: &feeds.Author{Name: displayName},
295+ }
296+ feedItems = append(feedItems, item)
297+ }
298+ }
299+ feed.Items = feedItems
300+
301+ rss, err := feed.ToAtom()
302+ if err != nil {
303+ http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
304+ return
305+ }
306+ w.Header().Add("Content-Type", "application/atom+xml; charset=utf-8")
307+ _, _ = w.Write([]byte(rss))
308+}
309+
310+func renderPrRss(w http.ResponseWriter, r *http.Request, web *WebCtx, pr *PatchRequest) {
311+ desc := fmt.Sprintf(
312+ "Events related to PR %s:%s on %s",
313+ pr.RepoName, pr.Slug, web.Backend.Cfg.Url,
314+ )
315+ feed := &feeds.Feed{
316+ Title: fmt.Sprintf("%s:%s events", pr.RepoName, pr.Slug),
317+ Link: &feeds.Link{Href: fmt.Sprintf("https://%s/%s/%s", web.Backend.Cfg.Url, pr.RepoName, pr.Slug)},
318+ Description: desc,
319+ Author: &feeds.Author{Name: "git collaboration server"},
320+ Created: time.Now(),
321+ }
322+
323+ eventLogs, err := web.Pr.GetEventLogsByPrID(pr.ID)
324+ if err != nil {
325+ w.WriteHeader(http.StatusInternalServerError)
326+ return
327+ }
328+
329+ var feedItems []*feeds.Item
330+ for _, eventLog := range eventLogs {
331+ user, err := web.Pr.GetUserByID(eventLog.UserID)
332+ if err != nil {
333+ continue
334+ }
335+ displayName := web.Backend.ComputeUserName(user.Pubkey)
336+ realUrl := fmt.Sprintf("https://%s/%s/%s", web.Backend.Cfg.Url, pr.RepoName, pr.Slug)
337+ content := fmt.Sprintf(
338+ "<div><div>Repo: %s</div><div>Slug: %s</div><div>Event: %s</div><div>Created: %s</div><div>Data: %s</div></div>",
339+ pr.RepoName, pr.Slug, eventLog.Event, eventLog.CreatedAt.Format(time.RFC3339Nano), eventLog.Data,
340+ )
341+ title := fmt.Sprintf(`%s in %s for PR "%s" (%s:%s)`, eventLog.Event, pr.RepoName, pr.Name, pr.RepoName, pr.Slug)
342+ item := &feeds.Item{
343+ Id: fmt.Sprintf("%d", eventLog.ID),
344+ Title: title,
345+ Link: &feeds.Link{Href: realUrl},
346+ Content: content,
347+ Created: eventLog.CreatedAt,
348+ Description: title,
349+ Author: &feeds.Author{Name: displayName},
350+ }
351+ feedItems = append(feedItems, item)
352+ }
353+ feed.Items = feedItems
354+
355+ rss, err := feed.ToAtom()
356+ if err != nil {
357+ http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
358+ return
359+ }
360+ w.Header().Add("Content-Type", "application/atom+xml; charset=utf-8")
361+ _, _ = w.Write([]byte(rss))
362+}
363+
364+func redirectLegacyPr(w http.ResponseWriter, r *http.Request) {
365+ idPath := r.PathValue("id")
366+ if idPath == "" {
367+ idPath = r.PathValue("slug")
368+ }
369+ if idPath == "" {
370+ http.Redirect(w, r, "/", http.StatusMovedPermanently)
371+ return
372+ }
373+
374+ if idPath == "active" {
375+ http.Redirect(w, r, "/active", http.StatusMovedPermanently)
376+ return
377+ }
378+ if idPath == "inactive" {
379+ http.Redirect(w, r, "/inactive", http.StatusMovedPermanently)
380+ return
381+ }
382+
383+ parts := strings.Split(idPath, "/")
384+ prIDStr := parts[0]
385+ revPart := ""
386+ if dot := strings.LastIndex(prIDStr, "."); dot != -1 {
387+ revPart = prIDStr[dot:]
388+ prIDStr = prIDStr[:dot]
389+ }
390+
391+ prID, err := strconv.ParseInt(prIDStr, 10, 64)
392+ if err != nil {
393+ http.NotFound(w, r)
394+ return
395+ }
396+
397+ web, err := getWebCtx(r)
398+ if err != nil {
399+ http.Error(w, "server error", 500)
400+ return
401+ }
402+
403+ pr, err := web.Pr.GetPatchRequestByID(prID)
404+ if err != nil {
405+ http.NotFound(w, r)
406+ return
407+ }
408+
409+ remainder := ""
410+ if len(parts) > 1 {
411+ remainder = "/" + strings.Join(parts[1:], "/")
412+ }
413+
414+ newURL := fmt.Sprintf("/%s/%s%s%s", pr.RepoName, pr.Slug, revPart, remainder)
415+ http.Redirect(w, r, newURL, http.StatusMovedPermanently)
416+}
417+
186418 func rssHandler(w http.ResponseWriter, r *http.Request) {
187419 web, err := getWebCtx(r)
188420 if err != nil {
......@@ -245,22 +477,23 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
245477 }
246478
247479 displayName := web.Backend.ComputeUserName(user.Pubkey)
248- realUrl := fmt.Sprintf("%s/prs/%d", web.Backend.Cfg.Url, eventLog.PatchRequestID.Int64)
480+ realUrl := fmt.Sprintf("https://%s/%s/%s", web.Backend.Cfg.Url, pr.RepoName, pr.Slug)
249481 content := fmt.Sprintf(
250- "<div><div>Repo: %s</div><div>PatchRequestID: %d</div><div>Event: %s</div><div>Created: %s</div><div>Data: %s</div></div>",
482+ "<div><div>Repo: %s</div><div>Slug: %s</div><div>Event: %s</div><div>Created: %s</div><div>Data: %s</div></div>",
251483 pr.RepoName,
252- eventLog.PatchRequestID.Int64,
484+ pr.Slug,
253485 eventLog.Event,
254486 eventLog.CreatedAt.Format(time.RFC3339Nano),
255487 eventLog.Data,
256488 )
257489
258490 title := fmt.Sprintf(
259- `%s in %s for PR "%s" (#%d)`,
491+ `%s in %s for PR "%s" (%s:%s)`,
260492 eventLog.Event,
261493 pr.RepoName,
262494 pr.Name,
263- eventLog.PatchRequestID.Int64,
495+ pr.RepoName,
496+ pr.Slug,
264497 )
265498 item := &feeds.Item{
266499 Id: fmt.Sprintf("%d", eventLog.ID),
......@@ -402,15 +635,18 @@ func GitWebServer(cfg *GitCfg) http.Handler {
402635 // ensure legacy router is disabled
403636 // GODEBUG=httpmuxgo121=0
404637 mux := http.NewServeMux()
405- mux.HandleFunc("GET /prs/active", ctxMdw(ctx, createPrListHandler("active")))
406- mux.HandleFunc("GET /prs/inactive", ctxMdw(ctx, createPrListHandler("inactive")))
407- mux.HandleFunc("GET /prs/{id}", ctxMdw(ctx, createPrDetail))
408- mux.HandleFunc("GET /prs/{id}/patches/{patchID}", ctxMdw(ctx, createPrDetail))
409- mux.HandleFunc("GET /prs/{id}/rss", ctxMdw(ctx, rssHandler))
410- mux.HandleFunc("GET /rss", ctxMdw(ctx, rssHandler))
411-
412638 mux.HandleFunc("GET /", ctxMdw(ctx, indexHandler))
639+ mux.HandleFunc("GET /active", ctxMdw(ctx, createPrListHandler("active")))
640+ mux.HandleFunc("GET /inactive", ctxMdw(ctx, createPrListHandler("inactive")))
641+ mux.HandleFunc("GET /rss", ctxMdw(ctx, rssHandler))
413642 mux.HandleFunc("GET /syntax.css", ctxMdw(ctx, chromaStyleHandler))
643+
644+ // Repo routes
645+ mux.HandleFunc("GET /{repo}", ctxMdw(ctx, createRepoPrListHandler))
646+
647+ // PR detail routes (supports branches with slashes, .patch, /rss, /patches/{patchID})
648+ mux.HandleFunc("GET /{repo}/{slug...}", ctxMdw(ctx, createPrDetail))
649+
414650 embedFS, err := getEmbedFS(embedStaticFS, "static")
415651 if err != nil {
416652 panic(err)
+78 -28 web_pr_detail.go #
......@@ -6,6 +6,7 @@ import (
66 "net/http"
77 "slices"
88 "strconv"
9+ "strings"
910 "time"
1011
1112 "github.com/bluekeyes/go-gitdiff/gitdiff"
......@@ -30,6 +31,7 @@ type PatchsetData struct {
3031 type PrData struct {
3132 UserData
3233 ID int64
34+ Slug string
3335 Title string
3436 Date string
3537 }
......@@ -133,7 +135,7 @@ func getAllPatchData(web *WebCtx, pr *PatchRequest, ps *Patchset) (*AllPatchData
133135 displayName := web.Backend.ComputeUserName(user.Pubkey)
134136 data := PatchsetData{
135137 Patchset: patchset,
136- FormattedID: getFormattedPatchsetID(pr.ID, idx+1),
138+ FormattedID: fmt.Sprintf("%s/%s.%d", pr.RepoName, pr.Slug, idx+1),
137139 UserData: UserData{
138140 UserID: user.ID,
139141 Name: displayName,
......@@ -297,29 +299,74 @@ func getLogData(web *WebCtx, prID int64, patchsetsData []*PatchsetData) ([]Event
297299 }
298300
299301 func createPrDetail(w http.ResponseWriter, r *http.Request) {
300- id := r.PathValue("id")
301- parsed, err := ParseID(id)
302+ web, err := getWebCtx(r)
302303 if err != nil {
303- w.WriteHeader(http.StatusUnprocessableEntity)
304+ w.WriteHeader(http.StatusInternalServerError)
304305 return
305306 }
306307
307- web, err := getWebCtx(r)
308- if err != nil {
309- w.WriteHeader(http.StatusInternalServerError)
308+ repo := r.PathValue("repo")
309+ slugPath := r.PathValue("slug")
310+
311+ if repo == "prs" {
312+ redirectLegacyPr(w, r)
310313 return
311314 }
312315
313- pr, err := web.Pr.GetPatchRequestByID(parsed.PrID)
314- if err != nil {
315- web.Pr.Backend.Logger.Error("cannot get pr", "err", err)
316- w.WriteHeader(http.StatusNotFound)
316+ // Repo RSS mode (e.g. /{repo}/rss)
317+ if slugPath == "rss" {
318+ repoRssHandler(w, r)
317319 return
318320 }
319321
320- ps, err := GetPatchsetByParsedID(web.Pr, parsed)
322+ // 1. Raw patch mode (.patch suffix)
323+ if strings.HasSuffix(slugPath, ".patch") {
324+ cleanSlug := strings.TrimSuffix(slugPath, ".patch")
325+ pr, ps, err := ResolveTarget(web.Pr, fmt.Sprintf("%s:%s", repo, cleanSlug))
326+ if err != nil {
327+ w.WriteHeader(http.StatusNotFound)
328+ return
329+ }
330+ patches, err := web.Pr.GetPatchesByPatchsetID(ps.ID)
331+ if err != nil {
332+ w.WriteHeader(http.StatusNotFound)
333+ return
334+ }
335+ events, err := web.Pr.GetEventLogsByPrID(ps.PatchRequestID)
336+ if err != nil {
337+ w.WriteHeader(http.StatusNotFound)
338+ return
339+ }
340+ users := resolveUsers(web.Pr, events)
341+ mbox := GenerateMboxWithCoverLetter(pr, patches, events, users, web.Backend.Cfg.Url)
342+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
343+ _, _ = w.Write([]byte(mbox))
344+ return
345+ }
346+
347+ // 2. PR RSS mode (/rss suffix)
348+ if strings.HasSuffix(slugPath, "/rss") {
349+ cleanSlug := strings.TrimSuffix(slugPath, "/rss")
350+ pr, _, err := ResolveTarget(web.Pr, fmt.Sprintf("%s:%s", repo, cleanSlug))
351+ if err != nil {
352+ w.WriteHeader(http.StatusNotFound)
353+ return
354+ }
355+ renderPrRss(w, r, web, pr)
356+ return
357+ }
358+
359+ // 3. Patch diff navigation (/patches/{patchID})
360+ var patchID int64
361+ if idx := strings.Index(slugPath, "/patches/"); idx != -1 {
362+ patchIDStr := slugPath[idx+len("/patches/"):]
363+ patchID, _ = strconv.ParseInt(patchIDStr, 10, 64)
364+ slugPath = slugPath[:idx]
365+ }
366+
367+ pr, ps, err := ResolveTarget(web.Pr, fmt.Sprintf("%s:%s", repo, slugPath))
321368 if err != nil {
322- web.Pr.Backend.Logger.Error("cannot get patchset", "err", err)
369+ web.Pr.Backend.Logger.Error("cannot resolve target", "err", err, "repo", repo, "slug", slugPath)
323370 w.WriteHeader(http.StatusNotFound)
324371 return
325372 }
......@@ -353,12 +400,7 @@ func createPrDetail(w http.ResponseWriter, r *http.Request) {
353400 }
354401
355402 selectedIdx := 0
356- if patchIDStr := r.PathValue("patchID"); patchIDStr != "" {
357- patchID, err := strconv.ParseInt(patchIDStr, 10, 64)
358- if err != nil {
359- w.WriteHeader(http.StatusUnprocessableEntity)
360- return
361- }
403+ if patchID > 0 {
362404 found := false
363405 for idx, summary := range aps.Patches {
364406 if summary.ID == patchID {
......@@ -371,6 +413,15 @@ func createPrDetail(w http.ResponseWriter, r *http.Request) {
371413 w.WriteHeader(http.StatusNotFound)
372414 return
373415 }
416+ } else if patchIDStr := r.PathValue("patchID"); patchIDStr != "" {
417+ if pID, err := strconv.ParseInt(patchIDStr, 10, 64); err == nil {
418+ for idx, summary := range aps.Patches {
419+ if summary.ID == pID {
420+ selectedIdx = idx
421+ break
422+ }
423+ }
424+ }
374425 }
375426
376427 selectedPatch, err := getPatchData(web, aps.Patches[selectedIdx].Patch)
......@@ -380,20 +431,18 @@ func createPrDetail(w http.ResponseWriter, r *http.Request) {
380431 return
381432 }
382433
383- formattedPsID := ""
384- for _, psData := range aps.Patchsets {
385- if psData.ID == ps.ID {
386- formattedPsID = psData.FormattedID
387- break
388- }
434+ rev := getPatchsetRev(web.Pr, ps)
435+ formattedPsID := fmt.Sprintf("%s/%s", pr.RepoName, pr.Slug)
436+ if rev > 0 {
437+ formattedPsID = fmt.Sprintf("%s/%s.%d", pr.RepoName, pr.Slug, rev)
389438 }
390439
391440 var prevUrl, nextUrl string
392441 if selectedIdx > 0 {
393- prevUrl = fmt.Sprintf("/prs/%s/patches/%d", formattedPsID, aps.Patches[selectedIdx-1].ID)
442+ prevUrl = fmt.Sprintf("/%s/patches/%d", formattedPsID, aps.Patches[selectedIdx-1].ID)
394443 }
395444 if selectedIdx < len(aps.Patches)-1 {
396- nextUrl = fmt.Sprintf("/prs/%s/patches/%d", formattedPsID, aps.Patches[selectedIdx+1].ID)
445+ nextUrl = fmt.Sprintf("/%s/patches/%d", formattedPsID, aps.Patches[selectedIdx+1].ID)
397446 }
398447
399448 logData, err := getLogData(web, pr.ID, aps.Patchsets)
......@@ -417,7 +466,8 @@ func createPrDetail(w http.ResponseWriter, r *http.Request) {
417466 NextUrl: nextUrl,
418467 Logs: logData,
419468 Pr: PrData{
420- ID: pr.ID,
469+ ID: pr.ID,
470+ Slug: pr.Slug,
421471 UserData: UserData{
422472 UserID: user.ID,
423473 Name: displayName,
+148 -0 web_test.go #
......@@ -0,0 +1,148 @@
1+package patchbin
2+
3+import (
4+ "context"
5+ "net/http"
6+ "net/http/httptest"
7+ "os"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/picosh/patchbin/util"
13+)
14+
15+func TestWebRoutingAndRedirects(t *testing.T) {
16+ dataDir := util.CreateTmpDir()
17+ defer func() {
18+ _ = os.RemoveAll(dataDir)
19+ }()
20+
21+ suite := setupTest(dataDir, cfgSingleTenantTmpl)
22+ ctx, cancel := context.WithCancel(context.Background())
23+ defer cancel()
24+
25+ s := GitSshServer(ctx, suite.cfg)
26+ go func() {
27+ _ = s.ListenAndServe()
28+ }()
29+ time.Sleep(100 * time.Millisecond)
30+
31+ // Create a PR via SSH: repo=myrepo, slug=feat/auth
32+ output := suite.userKey.MustCmd(suite.patch, "push myrepo:feat/auth")
33+ if !strings.Contains(output, "created") {
34+ t.Fatalf("failed to create PR: %s", output)
35+ }
36+
37+ handler := GitWebServer(suite.cfg)
38+
39+ tests := []struct {
40+ name string
41+ path string
42+ expectedStatus int
43+ expectedHeader map[string]string
44+ containsBody string
45+ }{
46+ {
47+ name: "Home page",
48+ path: "/",
49+ expectedStatus: http.StatusOK,
50+ containsBody: "A pastebin for patches",
51+ },
52+ {
53+ name: "Active PRs page",
54+ path: "/active",
55+ expectedStatus: http.StatusOK,
56+ containsBody: "feat/auth",
57+ },
58+ {
59+ name: "Inactive PRs page",
60+ path: "/inactive",
61+ expectedStatus: http.StatusOK,
62+ },
63+ {
64+ name: "Global RSS feed",
65+ path: "/rss",
66+ expectedStatus: http.StatusOK,
67+ containsBody: "xml",
68+ },
69+ {
70+ name: "Repo page",
71+ path: "/myrepo",
72+ expectedStatus: http.StatusOK,
73+ containsBody: "feat/auth",
74+ },
75+ {
76+ name: "Repo RSS feed",
77+ path: "/myrepo/rss",
78+ expectedStatus: http.StatusOK,
79+ containsBody: "xml",
80+ },
81+ {
82+ name: "PR detail page",
83+ path: "/myrepo/feat/auth",
84+ expectedStatus: http.StatusOK,
85+ containsBody: "feat/auth",
86+ },
87+ {
88+ name: "PR raw mbox patch",
89+ path: "/myrepo/feat/auth.patch",
90+ expectedStatus: http.StatusOK,
91+ containsBody: "From ",
92+ },
93+ {
94+ name: "PR RSS feed",
95+ path: "/myrepo/feat/auth/rss",
96+ expectedStatus: http.StatusOK,
97+ containsBody: "xml",
98+ },
99+ {
100+ name: "Legacy redirect /prs/1",
101+ path: "/prs/1",
102+ expectedStatus: http.StatusMovedPermanently,
103+ expectedHeader: map[string]string{
104+ "Location": "/myrepo/feat/auth",
105+ },
106+ },
107+ {
108+ name: "Legacy redirect /prs/1.patch",
109+ path: "/prs/1.patch",
110+ expectedStatus: http.StatusMovedPermanently,
111+ expectedHeader: map[string]string{
112+ "Location": "/myrepo/feat/auth.patch",
113+ },
114+ },
115+ {
116+ name: "Legacy redirect /prs/1/rss",
117+ path: "/prs/1/rss",
118+ expectedStatus: http.StatusMovedPermanently,
119+ expectedHeader: map[string]string{
120+ "Location": "/myrepo/feat/auth/rss",
121+ },
122+ },
123+ }
124+
125+ for _, tt := range tests {
126+ t.Run(tt.name, func(t *testing.T) {
127+ req := httptest.NewRequest("GET", tt.path, nil)
128+ rec := httptest.NewRecorder()
129+
130+ handler.ServeHTTP(rec, req)
131+
132+ if rec.Code != tt.expectedStatus {
133+ t.Fatalf("GET %s returned status %d, expected %d. Body: %s", tt.path, rec.Code, tt.expectedStatus, rec.Body.String())
134+ }
135+
136+ for header, expVal := range tt.expectedHeader {
137+ gotVal := rec.Header().Get(header)
138+ if gotVal != expVal {
139+ t.Errorf("GET %s header %s = %q, expected %q", tt.path, header, gotVal, expVal)
140+ }
141+ }
142+
143+ if tt.containsBody != "" && !strings.Contains(rec.Body.String(), tt.containsBody) {
144+ t.Errorf("GET %s body does not contain %q", tt.path, tt.containsBody)
145+ }
146+ })
147+ }
148+}
Back to top