patchbin
created pr with
patchbin/144.1
added patchbin/144.2
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 -3checkout specific patchset revision:
ssh pr.pico.sh pull patchbin:144 [rev] | git am -3add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh patchbin:144add comment to patch request:
ssh pr.pico.sh comment patchbin:144 "lgtm!"
Patchset
patchbin/144.2
refactor: user-provided repo:slug instead of referencing prIDs
Eric Bower
2026-10-01T19:17:40ZThis 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.
BREAKING CHANGE: ssh cli has different subcommands
BREAKING CHANGE: users must provide {repo}:{pr_name} for every patch request
Semantic diff summary
13 added,
27 modified,
2 signature changed,
3 removed
across 13 analyzed files
(9 files skipped: unsupported file type)
pr.go
-
chunklines 27-36modified -
chunklines 40-46modified -
method_declarationGetPatchRequestByRepoAndSlugadded -
method_declarationSubmitPatchRequestsignature changed -
function_declarationSubmitPatchRequestmodified -
method_declarationSubmitIssuesignature changed -
function_declarationSubmitIssuemodified
web.go
-
chunklines 12-19modified -
chunklines 31-37modified -
chunklines 46-52modified -
function_declarationcreatePrListHandlermodified -
function_declarationcreateRepoPrListHandleradded -
function_declarationrepoRssHandleradded -
function_declarationrenderPrRssadded -
function_declarationredirectLegacyPradded -
function_declarationrssHandlermodified -
function_declarationGitWebServermodified
+46
-35
README.md
#
| ... | ... | @@ -10,22 +10,24 @@ There's no accept or reject step. A patch request is simply active or inactive: | |
| 10 | 10 | ||
| 11 | 11 | ## quickstart | |
| 12 | 12 | ||
| 13 | - | Submit a patch request: | |
| 13 | + | Submit a patch request (new or follow-up): | |
| 14 | 14 | ||
| 15 | 15 | ``` | |
| 16 | - | git format-patch main --stdout | ssh {url} pr create {repo} | |
| 16 | + | git format-patch main --stdout | ssh {url} {repo}:{slug} | |
| 17 | 17 | ``` | |
| 18 | 18 | ||
| 19 | 19 | Checkout the latest patchset from a patch request: | |
| 20 | 20 | ||
| 21 | 21 | ``` | |
| 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 | |
| 23 | 25 | ``` | |
| 24 | 26 | ||
| 25 | - | Add a follow-up patchset (e.g. after addressing review comments): | |
| 27 | + | View PR metadata and discussion: | |
| 26 | 28 | ||
| 27 | 29 | ``` | |
| 28 | - | git format-patch main --stdout | ssh {url} pr add {prID} | |
| 30 | + | ssh {url} show {repo}:{slug} | |
| 29 | 31 | ``` | |
| 30 | 32 | ||
| 31 | 33 | Help guide: |
| ... | ... | @@ -36,63 +38,72 @@ ssh {url} help | |
| 36 | 38 | ||
| 37 | 39 | ## commands | |
| 38 | 40 | ||
| 39 | - | ### pr - manage patch requests | |
| 41 | + | ### {repo}:{slug} - submit a patchset | |
| 40 | 42 | ||
| 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) | |
| 50 | 51 | ``` | |
| 51 | - | ssh {url} pr edit {prID} "new title" | |
| 52 | + | ssh {url} pull {repo}:{slug} | git am -3 | |
| 52 | 53 | ``` | |
| 53 | - | - `pr comment {prID}` - add a comment to a PR from stdin | |
| 54 | + | - `{repo}:{slug}.patch` - shorthand to pull latest patchset | |
| 54 | 55 | ``` | |
| 55 | - | echo "looks good to me!" | ssh {url} pr comment {prID} | |
| 56 | + | ssh {url} {repo}:{slug}.patch | git am -3 | |
| 56 | 57 | ``` | |
| 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 | |
| 58 | 62 | ``` | |
| 59 | - | ssh {url} pr summary {prID} | |
| 63 | + | ssh {url} show {repo}:{slug} | |
| 60 | 64 | ``` | |
| 61 | - | - `pr ls [repo] [--active|--inactive|--mine]` - list PRs | |
| 65 | + | ||
| 66 | + | ### ls - list patch requests | |
| 67 | + | ||
| 68 | + | - `ls [repo] [--active|--inactive|--mine]` - list PRs | |
| 62 | 69 | ``` | |
| 63 | - | ssh {url} pr ls {repo} | |
| 70 | + | ssh {url} ls {repo} | |
| 64 | 71 | ``` | |
| 65 | 72 | ||
| 66 | - | ### issue - text-only patch requests | |
| 73 | + | ### comment - add a comment | |
| 67 | 74 | ||
| 68 | - | - `issue create {repo} [--title]` - submit a new issue from stdin | |
| 75 | + | - `comment {repo}:{slug} [message]` - add a comment via argument or stdin | |
| 69 | 76 | ``` | |
| 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} | |
| 71 | 79 | ``` | |
| 72 | 80 | ||
| 73 | - | ### ps - manage patchsets | |
| 81 | + | ### edit - rename a PR | |
| 74 | 82 | ||
| 75 | - | - `ps rm {prID}.{rev}` - remove a patchset and its patches (creator only) | |
| 83 | + | - `edit {repo}:{slug} {title}` - rename a PR (creator only) | |
| 76 | 84 | ``` | |
| 77 | - | ssh {url} ps rm {prID}.{rev} | |
| 85 | + | ssh {url} edit {repo}:{slug} "new title" | |
| 78 | 86 | ``` | |
| 79 | 87 | ||
| 80 | - | ### print - print patches for checkout | |
| 88 | + | ### rm - remove a patchset | |
| 81 | 89 | ||
| 82 | - | - `print {prID}` - print the latest patchset for a PR | |
| 90 | + | - `rm {repo}:{slug}.{rev}` - remove a patchset and its patches (creator only) | |
| 83 | 91 | ``` | |
| 84 | - | ssh {url} print {prID} | git am -3 | |
| 92 | + | ssh {url} rm {repo}:{slug}.2 | |
| 85 | 93 | ``` | |
| 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 | |
| 87 | 98 | ``` | |
| 88 | - | ssh {url} print {prID}.{rev} | git am -3 | |
| 99 | + | ssh {url} issue {repo}:{slug} "bug: crash on startup" "steps to reproduce..." | |
| 89 | 100 | ``` | |
| 90 | 101 | ||
| 91 | 102 | ### logs - event history | |
| 92 | 103 | ||
| 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 | |
| 94 | 105 | ``` | |
| 95 | - | ssh {url} logs --pr {prID} | |
| 106 | + | ssh {url} logs --pr {repo}:{slug} | |
| 96 | 107 | ``` | |
| 97 | 108 | ||
| 98 | 109 | ## self-hosting |
+457
-515
cli.go
#
| ... | ... | @@ -41,15 +41,6 @@ func formatTable(sesh io.Writer, render func(w io.Writer)) { | |
| 41 | 41 | } | |
| 42 | 42 | } | |
| 43 | 43 | ||
| 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 | - | ||
| 53 | 44 | // readStdinLimited reads all of stdin, rejecting input over maxBytes rather | |
| 54 | 45 | // than silently truncating it. | |
| 55 | 46 | func readStdinLimited(r io.Reader, maxBytes int64) ([]byte, error) { |
| ... | ... | @@ -64,46 +55,22 @@ func readStdinLimited(r io.Reader, maxBytes int64) ([]byte, error) { | |
| 64 | 55 | return body, nil | |
| 65 | 56 | } | |
| 66 | 57 | ||
| 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) | |
| 94 | 60 | 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) | |
| 96 | 63 | sesh.Printf(" %sDate:%s %s\n", ansiGray, ansiReset, request.CreatedAt.Format(be.Cfg.TimeFormat)) | |
| 97 | 64 | sesh.Printf("\n") | |
| 98 | 65 | ||
| 99 | - | patchsets, err := pr.GetPatchsetsByPrID(prID) | |
| 66 | + | patchsets, err := pr.GetPatchsetsByPrID(request.ID) | |
| 100 | 67 | if err != nil { | |
| 101 | 68 | return err | |
| 102 | 69 | } | |
| 103 | 70 | ||
| 104 | 71 | sesh.Printf("%s▸ Patchsets%s %s(%d total)%s\n", ansiBold, ansiReset, ansiGray, len(patchsets), ansiReset) | |
| 105 | 72 | formatTable(sesh, func(w io.Writer) { | |
| 106 | - | _, _ = fmt.Fprintln(w, " ID\tUser\tDate") | |
| 73 | + | _, _ = fmt.Fprintln(w, " Rev\tTarget\tUser\tDate") | |
| 107 | 74 | for idx, patchset := range patchsets { | |
| 108 | 75 | user, err := pr.GetUserByID(patchset.UserID) | |
| 109 | 76 | if err != nil { |
| ... | ... | @@ -114,27 +81,24 @@ func prSummary(be *Backend, pr GitPatchRequest, sesh *pssh.SSHServerConnSession, | |
| 114 | 81 | ||
| 115 | 82 | _, _ = fmt.Fprintf( | |
| 116 | 83 | 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, | |
| 119 | 89 | displayName, | |
| 120 | 90 | patchset.CreatedAt.Format(be.Cfg.TimeFormat), | |
| 121 | 91 | ) | |
| 122 | 92 | } | |
| 123 | 93 | }) | |
| 124 | 94 | ||
| 125 | - | latest, err := getPatchsetFromOpt(patchsets, "") | |
| 126 | - | if err != nil { | |
| 127 | - | return err | |
| 128 | - | } | |
| 129 | - | ||
| 95 | + | latest := patchsets[len(patchsets)-1] | |
| 130 | 96 | patches, err := pr.GetPatchesByPatchsetID(latest.ID) | |
| 131 | 97 | if err != nil { | |
| 132 | 98 | return err | |
| 133 | 99 | } | |
| 134 | 100 | ||
| 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) | |
| 138 | 102 | formatTable(sesh, func(w io.Writer) { | |
| 139 | 103 | _, _ = fmt.Fprintln(w, " #\tCommit\tAuthor\tDate\tTitle") | |
| 140 | 104 | for idx, patch := range patches { |
| ... | ... | @@ -211,85 +175,62 @@ or inactive: active ones go inactive after 14 days without activity. | |
| 211 | 175 | When a reviewer is happy with the code, they pull it, merge it, and | |
| 212 | 176 | push upstream themselves; there's nothing to manage here beyond that. | |
| 213 | 177 | ||
| 214 | - | COMMANDS | |
| 178 | + | QUICKSTART | |
| 215 | 179 | ||
| 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} | |
| 217 | 182 | ||
| 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 | |
| 221 | 186 | ||
| 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} | |
| 225 | 189 | ||
| 226 | - | pr edit {prID} {title} | |
| 227 | - | Rename a PR. | |
| 228 | - | ssh %[2]s pr edit {prID} "new title" | |
| 190 | + | COMMANDS | |
| 229 | 191 | ||
| 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} | |
| 233 | 195 | ||
| 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 | |
| 237 | 200 | ||
| 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} | |
| 241 | 204 | ||
| 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} | |
| 243 | 208 | ||
| 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} | |
| 247 | 213 | ||
| 248 | - | ps - manage patchsets | |
| 214 | + | edit {repo}:{slug} {title} | |
| 215 | + | Rename a PR (creator only). | |
| 216 | + | ssh %[2]s edit {repo}:{slug} "new title" | |
| 249 | 217 | ||
| 250 | - | ps rm {prID.rev} | |
| 218 | + | rm {repo}:{slug} [rev] | |
| 251 | 219 | 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 | |
| 259 | 221 | ||
| 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..." | |
| 263 | 225 | ||
| 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} | |
| 278 | 229 | ||
| 279 | 230 | GUARDS | |
| 280 | 231 | ||
| 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. | |
| 293 | 234 | ||
| 294 | 235 | Self-host your own patchbin: https://github.com/picosh/patchbin | |
| 295 | 236 | `, 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 | |
| 315 | 256 | }, | |
| 316 | 257 | Commands: []*cli.Command{ | |
| 317 | 258 | { | |
| 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 | + | } | |
| 336 | 267 | ||
| 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 | + | } | |
| 341 | 272 | ||
| 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 | + | } | |
| 347 | 277 | ||
| 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 | + | } | |
| 356 | 282 | ||
| 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 | + | } | |
| 368 | 290 | ||
| 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 | + | } | |
| 373 | 301 | ||
| 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) | |
| 378 | 315 | }, | |
| 379 | 316 | }, | |
| 380 | 317 | { | |
| 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]", | |
| 384 | 372 | 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)", | |
| 388 | 376 | }, | |
| 389 | 377 | &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", | |
| 392 | 384 | }, | |
| 393 | 385 | }, | |
| 394 | 386 | 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 | + | } | |
| 396 | 396 | if err != nil { | |
| 397 | 397 | return err | |
| 398 | 398 | } | |
| 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) | |
| 406 | 407 | } else { | |
| 407 | - | eventLogs, err = pr.GetEventLogs() | |
| 408 | + | sesh.Printf("%s▸ Patch Requests%s %s(%s)%s\n\n", ansiBold, ansiReset, ansiGray, repoName, ansiReset) | |
| 408 | 409 | } | |
| 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) | |
| 411 | 431 | } | |
| 412 | 432 | ||
| 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) | |
| 416 | 435 | return nil | |
| 417 | 436 | } | |
| 418 | 437 | ||
| 419 | 438 | 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 | |
| 425 | 446 | } | |
| 426 | 447 | ||
| 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) | |
| 437 | 449 | ||
| 438 | 450 | _, _ = fmt.Fprintf( | |
| 439 | 451 | 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, | |
| 446 | 459 | ) | |
| 447 | 460 | } | |
| 448 | 461 | }) |
| ... | ... | @@ -450,363 +463,292 @@ Self-host your own patchbin: https://github.com/picosh/patchbin | |
| 450 | 463 | }, | |
| 451 | 464 | }, | |
| 452 | 465 | { | |
| 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 | + | } | |
| 466 | 474 | ||
| 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 | + | } | |
| 471 | 479 | ||
| 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 | + | } | |
| 476 | 484 | ||
| 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 | + | } | |
| 481 | 489 | ||
| 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 | + | } | |
| 485 | 494 | ||
| 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 | + | } | |
| 490 | 505 | ||
| 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 | |
| 496 | 517 | }, | |
| 497 | 518 | }, | |
| 498 | 519 | { | |
| 499 | - | Name: "print", | |
| 500 | - | Usage: "Print patches in a patchset", | |
| 520 | + | Name: "edit", | |
| 521 | + | Usage: "Edit a PR's title (creator only)", | |
| 501 | 522 | Args: true, | |
| 502 | - | ArgsUsage: "[X] or [X.Y]", | |
| 523 | + | ArgsUsage: "<repo>:<slug> <title>", | |
| 503 | 524 | Action: func(cCtx *cli.Context) error { | |
| 504 | 525 | args := cCtx.Args() | |
| 505 | 526 | 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)") | |
| 507 | 528 | } | |
| 508 | 529 | ||
| 509 | - | parsed, err := ParseID(args.First()) | |
| 530 | + | target, err := ParseTarget(args.First()) | |
| 510 | 531 | if err != nil { | |
| 511 | 532 | return err | |
| 512 | 533 | } | |
| 513 | 534 | ||
| 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) | |
| 515 | 549 | if err != nil { | |
| 516 | 550 | return err | |
| 517 | 551 | } | |
| 518 | 552 | ||
| 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 | |
| 520 | 555 | }, | |
| 521 | 556 | }, | |
| 522 | 557 | { | |
| 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 | + | } | |
| 578 | 567 | ||
| 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 | + | } | |
| 582 | 574 | ||
| 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 | + | } | |
| 588 | 579 | ||
| 589 | - | if onlyMine && user.Pubkey != pubkey { | |
| 590 | - | continue | |
| 591 | - | } | |
| 580 | + | user, err := pr.GetUserByID(patchset.UserID) | |
| 581 | + | if err != nil { | |
| 582 | + | return err | |
| 583 | + | } | |
| 592 | 584 | ||
| 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 | + | } | |
| 595 | 588 | ||
| 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 | + | } | |
| 600 | 594 | ||
| 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)", | |
| 627 | 608 | }, | |
| 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 | + | } | |
| 642 | 614 | ||
| 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 | + | } | |
| 648 | 619 | ||
| 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 | + | } | |
| 653 | 624 | ||
| 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 | + | } | |
| 658 | 629 | ||
| 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 | |
| 672 | 632 | ||
| 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:], " ") | |
| 689 | 638 | } | |
| 639 | + | } else { | |
| 640 | + | bodyStr = strings.Join(args.Slice()[1:], " ") | |
| 641 | + | } | |
| 642 | + | } | |
| 690 | 643 | ||
| 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 | + | } | |
| 699 | 651 | ||
| 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 | + | } | |
| 705 | 664 | ||
| 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 | + | } | |
| 711 | 669 | ||
| 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>)", | |
| 714 | 681 | }, | |
| 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", | |
| 762 | 685 | }, | |
| 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 | + | } | |
| 786 | 709 | ||
| 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 | + | } | |
| 791 | 715 | ||
| 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 | + | } | |
| 799 | 727 | } | |
| 800 | 728 | ||
| 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 | + | } | |
| 804 | 738 | } | |
| 805 | 739 | ||
| 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 | |
| 810 | 752 | }, | |
| 811 | 753 | }, | |
| 812 | 754 | }, |
+8
-8
contrib/dev/main.go
#
| ... | ... | @@ -73,18 +73,18 @@ func main() { | |
| 73 | 73 | } | |
| 74 | 74 | ||
| 75 | 75 | // 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") | |
| 78 | 78 | ||
| 79 | 79 | // 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") | |
| 83 | 83 | ||
| 84 | 84 | // 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") | |
| 88 | 88 | ||
| 89 | 89 | fmt.Println("time to do some testing...") | |
| 90 | 90 | ch := make(chan os.Signal, 1) |
+25
-17
e2e_test.go
#
| ... | ... | @@ -35,13 +35,13 @@ func testSingleTenantE2E(t *testing.T) { | |
| 35 | 35 | ||
| 36 | 36 | // Users are auto-created on first use, no registration needed | |
| 37 | 37 | 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") | |
| 39 | 39 | ||
| 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") | |
| 42 | 42 | ||
| 43 | 43 | t.Log("List PRs") | |
| 44 | - | suite.userKey.MustCmd(nil, "pr ls") | |
| 44 | + | suite.userKey.MustCmd(nil, "ls") | |
| 45 | 45 | } | |
| 46 | 46 | ||
| 47 | 47 | func testMultiTenantE2E(t *testing.T) { |
| ... | ... | @@ -65,29 +65,31 @@ func testMultiTenantE2E(t *testing.T) { | |
| 65 | 65 | // Anyone can create PRs in any repo | |
| 66 | 66 | ||
| 67 | 67 | 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 | + | } | |
| 70 | 72 | ||
| 71 | 73 | 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") | |
| 73 | 75 | ||
| 74 | 76 | 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") | |
| 76 | 78 | ||
| 77 | 79 | 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") | |
| 79 | 81 | ||
| 80 | 82 | 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") { | |
| 83 | 85 | t.Fatalf("unexpected comment output: %s", commentOutput) | |
| 84 | 86 | } | |
| 85 | 87 | ||
| 86 | 88 | 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") | |
| 88 | 90 | ||
| 89 | 91 | t.Log("List PRs") | |
| 90 | - | suite.userKey.MustCmd(nil, "pr ls") | |
| 92 | + | suite.userKey.MustCmd(nil, "ls") | |
| 91 | 93 | ||
| 92 | 94 | t.Log("View event logs") | |
| 93 | 95 | logsOutput := suite.userKey.MustCmd(nil, "logs") |
| ... | ... | @@ -95,10 +97,16 @@ func testMultiTenantE2E(t *testing.T) { | |
| 95 | 97 | t.Fatalf("expected pr_commented in event logs, got: %s", logsOutput) | |
| 96 | 98 | } | |
| 97 | 99 | ||
| 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) | |
| 102 | 110 | } | |
| 103 | 111 | } | |
| 104 | 112 |
+11
-0
mdw.go
#
| ... | ... | @@ -10,6 +11,16 @@ func GitPatchRequestMiddleware(be *Backend, pr GitPatchRequest) pssh.SSHServerMi | |
| 10 | 11 | return func(next pssh.SSHServerHandler) pssh.SSHServerHandler { | |
| 11 | 12 | return func(sesh *pssh.SSHServerConnSession) error { | |
| 12 | 13 | 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 | + | } | |
| 13 | 24 | cli := NewCli(sesh, be, pr) | |
| 14 | 25 | margs := append([]string{"git"}, args...) | |
| 15 | 26 | be.Logger.Info("ssh args", "args", args) |
+1
-0
models.go
#
| ... | ... | @@ -33,6 +33,7 @@ type PatchRequest struct { | |
| 33 | 33 | ID int64 `db:"id"` | |
| 34 | 34 | UserID int64 `db:"user_id"` | |
| 35 | 35 | RepoName string `db:"repo_name"` // Plain string namespace | |
| 36 | + | Slug string `db:"slug"` | |
| 36 | 37 | Name string `db:"name"` | |
| 37 | 38 | Text string `db:"text"` | |
| 38 | 39 | CreatedAt time.Time `db:"created_at"` |
+20
-6
pr.go
#
| ... | ... | @@ -27,9 +27,10 @@ type GitPatchRequest interface { | |
| 27 | 27 | GetUserByPubkey(pubkey string) (*User, error) | |
| 28 | 28 | UpsertUserByPubkey(pubkey string) (*User, error) | |
| 29 | 29 | 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) | |
| 31 | 31 | SubmitPatchset(prID, userID int64, op PatchsetOp, patchset io.Reader) ([]*Patch, error) | |
| 32 | 32 | GetPatchRequestByID(prID int64) (*PatchRequest, error) | |
| 33 | + | GetPatchRequestByRepoAndSlug(repoName, slug string) (*PatchRequest, error) | |
| 33 | 34 | GetPatchRequests() ([]*PatchRequest, error) | |
| 34 | 35 | GetPatchRequestsByRepoName(repoName string) ([]*PatchRequest, error) | |
| 35 | 36 | GetPatchRequestsByPubkey(pubkey string) ([]*PatchRequest, error) |
| ... | ... | @@ -39,7 +40,7 @@ type GitPatchRequest interface { | |
| 39 | 40 | GetPatchesByPatchsetID(patchsetID int64) ([]*Patch, error) | |
| 40 | 41 | UpdatePatchRequestName(prID int64, userPubkey string, name string) error | |
| 41 | 42 | 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) | |
| 43 | 44 | AddComment(prID, userID int64, comment string) error | |
| 44 | 45 | CreateEventLog(tx *sqlx.Tx, eventLog EventLog) error | |
| 45 | 46 | GetEventLogs() ([]*EventLog, error) |
| ... | ... | @@ -224,6 +225,17 @@ func (cmd PrCmd) GetPatchRequestByID(prID int64) (*PatchRequest, error) { | |
| 224 | 225 | return &pr, err | |
| 225 | 226 | } | |
| 226 | 227 | ||
| 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 | + | ||
| 227 | 239 | func (cmd PrCmd) updateLastActivity(prID int64) error { | |
| 228 | 240 | _, err := cmd.Backend.DB.Exec( | |
| 229 | 241 | "UPDATE patch_requests SET last_activity=? WHERE id=?", |
| ... | ... | @@ -381,7 +393,7 @@ func (cmd PrCmd) createPatch(tx *sqlx.Tx, patch *Patch) (int64, error) { | |
| 381 | 393 | } | |
| 382 | 394 | ||
| 383 | 395 | // 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) { | |
| 385 | 397 | tx, err := cmd.Backend.DB.Beginx() | |
| 386 | 398 | if err != nil { | |
| 387 | 399 | return nil, err |
| ... | ... | @@ -410,9 +422,10 @@ func (cmd PrCmd) SubmitPatchRequest(userID int64, userPubkey string, repoName st | |
| 410 | 422 | now := time.Now() | |
| 411 | 423 | var prID int64 | |
| 412 | 424 | 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", | |
| 414 | 426 | userID, | |
| 415 | 427 | repoName, | |
| 428 | + | slug, | |
| 416 | 429 | prName, | |
| 417 | 430 | prText, | |
| 418 | 431 | now, |
| ... | ... | @@ -471,7 +484,7 @@ func (cmd PrCmd) SubmitPatchRequest(userID int64, userPubkey string, repoName st | |
| 471 | 484 | ||
| 472 | 485 | // SubmitIssue creates a new patch request as an issue (text-only, no patches). | |
| 473 | 486 | // 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) { | |
| 475 | 488 | if title == "" { | |
| 476 | 489 | return nil, fmt.Errorf("must provide a title for the issue") | |
| 477 | 490 | } |
| ... | ... | @@ -488,9 +501,10 @@ func (cmd PrCmd) SubmitIssue(userID int64, userPubkey string, repoName, title, b | |
| 488 | 501 | now := time.Now() | |
| 489 | 502 | var prID int64 | |
| 490 | 503 | 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", | |
| 492 | 505 | userID, | |
| 493 | 506 | repoName, | |
| 507 | + | slug, | |
| 494 | 508 | title, | |
| 495 | 509 | body, | |
| 496 | 510 | now, |
+7
-0
sqlite.go
#
| ... | ... | @@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS patch_requests ( | |
| 29 | 29 | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 30 | 30 | user_id INTEGER NOT NULL, | |
| 31 | 31 | repo_name TEXT NOT NULL DEFAULT '', | |
| 32 | + | slug TEXT NOT NULL DEFAULT '', | |
| 32 | 33 | name TEXT NOT NULL, | |
| 33 | 34 | text TEXT NOT NULL, | |
| 34 | 35 | created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
| ... | ... | @@ -40,6 +41,8 @@ CREATE TABLE IF NOT EXISTS patch_requests ( | |
| 40 | 41 | ON UPDATE CASCADE | |
| 41 | 42 | ); | |
| 42 | 43 | ||
| 44 | + | CREATE UNIQUE INDEX IF NOT EXISTS patch_requests_repo_slug_idx ON patch_requests (repo_name, slug); | |
| 45 | + | ||
| 43 | 46 | CREATE TABLE IF NOT EXISTS patchsets ( | |
| 44 | 47 | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 45 | 48 | user_id INTEGER NOT NULL, |
| ... | ... | @@ -285,6 +288,10 @@ var sqliteMigrations = []string{ | |
| 285 | 288 | ALTER TABLE tmp_patch_requests_v3 RENAME TO patch_requests; | |
| 286 | 289 | CREATE INDEX IF NOT EXISTS idx_patch_requests_last_activity ON patch_requests(last_activity); | |
| 287 | 290 | 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);`, | |
| 288 | 295 | } | |
| 289 | 296 | ||
| 290 | 297 | // 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 @@ | |
| 22 | 22 | <img src="/static/logo-patchbin.svg" alt="patchbin logo" width="20" height="20" /> | |
| 23 | 23 | <span style="margin-left: 3px;">patchbin</span> | |
| 24 | 24 | </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> | |
| 27 | 27 | <a class="btn-nav flex items-center" href="/rss">rss</a> | |
| 28 | 28 | </nav> | |
| 29 | 29 | </div> |
+1
-1
tmpl/components/commit-list.html
#
| ... | ... | @@ -3,7 +3,7 @@ | |
| 3 | 3 | {{$selectedID := .Patch.ID}} | |
| 4 | 4 | {{range $idx, $patch := .Patches}} | |
| 5 | 5 | <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}}"> | |
| 7 | 7 | <span class="mono truncate" style="flex: 1;"> | |
| 8 | 8 | {{if eq $patch.ID $selectedID}}<span class="text-md" style="color: var(--success);">→</span> {{end}}{{$patch.Title}} | |
| 9 | 9 | </span> |
+10
-7
tmpl/components/pr-header.html
#
| ... | ... | @@ -2,9 +2,9 @@ | |
| 2 | 2 | <header id="top"> | |
| 3 | 3 | <h1 class="text-2xl mb"> | |
| 4 | 4 | <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> | |
| 8 | 8 | </h1> | |
| 9 | 9 | ||
| 10 | 10 | <div class="mb"> |
| ... | ... | @@ -16,13 +16,16 @@ | |
| 16 | 16 | <summary>Help</summary> | |
| 17 | 17 | <div class="group"> | |
| 18 | 18 | 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> | |
| 20 | 20 | ||
| 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> | |
| 23 | 23 | ||
| 24 | 24 | 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> | |
| 26 | 29 | </div> | |
| 27 | 30 | </details> | |
| 28 | 31 | </header> |
+2
-2
tmpl/components/pr-tabs.html
#
| ... | ... | @@ -1,6 +1,6 @@ | |
| 1 | 1 | {{define "pr-tabs"}} | |
| 2 | 2 | <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> | |
| 5 | 5 | </div> | |
| 6 | 6 | {{end}} |
+55
-29
tmpl/pages/index.html
#
| ... | ... | @@ -49,15 +49,17 @@ | |
| 49 | 49 | <h2>Quickstart</h2> | |
| 50 | 50 | ||
| 51 | 51 | <div> | |
| 52 | - | Submit a patch request: | |
| 52 | + | Submit a patch request (new or follow-up): | |
| 53 | 53 | </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> | |
| 55 | 55 | ||
| 56 | 56 | <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> | |
| 58 | 60 | ||
| 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> | |
| 61 | 63 | ||
| 62 | 64 | <div>Help guide:</div> | |
| 63 | 65 | <pre class="m-0">ssh {{.MetaData.URL}} help</pre> |
| ... | ... | @@ -67,61 +69,85 @@ | |
| 67 | 69 | <h2>Commands</h2> | |
| 68 | 70 | ||
| 69 | 71 | <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> | |
| 71 | 73 | <div class="group my"> | |
| 72 | 74 | <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> | |
| 75 | 77 | </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"> | |
| 76 | 84 | <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> | |
| 79 | 87 | </div> | |
| 80 | 88 | <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> | |
| 83 | 91 | </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"> | |
| 84 | 98 | <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> | |
| 87 | 101 | </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"> | |
| 88 | 108 | <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> | |
| 91 | 111 | </div> | |
| 92 | 112 | </div> | |
| 93 | 113 | </details> | |
| 94 | 114 | ||
| 95 | 115 | <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> | |
| 97 | 117 | <div class="group my"> | |
| 98 | 118 | <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> | |
| 101 | 121 | </div> | |
| 102 | 122 | </div> | |
| 103 | 123 | </details> | |
| 104 | 124 | ||
| 105 | 125 | <details class="details-min"> | |
| 106 | - | <summary class="font-bold">ps - manage patchsets</summary> | |
| 126 | + | <summary class="font-bold">edit - rename a PR</summary> | |
| 107 | 127 | <div class="group my"> | |
| 108 | 128 | <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> | |
| 111 | 131 | </div> | |
| 112 | 132 | </div> | |
| 113 | 133 | </details> | |
| 114 | 134 | ||
| 115 | 135 | <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> | |
| 117 | 137 | <div class="group my"> | |
| 118 | 138 | <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> | |
| 121 | 141 | </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"> | |
| 122 | 148 | <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> | |
| 125 | 151 | </div> | |
| 126 | 152 | </div> | |
| 127 | 153 | </details> |
| ... | ... | @@ -130,8 +156,8 @@ | |
| 130 | 156 | <summary class="font-bold">logs - event history</summary> | |
| 131 | 157 | <div class="group my"> | |
| 132 | 158 | <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> | |
| 135 | 161 | </div> | |
| 136 | 162 | </div> | |
| 137 | 163 | </details> |
+15
-15
tmpl/pages/pr.html
#
| ... | ... | @@ -1,13 +1,13 @@ | |
| 1 | - | {{template "base" .}} | |
| 1 | + | {{template "base" .}} | |
| 2 | 2 | ||
| 3 | 3 | {{define "title"}}{{.Pr.Title}} - pr summary{{end}} | |
| 4 | 4 | ||
| 5 | 5 | {{define "meta"}} | |
| 6 | 6 | <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" /> | |
| 9 | 9 | <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}}" /> | |
| 11 | 11 | <meta property="og:type" content="object" /> | |
| 12 | 12 | <meta property="og:site_name" content="{{.MetaData.URL}}" /> | |
| 13 | 13 | {{end}} |
| ... | ... | @@ -16,11 +16,11 @@ | |
| 16 | 16 | <main class="group"> | |
| 17 | 17 | <div class="flex justify-center items-center"> | |
| 18 | 18 | <div class="box container w-full"> | |
| 19 | - | <h1 class="text-xl">{{.RepoName}}</h1> | |
| 19 | + | <h1 class="text-xl"><a href="/{{.RepoName}}">{{.RepoName}}</a></h1> | |
| 20 | 20 | ||
| 21 | 21 | <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;"> | |
| 24 | 24 | rss | |
| 25 | 25 | </a> | |
| 26 | 26 | </div> |
| ... | ... | @@ -31,7 +31,7 @@ | |
| 31 | 31 | {{if eq .Event "pr_patchset_added"}} | |
| 32 | 32 | <details class="mb" style="border: 0; padding: 0;"> | |
| 33 | 33 | <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> | |
| 35 | 35 | <span class="event-meta">on <date>{{.Date}}</date> · by {{template "user-pill" .UserData}}</span> | |
| 36 | 36 | </summary> | |
| 37 | 37 |
| ... | ... | @@ -59,7 +59,7 @@ | |
| 59 | 59 | <div> | |
| 60 | 60 | <span class="font-bold"> | |
| 61 | 61 | {{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> | |
| 63 | 63 | {{else if eq .Event "pr_patchset_deleted"}} | |
| 64 | 64 | deleted <code>{{.FormattedPatchsetID}}</code> | |
| 65 | 65 | {{else if eq .Event "pr_patchset_replaced"}} |
| ... | ... | @@ -81,16 +81,16 @@ | |
| 81 | 81 | <summary>cmds</summary> | |
| 82 | 82 | <div class="group my"> | |
| 83 | 83 | 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> | |
| 85 | 85 | ||
| 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> | |
| 88 | 88 | ||
| 89 | 89 | 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> | |
| 91 | 91 | ||
| 92 | 92 | 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> | |
| 94 | 94 | </div> | |
| 95 | 95 | </details> | |
| 96 | 96 | </div> |
| ... | ... | @@ -100,7 +100,7 @@ | |
| 100 | 100 | <div class="patchset-split"> | |
| 101 | 101 | <h3 class="text-md mb"> | |
| 102 | 102 | Patchset | |
| 103 | - | <a href="/prs/{{.FormattedPatchsetID}}"><code class="border-visited">{{.FormattedPatchsetID}}</code></a> | |
| 103 | + | <a href="/{{.FormattedPatchsetID}}"><code class="border-visited">{{.FormattedPatchsetID}}</code></a> | |
| 104 | 104 | <span class="event-meta">on <date>{{.PatchsetDate}}</date> · commit {{sha .Patch.CommitSha}}</span> | |
| 105 | 105 | </h3> | |
| 106 | 106 |
+3
-3
tmpl/pages/prs.html
#
| ... | ... | @@ -14,12 +14,12 @@ | |
| 14 | 14 | {{range .PRs}} | |
| 15 | 15 | <div class="flex items-center justify-between collapse gap py border-b"> | |
| 16 | 16 | <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> | |
| 19 | 19 | {{if gt .NumPatchsets 1}}<code class="text-sm">{{.NumPatchsets}}</code>{{end}} | |
| 20 | 20 | </div> | |
| 21 | 21 | <div class="flex items-center gap mono text-sm"> | |
| 22 | - | <code class="text-sm">#{{.ID}}</code> | |
| 22 | + | <code class="text-sm">{{.RepoName}}:{{.Slug}}</code> | |
| 23 | 23 | <span>{{.FormattedDate}}</span> | |
| 24 | 24 | </div> | |
| 25 | 25 | </div> |
+23
-8
tmpl/pages/repo.html
#
| ... | ... | @@ -4,31 +4,46 @@ | |
| 4 | 4 | ||
| 5 | 5 | {{define "meta"}} | |
| 6 | 6 | <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" /> | |
| 9 | 9 | {{end}} | |
| 10 | 10 | ||
| 11 | 11 | {{define "body"}} | |
| 12 | 12 | <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> | |
| 14 | 14 | <div class="group"> | |
| 15 | 15 | <details> | |
| 16 | 16 | <summary>Help</summary> | |
| 17 | 17 | <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> | |
| 20 | 20 | <pre class="m-0"># list prs for repo | |
| 21 | - | ssh {{.MetaData.URL}} pr ls {{.Username}}/{{.Name}}</pre> | |
| 21 | + | ssh {{.MetaData.URL}} ls {{.Name}}</pre> | |
| 22 | 22 | </div> | |
| 23 | 23 | </details> | |
| 24 | 24 | </div> | |
| 25 | 25 | </header> | |
| 26 | 26 | ||
| 27 | 27 | <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> | |
| 29 | 44 | </main> | |
| 30 | 45 | ||
| 31 | 46 | <footer class="mt"> | |
| 32 | - | <a href="/r/{{.Username}}/{{.Name}}/rss">rss</a> | |
| 47 | + | <a href="/{{.Name}}/rss">rss</a> | |
| 33 | 48 | </footer> | |
| 34 | 49 | {{end}} |
+93
-0
util.go
#
| ... | ... | @@ -46,6 +46,99 @@ func GetAuthorizedKeys(pubkeys []string) ([]ssh.PublicKey, error) { | |
| 46 | 46 | return keys, nil | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 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 | + | ||
| 49 | 142 | type ParsedID struct { | |
| 50 | 143 | PrID int64 | |
| 51 | 144 | 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) { | |
| 130 | 130 | t.Errorf("expected empty string, got %q", got) | |
| 131 | 131 | } | |
| 132 | 132 | } | |
| 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
#
| ... | ... | @@ -183,6 +188,233 @@ func shaFn(sha string) string { | |
| 183 | 188 | return truncateSha(sha) | |
| 184 | 189 | } | |
| 185 | 190 | ||
| 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 | + | ||
| 186 | 418 | func rssHandler(w http.ResponseWriter, r *http.Request) { | |
| 187 | 419 | web, err := getWebCtx(r) | |
| 188 | 420 | if err != nil { |
| ... | ... | @@ -245,22 +477,23 @@ func rssHandler(w http.ResponseWriter, r *http.Request) { | |
| 245 | 477 | } | |
| 246 | 478 | ||
| 247 | 479 | 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) | |
| 249 | 481 | 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>", | |
| 251 | 483 | pr.RepoName, | |
| 252 | - | eventLog.PatchRequestID.Int64, | |
| 484 | + | pr.Slug, | |
| 253 | 485 | eventLog.Event, | |
| 254 | 486 | eventLog.CreatedAt.Format(time.RFC3339Nano), | |
| 255 | 487 | eventLog.Data, | |
| 256 | 488 | ) | |
| 257 | 489 | ||
| 258 | 490 | title := fmt.Sprintf( | |
| 259 | - | `%s in %s for PR "%s" (#%d)`, | |
| 491 | + | `%s in %s for PR "%s" (%s:%s)`, | |
| 260 | 492 | eventLog.Event, | |
| 261 | 493 | pr.RepoName, | |
| 262 | 494 | pr.Name, | |
| 263 | - | eventLog.PatchRequestID.Int64, | |
| 495 | + | pr.RepoName, | |
| 496 | + | pr.Slug, | |
| 264 | 497 | ) | |
| 265 | 498 | item := &feeds.Item{ | |
| 266 | 499 | Id: fmt.Sprintf("%d", eventLog.ID), |
| ... | ... | @@ -402,15 +635,18 @@ func GitWebServer(cfg *GitCfg) http.Handler { | |
| 402 | 635 | // ensure legacy router is disabled | |
| 403 | 636 | // GODEBUG=httpmuxgo121=0 | |
| 404 | 637 | 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 | - | ||
| 412 | 638 | 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)) | |
| 413 | 642 | 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 | + | ||
| 414 | 650 | embedFS, err := getEmbedFS(embedStaticFS, "static") | |
| 415 | 651 | if err != nil { | |
| 416 | 652 | panic(err) |
+78
-28
web_pr_detail.go
#
| ... | ... | @@ -133,7 +135,7 @@ func getAllPatchData(web *WebCtx, pr *PatchRequest, ps *Patchset) (*AllPatchData | |
| 133 | 135 | displayName := web.Backend.ComputeUserName(user.Pubkey) | |
| 134 | 136 | data := PatchsetData{ | |
| 135 | 137 | Patchset: patchset, | |
| 136 | - | FormattedID: getFormattedPatchsetID(pr.ID, idx+1), | |
| 138 | + | FormattedID: fmt.Sprintf("%s/%s.%d", pr.RepoName, pr.Slug, idx+1), | |
| 137 | 139 | UserData: UserData{ | |
| 138 | 140 | UserID: user.ID, | |
| 139 | 141 | Name: displayName, |
| ... | ... | @@ -297,29 +299,74 @@ func getLogData(web *WebCtx, prID int64, patchsetsData []*PatchsetData) ([]Event | |
| 297 | 299 | } | |
| 298 | 300 | ||
| 299 | 301 | func createPrDetail(w http.ResponseWriter, r *http.Request) { | |
| 300 | - | id := r.PathValue("id") | |
| 301 | - | parsed, err := ParseID(id) | |
| 302 | + | web, err := getWebCtx(r) | |
| 302 | 303 | if err != nil { | |
| 303 | - | w.WriteHeader(http.StatusUnprocessableEntity) | |
| 304 | + | w.WriteHeader(http.StatusInternalServerError) | |
| 304 | 305 | return | |
| 305 | 306 | } | |
| 306 | 307 | ||
| 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) | |
| 310 | 313 | return | |
| 311 | 314 | } | |
| 312 | 315 | ||
| 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) | |
| 317 | 319 | return | |
| 318 | 320 | } | |
| 319 | 321 | ||
| 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)) | |
| 321 | 368 | 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) | |
| 323 | 370 | w.WriteHeader(http.StatusNotFound) | |
| 324 | 371 | return | |
| 325 | 372 | } |
| ... | ... | @@ -353,12 +400,7 @@ func createPrDetail(w http.ResponseWriter, r *http.Request) { | |
| 353 | 400 | } | |
| 354 | 401 | ||
| 355 | 402 | 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 { | |
| 362 | 404 | found := false | |
| 363 | 405 | for idx, summary := range aps.Patches { | |
| 364 | 406 | if summary.ID == patchID { |
| ... | ... | @@ -371,6 +413,15 @@ func createPrDetail(w http.ResponseWriter, r *http.Request) { | |
| 371 | 413 | w.WriteHeader(http.StatusNotFound) | |
| 372 | 414 | return | |
| 373 | 415 | } | |
| 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 | + | } | |
| 374 | 425 | } | |
| 375 | 426 | ||
| 376 | 427 | selectedPatch, err := getPatchData(web, aps.Patches[selectedIdx].Patch) |
| ... | ... | @@ -380,20 +431,18 @@ func createPrDetail(w http.ResponseWriter, r *http.Request) { | |
| 380 | 431 | return | |
| 381 | 432 | } | |
| 382 | 433 | ||
| 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) | |
| 389 | 438 | } | |
| 390 | 439 | ||
| 391 | 440 | var prevUrl, nextUrl string | |
| 392 | 441 | 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) | |
| 394 | 443 | } | |
| 395 | 444 | 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) | |
| 397 | 446 | } | |
| 398 | 447 | ||
| 399 | 448 | logData, err := getLogData(web, pr.ID, aps.Patchsets) |
+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 | + | } |