Commit 5886128
Eric Bower
·
2026-02-25 22:52:05 -0500 EST
parent eb3a9e1
refactor: anonymous system with less pr mgmt
This marks are major departure from how patch requests work internally and
externally.
The goal is to focus on the core PR loop and remove any other distractions.
We have replaced the permission model and removed all permissioned interactions.
There are no only 2 states for a PR: draft and open
Draft provides contributors the opportunity to work on a PR before formally
submitting it which means it will not show up in the RSS feeds.
Open just means it shows up in RSS.
We have removed the single/multi tenant concept and now have a single paradigm.
Anyone can submit PRs to any "repo" and only admins own repos.
Anyone can submit patchsets on top of a PR, allowing for easier collaboration.
There's no concept of reviewing a PR: everyone just submits patchsets on top of
each other.
feat: issues api
Issues are a thin wrapper around patch requests. The idea is that if an issue
is actionable then users can work on the issue by submitting code.
echo "does this work?" | ssh {host} issue create --repo xxx
This creates the patch request in the "open" state and converts the text into
a cover-letter with no diff (so it's an empty commit).
feat: generate cover letter with pr history
This is an attempt to preserve the history of a patch request inside of a cover
letter, empty git commit.
We store the revisions and comments on a patch request inside the message body
of a commit.
Example:
```
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: git-pr <git-pr@git-pr.example.com>
Date: Wed, 15 Jan 2025 10:30:00 +0000
Subject: [git-pr #42] feat: add JWT auth middleware
References: https://git-pr.example.com/pr/42
[2025-01-15T10:30:00Z] SHA256:3df5270c...:
Submitted revision ps-1
[2025-01-15T10:30:00Z] SHA256:3df5270c...:
Initial submission. Adds JWT-based auth for API endpoints.
[2025-01-16T09:00:00Z] SHA256:bb28e0ce...:
Submitted revision ps-3
[2025-01-16T09:00:00Z] SHA256:bb28e0ce...:
LGTM. One suggestion: add rate limiting to prevent abuse.
[2025-01-17T11:00:00Z] SHA256:3df5270c...:
Submitted revision ps-5
[2025-01-17T11:00:00Z] SHA256:3df5270c...:
Added rate limiting. Thanks for the review!
[2025-01-18T16:00:00Z] SHA256:bb28e0ce...:
Submitted revision ps-7
[2025-01-18T16:00:00Z] SHA256:bb28e0ce...:
Looks good now. Ready to merge.
```
This provides some benefits:
- Git log will show the history of the patch request (revs, comments, etc.)
- You can use git-pr without worrying about losing metadata
- It automatically links to the git-pr url (link-back)
This provides a convenient audit trail that the maintainer doesn't have to
create by hand.
If the person submitting the patch request also provides a cover letter,
we perform a merge.
The downside to this strategy is we are now generating an empty commit at the
start of every patch request. Thankfully cover letters don't need any shasums
and mostly informational.
Further, an additional flag is required to keep empty commits when performing
`git am`:
```
git am --keep-empty
```
This also has a global setting:
```
git config --global am.keepEmpty true
```
feat: semantic summary
The default review experience is not a line-diff, rather a semantic diff where
we should a list of methods, functions that have changed, added, removed. This
creates an overview of the change for at-a-glance review which is the intended
design of the web viewer. A full PR review should happen in your local editor.
We perform a semantic summary by using tree-sitter. Current languages
supported: go, rust, python, ts, js
55 files changed,
+4795,
-4074
+1,
-1
1@@ -18,4 +18,4 @@ review.patch
2 .aider*
3 git_src/
4 .beads/
5-git-pr
6+patchbin
+26,
-0
1@@ -2,6 +2,32 @@
2
3 Use spec: https://common-changelog.org/
4
5+## Staged
6+
7+### Added
8+
9+- Issues API: a thin wrapper around patch requests for actionable text-only submissions
10+ - `echo "does this work?" | ssh {host} issue create --repo xxx`
11+ - Creates a PR in the "open" state with an empty-commit cover letter
12+- Cover letter now preserves full PR history (revisions, comments) in the commit message body, with a link back to the PR
13+- Semantic summary view: shows changed/added/removed functions and methods for at-a-glance review instead of a line diff (via tree-sitter; supports Go, Rust, Python, TS, JS)
14+- Abuse guards on submissions (`pr create`, `pr add`, `issue create`): a global rate limit (configurable via `rate_limit_count`/`rate_limit_interval` in the toml) and a max stdin size (configurable via `max_stdin_bytes`); both are documented in the SSH CLI help text
15+
16+### Changed
17+
18+- *BREAKING*: Renamed the project from `git-pr` to `patchbin`, including the binary (`cmd/git-pr` to `cmd/patchbin`), default config file (`git-pr.toml` to `patchbin.toml`), and stylesheet (`static/git-pr.css` to `static/patchbin.css`)
19+- Replaced the permission model: PRs now have only two states, `draft` and `open` (draft is hidden from RSS feeds, open is not)
20+- Removed the single/multi-tenant concept in favor of one paradigm: anyone can submit PRs to any repo, but only admins own repos
21+- Anyone can submit patchsets on top of a PR for collaboration; there is no separate review step, only stacked patchsets
22+- `git am` now requires `--keep-empty` (or `git config --global am.keepEmpty true`) to retain cover letter commits
23+
24+### Removed
25+
26+- All repo/user index pages (to be reintroduced later)
27+- `create_repo` config field, which gated who could create repos (`admin` vs `user`); anyone can now create repos under the anonymous model
28+
29+### Fixed
30+
31 ## v2026-02-25
32
33 ### Added
+1,
-1
1@@ -48,6 +48,6 @@
2 }
3
4 :443 {
5- reverse_proxy git-pr:3000
6+ reverse_proxy patchbin:3000
7 encode zstd gzip
8 }
+3,
-3
1@@ -22,7 +22,7 @@ ENV LDFLAGS="-s -w"
2
3 ENV GOOS=${TARGETOS} GOARCH=${TARGETARCH}
4
5-RUN go build -ldflags "$LDFLAGS" -o /go/bin/git-pr ./cmd/git-pr
6+RUN go build -ldflags "$LDFLAGS" -o /go/bin/patchbin ./cmd/patchbin
7
8 FROM scratch as release
9
10@@ -30,6 +30,6 @@ WORKDIR /app
11 ENV TERM="xterm-256color"
12
13 COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
14-COPY --from=builder /go/bin/git-pr ./git-pr
15+COPY --from=builder /go/bin/patchbin ./patchbin
16
17-CMD ["/app/git-pr"]
18+CMD ["/app/patchbin"]
M
Makefile
+9,
-15
1@@ -1,7 +1,6 @@
2 DOCKER_TAG?=$(shell git log --format="%H" -n 1)
3 DOCKER_PLATFORM?=linux/amd64,linux/arm64
4-DOCKER_CMD?=docker
5-DOCKER_BUILDX_BUILD?=$(DOCKER_CMD) buildx build --push --platform $(DOCKER_PLATFORM) -t
6+DOCKER_BUILDX_BUILD?=docker buildx build --push --platform $(DOCKER_PLATFORM) -t
7
8 fmt:
9 go fmt ./...
10@@ -23,27 +22,22 @@ snapshot:
11 .PHONY: snapshot
12
13 build:
14- go build -o ./build/git-pr ./cmd/git-pr
15+ go build -o ./build/patchbin ./cmd/patchbin
16 .PHONY: build
17
18 bp-setup:
19-ifeq ($(DOCKER_CMD),docker)
20- $(DOCKER_CMD) buildx ls | grep pico || $(DOCKER_CMD) buildx create --name pico
21- $(DOCKER_CMD) buildx use pico
22-else
23- # podman
24-endif
25+ docker buildx ls | grep pico || docker buildx create --name pico
26+ docker buildx use pico
27 .PHONY: bp-setup
28
29 bp: bp-setup
30- $(DOCKER_BUILDX_BUILD) ghcr.io/picosh/pico/git-pr:$(DOCKER_TAG) --target release .
31-ifeq ($(DOCKER_CMD),docker)
32- # docker
33-else
34- podman manifest push ghcr.io/picosh/pico/git-pr:$(DOCKER_TAG)
35-endif
36+ $(DOCKER_BUILDX_BUILD) ghcr.io/picosh/patchbin:$(DOCKER_TAG) --target release .
37 .PHONY: bp
38
39 smol:
40 curl https://pico.sh/smol.css -o ./static/smol.css
41 .PHONY: smol
42+
43+backup:
44+ scp pico.ash.3:git-pr/data/git-pr/data/pr.db ./data/prod.db
45+.PHONY: backup
+131,
-163
1@@ -1,209 +1,177 @@
2-
3+# patchbin
4
5-# `pico/git-pr` a self-hosted git collaboration server
6+A pastebin for patches, supercharged for git collaboration.
7
8-We are trying to build the simplest git collaboration tool. The goal is to make
9-self-hosting a git server as simple as running an SSH server -- all without
10-sacrificing external collaborators time and energy.
11+Contributions are designed to be anonymous: the quality of your work is what matters. No signup required, just connect with an SSH key.
12
13-> `git format-patch` isn't the problem and pull requests aren't the solution.
14+The target project doesn't need to run patchbin for someone to submit a patch request against it. It works like a pull request, except both sides collaborate by sending rounds of patchsets: a contributor sends patches, a reviewer replies with their own patches on top, back and forth, as commits rather than comments. The result is a collaborative workspace built entirely out of patches. Reviewing means pulling the code down, not clicking through a diff viewer. Issues work the same way: an issue is just a patch request without any code attached yet, and anyone can follow up with a real patch request on top of it.
15
16-We are combining mailing list and pull request workflows. In order to build the
17-simplest collaboration tool, we needed something as simple as generating patches
18-but the ease-of-use of pull requests.
19+There's no accept or reject step. A patch request is simply active or inactive: active ones go inactive after 30 days without activity. When a reviewer is happy with the code, they pull it, merge it, and push upstream themselves; there's nothing to manage here beyond that.
20
21-The goal is not to create another code forge here. The goal is to create a very
22-simple self-hosted git solution with the ability to collaborate with external
23-contributors. All the code owner needs to setup a running git server:
24+## quickstart
25
26-- A single golang binary
27+Submit a patch request (starts as a draft, visible only to you):
28
29-All an external contributor needs is:
30-
31-- An SSH keypair
32-- An SSH client
33-
34-# demo video
35-
36-https://youtu.be/d28Dih-BBUw
37-
38-# the problem
39-
40-Email is great as a decentralized system to send and receive changes (patchsets)
41-to a git repo. However, onboarding a new user to a mailing list, properly
42-setting up their email client, and then finally submitting the code contribution
43-is enough to make many developers give up. Further, because we are leveraging
44-the email protocol for collaboration, we are limited by its feature-set. For
45-example, it is not possible to make edits to emails, everyone has a different
46-client, those clients have different limitations around plain text email and
47-downloading patches from it.
48-
49-Github pull requests are easy to use, easy to edit, and easy to manage. The
50-downside is it forces the user to be inside their website to perform reviews.
51-For quick changes, this is great, but when you start reading code within a web
52-browser, there are quite a few downsides. At a certain point, it makes more
53-sense to review code inside your local development environment, IDE, etc. There
54-are tools and plugins that allow users to review PRs inside their IDE, but it
55-requires a herculean effort to make it usable.
56-
57-Further, self-hosted solutions that mimic a pull request require a lot of
58-infrastructure in order to manage it. A database, a web site connected to git,
59-admin management, and services to manage it all. Another big point of friction:
60-before an external user submits a code change, they first need to create an
61-account and then login. This adds quite a bit of friction for a self-hosted
62-solution, not only for an external contributor, but also for the code owner who
63-has to provision the infra. Often times they also have to fork the repo within
64-the code forge before submitting a PR. Then they never make a contribution ever
65-again and a forked repo lingers. That seems silly.
66-
67-# introducing patch requests (PR)
68-
69-Instead, we want to create a self-hosted git "server" that can handle sending
70-and receiving patches without the cumbersome nature of setting up email or the
71-limitations imposed by the email protocol. Further, we want the primary workflow
72-to surround the local development environment. Github is bringing the IDE to the
73-browser in order to support their workflow, we want to flip that idea on its
74-head by making code reviews a first-class citizen inside your local development
75-environment. This has an interesting side-effect: the owner is placed in a more
76-collaborative role because they must create at least one patch to submit a
77-review. They are already in their local editor, they are already creating a git
78-commit and "pushing" it, so naturally it is easier to make code changes during
79-the review itself.
80-
81-We see this as a hybrid between the github workflow of a pull request and
82-sending and receiving patches over email.
83+```
84+git format-patch main --stdout | ssh {url} pr create {repo}
85+```
86
87-The basic idea is to leverage an SSH app to handle most of the interaction
88-between contributor and owner of a project. Everything can be done completely
89-within the terminal, in a way that is ergonomic and fully featured.
90+Open it so others can see it (also enables RSS notifications):
91
92-The web view is mostly for discovery.
93+```
94+ssh {url} pr open {prID}
95+```
96
97-Notifications would happen with RSS and all state mutations would result in the
98-generation of static web assets so the web views can be hosted using a simple
99-web file server.
100+Checkout the latest patchset from a patch request:
101
102-## format-patch workflow
103+```
104+ssh {url} print pr-{prID} | git am -3
105+```
106
107-```bash
108-# Owner hosts repo `test.git` using github
109+Add a follow-up patchset (e.g. after addressing review comments):
110
111-# Contributor clones repo
112-git clone git@github.com:picosh/test.git
113+```
114+git format-patch main --stdout | ssh {url} pr add {prID}
115+```
116
117-# Contributor wants to make a change
118-# Contributor makes changes via commits
119-git add -A && git commit -m "fix: some bugs"
120+Help guide:
121
122-# Contributor runs:
123-git format-patch origin/main --stdout | ssh pr.pico.sh pr create test
124-# > Patch Request has been created (ID: 1)
125+```
126+ssh {url} help
127+```
128
129-# Owner can checkout patch:
130-ssh pr.pico.sh pr print 1 | git am -3
131-# Owner can comment (IN CODE), commit, then send another format-patch
132-# on top of the PR:
133-git format-patch origin/main --stdout | ssh pr.pico.sh pr add --review 1
134-# UI clearly marks patch as a review
135+## commands
136+
137+### pr - manage patch requests
138+
139+- `pr create {repo}` - submit a new PR from stdin (starts as draft)
140+ ```
141+ git format-patch main --stdout | ssh {url} pr create {repo}
142+ ```
143+- `pr add {prID}` - add a new patchset to an existing PR from stdin
144+ ```
145+ git format-patch main --stdout | ssh {url} pr add {prID}
146+ ```
147+- `pr open {prID} [--comment]` - transition draft open, enables RSS notifications
148+ ```
149+ ssh {url} pr open {prID}
150+ ```
151+- `pr draft {prID} [--comment]` - transition open draft, disables RSS notifications
152+ ```
153+ ssh {url} pr draft {prID}
154+ ```
155+- `pr edit {prID} {title}` - rename a PR
156+ ```
157+ ssh {url} pr edit {prID} "new title"
158+ ```
159+- `pr summary {prID}` - show metadata, patchsets, and patches for a PR
160+ ```
161+ ssh {url} pr summary {prID}
162+ ```
163+- `pr ls [repo] [--draft|--open|--active|--inactive|--mine]` - list PRs
164+ ```
165+ ssh {url} pr ls {repo} --open
166+ ```
167+
168+### issue - text-only patch requests
169+
170+- `issue create {repo} [--title]` - submit a new issue from stdin (starts as open)
171+ ```
172+ echo "steps to reproduce..." | ssh {url} issue create {repo} --title "bug: crash on startup"
173+ ```
174+
175+### ps - manage patchsets
176+
177+- `ps rm {patchsetID}` - remove a patchset and its patches (creator only)
178+ ```
179+ ssh {url} ps rm ps-{patchsetID}
180+ ```
181+
182+### print - print patches for checkout
183+
184+- `print pr-{prID}` - print the latest patchset for a PR
185+ ```
186+ ssh {url} print pr-{prID} | git am -3
187+ ```
188+- `print ps-{patchsetID}` - print a specific patchset
189+ ```
190+ ssh {url} print ps-{patchsetID} | git am -3
191+ ```
192+
193+### logs - event history
194+
195+- `logs [--pr ID] [--pubkey]` - list event logs, optionally filtered to a PR or your own activity
196+ ```
197+ ssh {url} logs --pr {prID}
198+ ```
199+
200+## self-hosting
201+
202+patchbin needs a `patchbin.toml` config file and a data directory (for the sqlite db and SSH host keys).
203+
204+[Copy](./patchbin.toml) or create a `patchbin.toml` file inside a `./data` directory:
205
206-# Contributor can checkout reviews
207-ssh pr.pico.sh pr print 1 | git am -3
208+```
209+mkdir -p data
210+cp patchbin.toml ./data/patchbin.toml
211+vim ./data/patchbin.toml
212+```
213
214-# Owner can reject a pr:
215-ssh pr.pico.sh pr close 1
216+### docker-compose
217
218-# Owner can accept a pr:
219-ssh pr.pico.sh pr accept 1
220+The included `docker-compose.yml` pulls the published image and mounts a local data directory:
221
222-# Owner can prep PR for upstream:
223-git rebase -i origin/main
224+```
225+services:
226+ patchbin:
227+ image: ghcr.io/picosh/pico/patchbin:latest
228+ restart: always
229+ volumes:
230+ - ./data/patchbin/data:/app/data
231+```
232
233-# Then push to upstream
234-git push origin main
235+Place `patchbin.toml` inside `./data/patchbin/data`, then run:
236
237-# Done!
238+```
239+docker compose up -d
240 ```
241
242-The fundamental collaboration tool here is `format-patch`. Whether you are
243-submitting code changes or reviewing them, it all happens in code. Both
244-contributor and owner are simply creating new commits and generating patches on
245-top of each other. This obviates the need to have a web viewer where the
246-reviewer can "comment" on a line of code block. There's no need, apply the
247-contributor's patches, write comments or code changes, generate a new patch,
248-send the patch to the git server as a "review." This flow also works the exact
249-same if two users are collaborating on a set of changes.
250+### docker image
251
252-This also solves the problem of sending multiple patchsets for the same code
253-change. There's a single, central Patch Request where all changes and
254-collaboration happens.
255+Run the image directly, mounting your data directory to `/app/data`:
256
257-We could figure out a way to leverage `git notes` for reviews / comments, but
258-honestly, that solution feels brutal and outside the comfort level of most git
259-users. Just send reviews as code and write comments in the programming language
260-you are using. It's the job of the contributor to "address" those comments and
261-then remove them in subsequent patches. This is the forcing function to address
262-all comments: the patch won't be merged if there are comment unaddressed in
263-code; they cannot be ignored or else they will be upstreamed erroneously.
264+```
265+docker run -d -v ./data:/app/data ghcr.io/picosh/pico/patchbin:latest
266+```
267
268-# installation and setup
269+`patchbin.toml` must live inside the mounted `./data` directory, since that's the default config path the binary looks for.
270
271-## setup
272+### from go source
273
274-[Copy](./git-pr.toml) or create a `git-pr.toml` file inside `./data` directory:
275+Clone the repo, then build and run the binary:
276
277-```bash
278-mkdir data
279-vim ./data/git-pr.toml
280-# configure file
281 ```
282-
283-## docker
284-
285-Run the app image:
286-
287-```bash
288-docker run -d -v ./data:/app/data ghcr.io/picosh/pico/git-pr:latest
289+make build
290+./build/patchbin --config ./data/patchbin.toml
291 ```
292
293-## golang
294+Or without the Makefile:
295
296-Clone this repo and then build the go binaries:
297-
298-```bash
299-make build
300 ```
301-
302-```bash
303-./build/git-pr --config ./data/git-pr.toml
304+go build -o ./build/patchbin ./cmd/patchbin
305+./build/patchbin --config ./data/patchbin.toml
306 ```
307
308-## done!
309+### done
310
311-Access the ssh app:
312+Access the SSH app:
313
314-```bash
315+```
316 ssh -p 2222 localhost help
317 ```
318
319 Access the web app:
320
321-```bash
322+```
323 curl localhost:3000
324 ```
325-
326-# roadmap
327-
328-> [!IMPORTANT]\
329-> This project is being actively developed and we have not reached alpha status
330-> yet.
331-
332-1. Commenting system (git notes?)
333-1. Support a `diff` workflow (convert `git diff` into mbox patch format)
334-1. Moderation tooling
335-1. Adapter to statically generate web view
336-
337-## ideas
338-
339-1. TUI?
340-1. PR build steps? (e.g. ci/cd, status checks, merge checks)
341-1. Bulk modify PRs? (rsync, sftp, sshfs)
+13,
-57
1@@ -1,68 +1,24 @@
2-
3 [TestE2E - 1]
4-ID RepoID Name Status Patchsets User Date
5-2 test feat: lets build an rnn [open] 1 contributor
6-1 test feat: lets build an rnn [open] 1 admin
7+ID Repo Name Status Patchsets User Date
8+2 test feat: lets build an rnn [draft] 1 b6caf904
9+1 test feat: lets build an rnn [draft] 1 582df2ac
10
11 ---
12
13 [TestE2E - 2]
14-PR submitted! Use the ID for interacting with this PR.
15-Info
16-====
17-URL: https://localhost/prs/10
18-Repo: contributor/bin
19-
20-ID Name Status Date
21-10 feat: lets build an rnn [open]
22-
23-Patchsets
24-====
25-ID Type User Date
26-ps-14 contributor
27-
28-Patches from latest patchset
29-====
30-Idx Title Commit Author Date
31-0 feat: lets build an rnn 5945657 Eric Bower <me@erock.io>
32+ID Repo Name Status Patchsets User Date
33+3 draft-repo feat: lets build an rnn [open] 1 b6caf904
34+2 admin-repo feat: lets build an rnn [draft] 1 582df2ac
35+1 test feat: lets build an rnn [open] 3 b6caf904
36
37 ---
38
39 [TestE2E - 3]
40-ID RepoID Name Status Patchsets User Date
41-10 contributor/bin feat: lets build an rnn [open] 1 contributor
42-9 admin/ai feat: lets build an rnn [accepted] 1 contributor
43-8 admin/ai feat: lets build an rnn [accepted] 2 contributor
44-7 contributor/ai feat: lets build an rnn [accepted] 1 admin
45-6 contributor/test Closed patch with review [closed] 2 contributor
46-5 contributor/test Accepted patch with review [accepted] 2 contributor
47-4 contributor/test Reviewed patch [open] 2 contributor
48-3 contributor/test Closed patch (contributor) [closed] 1 contributor
49-2 contributor/test Closed patch (admin) [closed] 1 contributor
50-1 admin/test Accepted patch [accepted] 1 contributor
51-
52----
53-
54-[TestE2E - 4]
55-RepoID PrID PatchsetID Event Created Data
56-admin/ai 8 ps-11 pr_created
57-admin/ai 8 ps-12 pr_patchset_added
58-admin/ai 8 pr_status_changed {"status":"accepted"}
59-admin/ai 9 ps-13 pr_created
60-admin/ai 9 pr_status_changed {"status":"accepted","comment":"nice work"}
61-
62----
63-
64-[TestE2E - 5]
65-ID RepoID Name Status Patchsets User Date
66-10 contributor/bin feat: lets build an rnn [open] 1 contributor
67-9 admin/ai feat: lets build an rnn [accepted] 1 contributor
68-8 admin/ai feat: lets build an rnn [accepted] 2 contributor
69-6 contributor/test Closed patch with review [closed] 2 contributor
70-5 contributor/test Accepted patch with review [accepted] 2 contributor
71-4 contributor/test Reviewed patch [open] 2 contributor
72-3 contributor/test Closed patch (contributor) [closed] 1 contributor
73-2 contributor/test Closed patch (admin) [closed] 1 contributor
74-1 admin/test Accepted patch [accepted] 1 contributor
75+Repo PrID PatchsetID Event Created Data
76+test 1 ps-1 pr_created
77+test 1 ps-2 pr_patchset_added
78+test 1 pr_status_changed {"status":"open"}
79+test 1 ps-3 pr_patchset_added
80+test 1 pr_status_changed {"status":"open"}
81
82 ---
+14,
-117
1@@ -1,74 +1,24 @@
2-package git
3+package patchbin
4
5 import (
6- "bytes"
7+ "crypto/sha256"
8 "encoding/base64"
9+ "encoding/hex"
10 "fmt"
11 "log/slog"
12- "strings"
13
14 "github.com/jmoiron/sqlx"
15 "golang.org/x/crypto/ssh"
16 )
17
18 type Backend struct {
19- Logger *slog.Logger
20- DB *sqlx.DB
21- Cfg *GitCfg
22-}
23-
24-var ErrRepoNoNamespace = fmt.Errorf("repo must be namespaced by username")
25-
26-// Repo Namespace.
27-func (be *Backend) CreateRepoNs(userName, repoName string) string {
28- if be.Cfg.CreateRepo == "admin" {
29- return repoName
30- }
31- return fmt.Sprintf("%s/%s", userName, repoName)
32-}
33-
34-func (be *Backend) ValidateRepoNs(repoNs string) error {
35- _, repoID := be.SplitRepoNs(repoNs)
36- if strings.Contains(repoID, "/") {
37- return fmt.Errorf("repo can only contain a single forward-slash")
38- }
39- return nil
40-}
41-
42-func (be *Backend) SplitRepoNs(repoNs string) (string, string) {
43- results := strings.SplitN(repoNs, "/", 2)
44- if len(results) == 1 {
45- return "", results[0]
46- }
47-
48- return results[0], results[1]
49-}
50-
51-func (be *Backend) CanCreateRepo(repo *Repo, requester *User) error {
52- pubkey, err := be.PubkeyToPublicKey(requester.Pubkey)
53- if err != nil {
54- return err
55- }
56- isAdmin := be.IsAdmin(pubkey)
57- if isAdmin {
58- return nil
59- }
60-
61- // can create repo is a misnomer since we are saying it's ok to create
62- // a repo even though one already exists. this is a hack since this function
63- // is used exclusively inside pr creation flow.
64- if repo != nil {
65- return nil
66- }
67-
68- if be.Cfg.CreateRepo == "user" {
69- return nil
70- }
71-
72- // new repo with cfg indicating only admins can create prs/repos
73- return fmt.Errorf("you are not authorized to create repo")
74+ Logger *slog.Logger
75+ DB *sqlx.DB
76+ Cfg *GitCfg
77+ Limiter *RateLimiter
78 }
79
80+// Pubkey returns the standardized public key string for SSH.
81 func (be *Backend) Pubkey(pk ssh.PublicKey) string {
82 return be.KeyForKeyText(pk)
83 }
84@@ -92,7 +42,7 @@ func (be *Backend) KeysEqual(pka, pkb string) bool {
85 }
86
87 func (be *Backend) PublicKeysEqual(a, b ssh.PublicKey) bool {
88- return bytes.Equal(a.Marshal(), b.Marshal())
89+ return string(a.Marshal()) == string(b.Marshal())
90 }
91
92 func (be *Backend) IsAdmin(pk ssh.PublicKey) bool {
93@@ -104,62 +54,9 @@ func (be *Backend) IsAdmin(pk ssh.PublicKey) bool {
94 return false
95 }
96
97-func (be *Backend) IsPrOwner(pka, pkb int64) bool {
98- return pka == pkb
99-}
100-
101-type PrAcl struct {
102- CanModify bool
103- CanDelete bool
104- CanReview bool
105- CanAddPatchset bool
106-}
107-
108-func (be *Backend) GetPatchRequestAcl(repo *Repo, prq *PatchRequest, requester *User) *PrAcl {
109- acl := &PrAcl{}
110- if requester == nil {
111- return acl
112- }
113-
114- pubkey, err := be.PubkeyToPublicKey(requester.Pubkey)
115- if err != nil {
116- return acl
117- }
118-
119- isAdmin := be.IsAdmin(pubkey)
120- // admin can do it all
121- if isAdmin {
122- acl.CanModify = true
123- acl.CanReview = true
124- acl.CanDelete = true
125- acl.CanAddPatchset = true
126- return acl
127- }
128-
129- // repo owner can do it all
130- if repo.UserID == requester.ID {
131- acl.CanModify = true
132- acl.CanReview = true
133- acl.CanDelete = true
134- acl.CanAddPatchset = true
135- return acl
136- }
137-
138- // pr creator has special priv
139- if be.IsPrOwner(prq.UserID, requester.ID) {
140- acl.CanModify = true
141- acl.CanReview = false
142- acl.CanDelete = true
143- acl.CanAddPatchset = true
144- return acl
145- }
146-
147- // otherwise no perms
148- acl.CanModify = false
149- acl.CanDelete = false
150- acl.CanReview = false
151- // anyone can add a patchset
152- acl.CanAddPatchset = true
153-
154- return acl
155+// ComputeUserName derives a username from an SSH public key.
156+// Uses the first 8 characters of the SHA256 hash of the key.
157+func (be *Backend) ComputeUserName(pubkey string) string {
158+ hash := sha256.Sum256([]byte(pubkey))
159+ return hex.EncodeToString(hash[:4])
160 }
M
cfg.go
+30,
-18
1@@ -1,4 +1,4 @@
2-package git
3+package patchbin
4
5 import (
6 "fmt"
7@@ -17,19 +17,21 @@ import (
8 var k = koanf.New(".")
9
10 type GitCfg struct {
11- DataDir string `koanf:"data_dir"`
12- Url string `koanf:"url"`
13- Host string `koanf:"host"`
14- SshPort string `koanf:"ssh_port"`
15- WebPort string `koanf:"web_port"`
16- PromPort string `koanf:"prom_port"`
17- AdminsStr []string `koanf:"admins"`
18- Admins []ssh.PublicKey `koanf:"admins_pk"`
19- CreateRepo string `koanf:"create_repo"`
20- Theme string `koanf:"theme"`
21- TimeFormat string `koanf:"time_format"`
22- Desc string `koanf:"desc"`
23- Logger *slog.Logger
24+ DataDir string `koanf:"data_dir"`
25+ Url string `koanf:"url"`
26+ Host string `koanf:"host"`
27+ SshPort string `koanf:"ssh_port"`
28+ WebPort string `koanf:"web_port"`
29+ PromPort string `koanf:"prom_port"`
30+ AdminsStr []string `koanf:"admins"`
31+ Admins []ssh.PublicKey `koanf:"admins_pk"`
32+ Theme string `koanf:"theme"`
33+ TimeFormat string `koanf:"time_format"`
34+ Desc string `koanf:"desc"`
35+ RateLimitCount int `koanf:"rate_limit_count"`
36+ RateLimitInterval string `koanf:"rate_limit_interval"`
37+ MaxStdinBytes int64 `koanf:"max_stdin_bytes"`
38+ Logger *slog.Logger
39 }
40
41 func LoadConfigFile(fpath string, logger *slog.Logger) {
42@@ -67,7 +69,7 @@ func NewGitCfg(logger *slog.Logger) *GitCfg {
43 panic(fmt.Sprintf("could not parse authorized keys file: %v", err))
44 }
45 } else {
46- logger.Info("no admin specified in config so no one can submit a review!")
47+ logger.Info("no admin specified in config")
48 }
49
50 // make datadir absolute
51@@ -101,8 +103,16 @@ func NewGitCfg(logger *slog.Logger) *GitCfg {
52 out.TimeFormat = time.RFC3339
53 }
54
55- if out.CreateRepo == "" {
56- out.CreateRepo = "admin"
57+ if out.RateLimitCount == 0 {
58+ out.RateLimitCount = 10
59+ }
60+
61+ if out.RateLimitInterval == "" {
62+ out.RateLimitInterval = "1m"
63+ }
64+
65+ if out.MaxStdinBytes == 0 {
66+ out.MaxStdinBytes = 5 << 20 // 5MB
67 }
68
69 logger.Info(
70@@ -114,8 +124,10 @@ func NewGitCfg(logger *slog.Logger) *GitCfg {
71 "web_port", out.WebPort,
72 "theme", out.Theme,
73 "time_format", out.TimeFormat,
74- "create_repo", out.CreateRepo,
75 "desc", out.Desc,
76+ "rate_limit_count", out.RateLimitCount,
77+ "rate_limit_interval", out.RateLimitInterval,
78+ "max_stdin_bytes", out.MaxStdinBytes,
79 )
80
81 for _, pubkey := range out.AdminsStr {
M
cli.go
+369,
-541
1@@ -1,21 +1,18 @@
2-package git
3+package patchbin
4
5 import (
6- "errors"
7+ "bytes"
8 "fmt"
9 "io"
10 "strconv"
11 "strings"
12 "text/tabwriter"
13+ "time"
14
15 "github.com/picosh/pico/pkg/pssh"
16 "github.com/urfave/cli/v2"
17 )
18
19-func errNotExist(host, pubkey string) error {
20- return fmt.Errorf("User does not exist, run `ssh <username>@%s register` to create an account\nPubkey: %s", host, pubkey)
21-}
22-
23 func NewTabWriter(out io.Writer) *tabwriter.Writer {
24 return tabwriter.NewWriter(out, 0, 0, 1, ' ', tabwriter.TabIndent)
25 }
26@@ -25,6 +22,20 @@ func strToInt(str string) (int64, error) {
27 return prID, err
28 }
29
30+// readStdinLimited reads all of stdin, rejecting input over maxBytes rather
31+// than silently truncating it.
32+func readStdinLimited(r io.Reader, maxBytes int64) ([]byte, error) {
33+ limited := io.LimitReader(r, maxBytes+1)
34+ body, err := io.ReadAll(limited)
35+ if err != nil {
36+ return nil, err
37+ }
38+ if int64(len(body)) > maxBytes {
39+ return nil, fmt.Errorf("stdin exceeds max size of %d bytes", maxBytes)
40+ }
41+ return body, nil
42+}
43+
44 func getPatchsetFromOpt(patchsets []*Patchset, optPatchsetID string) (*Patchset, error) {
45 if optPatchsetID == "" {
46 return patchsets[len(patchsets)-1], nil
47@@ -44,40 +55,15 @@ func getPatchsetFromOpt(patchsets []*Patchset, optPatchsetID string) (*Patchset,
48 return nil, fmt.Errorf("cannot find patchset: %s", optPatchsetID)
49 }
50
51-func printPatches(sesh *pssh.SSHServerConnSession, patches []*Patch) {
52- if len(patches) == 1 {
53- sesh.Println(patches[0].RawText)
54- return
55- }
56-
57- opatches := patches
58- for idx, patch := range opatches {
59- sesh.Println(patch.RawText)
60- if idx < len(patches)-1 {
61- sesh.Printf("\n\n\n")
62- }
63- }
64-}
65-
66 func prSummary(be *Backend, pr GitPatchRequest, sesh *pssh.SSHServerConnSession, prID int64) error {
67 request, err := pr.GetPatchRequestByID(prID)
68 if err != nil {
69 return err
70 }
71
72- repo, err := pr.GetRepoByID(request.RepoID)
73- if err != nil {
74- return err
75- }
76-
77- repoUser, err := pr.GetUserByID(repo.UserID)
78- if err != nil {
79- return err
80- }
81-
82 sesh.Printf("Info\n====\n")
83 sesh.Printf("URL: https://%s/prs/%d\n", be.Cfg.Url, prID)
84- sesh.Printf("Repo: %s\n\n", be.CreateRepoNs(repoUser.Name, repo.Name))
85+ sesh.Printf("Repo: %s\n\n", request.RepoName)
86
87 writer := NewTabWriter(sesh)
88 _, _ = fmt.Fprintln(writer, "ID\tName\tStatus\tDate")
89@@ -96,24 +82,20 @@ func prSummary(be *Backend, pr GitPatchRequest, sesh *pssh.SSHServerConnSession,
90 sesh.Printf("\nPatchsets\n====\n")
91
92 writerSet := NewTabWriter(sesh)
93- _, _ = fmt.Fprintln(writerSet, "ID\tType\tUser\tDate")
94+ _, _ = fmt.Fprintln(writerSet, "ID\tUser\tDate")
95 for _, patchset := range patchsets {
96 user, err := pr.GetUserByID(patchset.UserID)
97 if err != nil {
98 be.Logger.Error("cannot find user for patchset", "err", err)
99 continue
100 }
101- isReview := ""
102- if patchset.Review {
103- isReview = "[review]"
104- }
105+ displayName := be.ComputeUserName(user.Pubkey)
106
107 _, _ = fmt.Fprintf(
108 writerSet,
109- "%s\t%s\t%s\t%s\n",
110+ "%s\t%s\t%s\n",
111 getFormattedPatchsetID(patchset.ID),
112- isReview,
113- user.Name,
114+ displayName,
115 patchset.CreatedAt.Format(be.Cfg.TimeFormat),
116 )
117 }
118@@ -151,61 +133,191 @@ func prSummary(be *Backend, pr GitPatchRequest, sesh *pssh.SSHServerConnSession,
119 return nil
120 }
121
122-func printPatchsetFromID(sesh *pssh.SSHServerConnSession, pr GitPatchRequest, psID int64) error {
123- patches, err := pr.GetPatchesByPatchsetID(psID)
124+// printCoverLetterFromPrID prints patches with a cover letter and discussion.
125+func printCoverLetterFromPrID(sesh *pssh.SSHServerConnSession, be *Backend, gpr GitPatchRequest, prID int64) error {
126+ pr, err := gpr.GetPatchRequestByID(prID)
127+ if err != nil {
128+ return err
129+ }
130+
131+ patchsets, err := gpr.GetPatchsetsByPrID(prID)
132 if err != nil {
133 return err
134 }
135- printPatches(sesh, patches)
136+ ps := patchsets[len(patchsets)-1]
137+
138+ patches, err := gpr.GetPatchesByPatchsetID(ps.ID)
139+ if err != nil {
140+ return err
141+ }
142+
143+ events, err := gpr.GetEventLogsByPrID(prID)
144+ if err != nil {
145+ return err
146+ }
147+
148+ users := resolveUsers(gpr, events)
149+
150+ mbox := GenerateMboxWithCoverLetter(pr, patches, events, users, be.Cfg.Url)
151+ sesh.Println(mbox)
152 return nil
153 }
154
155-func printPatchsetFromPrID(sesh *pssh.SSHServerConnSession, pr GitPatchRequest, prID int64) error {
156- patchsets, err := pr.GetPatchsetsByPrID(prID)
157+// printCoverLetterFromPsID prints patches with a cover letter and discussion.
158+func printCoverLetterFromPsID(sesh *pssh.SSHServerConnSession, be *Backend, gpr GitPatchRequest, psID int64) error {
159+ ps, err := gpr.GetPatchsetByID(psID)
160 if err != nil {
161 return err
162 }
163- ps := patchsets[len(patchsets)-1]
164- patches, err := pr.GetPatchesByPatchsetID(ps.ID)
165+
166+ pr, err := gpr.GetPatchRequestByID(ps.PatchRequestID)
167 if err != nil {
168 return err
169 }
170
171- printPatches(sesh, patches)
172+ patches, err := gpr.GetPatchesByPatchsetID(ps.ID)
173+ if err != nil {
174+ return err
175+ }
176+
177+ events, err := gpr.GetEventLogsByPrID(ps.PatchRequestID)
178+ if err != nil {
179+ return err
180+ }
181+
182+ users := resolveUsers(gpr, events)
183+
184+ mbox := GenerateMboxWithCoverLetter(pr, patches, events, users, be.Cfg.Url)
185+ sesh.Println(mbox)
186 return nil
187 }
188
189+// resolveUsers loads user records for all user IDs referenced in events.
190+func resolveUsers(gpr GitPatchRequest, events []*EventLog) map[int64]*User {
191+ users := make(map[int64]*User)
192+ for _, event := range events {
193+ if _, ok := users[event.UserID]; !ok {
194+ user, err := gpr.GetUserByID(event.UserID)
195+ if err == nil {
196+ users[event.UserID] = user
197+ }
198+ }
199+ }
200+ return users
201+}
202+
203 func NewCli(sesh *pssh.SSHServerConnSession, be *Backend, pr GitPatchRequest) *cli.App {
204- desc := fmt.Sprintf(`git-pr (v%s): A pastebin supercharged for git collaboration.
205-
206-Here's how it works:
207- - External contributor clones repo (git-clone)
208- - External contributor makes a code change (git-add & git-commit)
209- - External contributor generates patches (git-format-patch)
210- - External contributor submits a PR to SSH server
211- - Owner receives RSS notification that there's a new PR
212- - Owner applies patches locally (git-am) from SSH server
213- - Owner makes suggestions in code! (git-add & git-commit)
214- - Owner submits review by piping patch to SSH server (git-format-patch)
215- - External contributor receives RSS notification of the PR review
216- - External contributor re-applies patches (git-am)
217- - External contributor reviews and removes comments in code!
218- - External contributor submits another patch (git-format-patch)
219- - Owner applies patches locally (git-am)
220- - Owner marks PR as accepted and pushes code to main (git-push)
221-
222-To get started, submit a new patch request:
223- git format-patch main --stdout | ssh %s pr create {repo}
224-`, GITPR_VERSION, be.Cfg.Url)
225+ url := be.Cfg.Url
226+ desc := fmt.Sprintf(`patchbin (v%s): a pastebin for patches, supercharged for git collaboration.
227+
228+Contributions are anonymous: connect with an SSH key, no signup. A patch
229+request works like a pull request, except both sides collaborate by
230+sending rounds of patchsets -- as commits, not comments -- back and forth
231+on top of each other. Reviewing means pulling the code down, not clicking
232+through a diff viewer. An issue is just a patch request without any code
233+attached yet, so anyone can follow up with a real patch request on top of it.
234+
235+There's no accept/reject step. A PR is either draft (visible only to you)
236+or open (visible to everyone, appears in RSS). It goes inactive after 30
237+days without activity; a reviewer who's happy just pulls it, merges it, and
238+pushes upstream themselves.
239+
240+COMMANDS
241+
242+pr - manage patch requests
243+
244+ pr create {repo}
245+ Submit a new PR from stdin (starts as draft).
246+ git format-patch main --stdout | ssh %[2]s pr create {repo}
247+
248+ pr add {prID}
249+ Add a new patchset to an existing PR from stdin.
250+ git format-patch main --stdout | ssh %[2]s pr add {prID}
251+
252+ pr open {prID} [--comment]
253+ Transition draft -> open, enables RSS notifications.
254+ ssh %[2]s pr open {prID}
255+
256+ pr draft {prID} [--comment]
257+ Transition open -> draft, disables RSS notifications.
258+ ssh %[2]s pr draft {prID}
259+
260+ pr edit {prID} {title}
261+ Rename a PR.
262+ ssh %[2]s pr edit {prID} "new title"
263+
264+ pr summary {prID}
265+ Show metadata, patchsets, and patches for a PR.
266+ ssh %[2]s pr summary {prID}
267+
268+ pr ls [repo] [--draft|--open|--active|--inactive|--mine]
269+ List PRs.
270+ ssh %[2]s pr ls {repo} --open
271+
272+issue - text-only patch requests (no code required)
273+
274+ issue create {repo} [--title]
275+ Submit a new issue from stdin (starts as open).
276+ echo "steps to reproduce..." | ssh %[2]s issue create {repo} --title "bug: crash on startup"
277+
278+ps - manage patchsets
279+
280+ ps rm {patchsetID}
281+ Remove a patchset and its patches (creator only).
282+ ssh %[2]s ps rm ps-{patchsetID}
283+
284+print - print patches for checkout
285+
286+ print pr-{prID}
287+ Print the latest patchset for a PR.
288+ ssh %[2]s print pr-{prID} | git am -3
289+
290+ print ps-{patchsetID}
291+ Print a specific patchset.
292+ ssh %[2]s print ps-{patchsetID} | git am -3
293+
294+ Cover letters are stored as an empty commit. If you want to keep them
295+ when applying, use "git am --keep-empty" (or set it globally with
296+ "git config --global am.keepEmpty true").
297+
298+logs - event history
299+
300+ logs [--pr ID] [--pubkey]
301+ List event logs, optionally filtered to a PR or your own activity.
302+ ssh %[2]s logs --pr {prID}
303+
304+STDIN
305+
306+ pr create, pr add expect the output of "git format-patch --stdout"
307+ issue create expects free-form text (the issue body)
308+ pr open/draft --comment expects free-form text (a comment to attach to the status change)
309+
310+GUARDS
311+
312+ To limit abuse, submissions (pr create, pr add, issue create) are capped
313+ at %[3]d bytes of stdin, and globally rate limited to %[4]d submissions
314+ per %[5]s across all users. Contact an admin if you hit these limits.
315+
316+ Admins with shell access to the host can ban a pubkey or IP address by
317+ inserting a row directly into the "acl" table of the sqlite database:
318+
319+ sqlite3 data/pr.db "INSERT INTO acl (pubkey, permission) VALUES ('{pubkey}', 'banned')"
320+ sqlite3 data/pr.db "INSERT INTO acl (ip_address, permission) VALUES ('{ip}', 'banned')"
321+
322+ Banned pubkeys/IPs are rejected at SSH auth time. There is currently no
323+ SSH command for this; it requires direct database access.
324+
325+Self-host your own patchbin: https://github.com/picosh/patchbin
326+`, GITPR_VERSION, url, be.Cfg.MaxStdinBytes, be.Cfg.RateLimitCount, be.Cfg.RateLimitInterval)
327
328 pubkey := be.Pubkey(sesh.PublicKey())
329- userName := sesh.User()
330 app := &cli.App{
331- Name: "ssh",
332- Description: desc,
333- Usage: "Collaborate with contributors for your git project",
334- Writer: sesh,
335- ErrWriter: sesh,
336+ Name: "ssh",
337+ Description: desc,
338+ Usage: "A pastebin for patches, supercharged for git collaboration",
339+ CustomAppHelpTemplate: "{{.Description}}\n",
340+ Writer: sesh,
341+ ErrWriter: sesh,
342 ExitErrHandler: func(cCtx *cli.Context, err error) {
343 if err != nil {
344 sesh.Fatal(fmt.Errorf("err: %w", err))
345@@ -218,6 +330,69 @@ To get started, submit a new patch request:
346 return nil
347 },
348 Commands: []*cli.Command{
349+ {
350+ Name: "issue",
351+ Usage: "Manage issues (text-only patch requests)",
352+ Subcommands: []*cli.Command{
353+ {
354+ Name: "create",
355+ Usage: "Submit a new issue (starts as open)",
356+ Args: true,
357+ ArgsUsage: "repoName",
358+ Flags: []cli.Flag{
359+ &cli.StringFlag{
360+ Name: "title",
361+ Usage: "issue title (default: first line of stdin)",
362+ },
363+ },
364+ Action: func(cCtx *cli.Context) error {
365+ if !be.Limiter.Allow() {
366+ return be.Limiter.Error()
367+ }
368+
369+ user, err := pr.UpsertUserByPubkey(pubkey)
370+ if err != nil {
371+ return err
372+ }
373+
374+ args := cCtx.Args()
375+ if !args.Present() {
376+ return fmt.Errorf("must provide a repo name")
377+ }
378+ repoName := args.First()
379+
380+ body, err := readStdinLimited(sesh, be.Cfg.MaxStdinBytes)
381+ if err != nil {
382+ return fmt.Errorf("failed to read issue body from stdin: %w", err)
383+ }
384+ bodyStr := strings.TrimSpace(string(body))
385+ if bodyStr == "" {
386+ return fmt.Errorf("must provide issue body via stdin")
387+ }
388+
389+ title := cCtx.String("title")
390+ if title == "" {
391+ // Use first line as title
392+ lines := strings.SplitN(bodyStr, "\n", 2)
393+ title = lines[0]
394+ if len(lines) > 1 {
395+ bodyStr = strings.TrimSpace(lines[1])
396+ } else {
397+ bodyStr = ""
398+ }
399+ }
400+
401+ prq, err := pr.SubmitIssue(user.ID, pubkey, repoName, title, bodyStr)
402+ if err != nil {
403+ return err
404+ }
405+
406+ sesh.Printf("Issue created! #%d\n", prq.ID)
407+ return prSummary(be, pr, sesh, prq.ID)
408+ },
409+ },
410+ },
411+ },
412 {
413 Name: "logs",
414 Usage: "List event logs with filters",
415@@ -231,33 +406,19 @@ To get started, submit a new patch request:
416 Name: "pubkey",
417 Usage: "show all events related to your pubkey",
418 },
419- &cli.StringFlag{
420- Name: "repo",
421- Usage: "show all events related to a repo",
422- },
423 },
424 Action: func(cCtx *cli.Context) error {
425- pubkey := be.Pubkey(sesh.PublicKey())
426- user, err := pr.GetUserByPubkey(pubkey)
427+ user, err := pr.UpsertUserByPubkey(pubkey)
428 if err != nil {
429- return errNotExist(be.Cfg.Host, pubkey)
430+ return err
431 }
432 isPubkey := cCtx.Bool("pubkey")
433 prID := cCtx.Int64("pr")
434- repoNs := cCtx.String("repo")
435 var eventLogs []*EventLog
436 if isPubkey {
437 eventLogs, err = pr.GetEventLogsByUserID(user.ID)
438 } else if prID != 0 {
439 eventLogs, err = pr.GetEventLogsByPrID(prID)
440- } else if repoNs != "" {
441- repoUsername, repoName := be.SplitRepoNs(repoNs)
442- var repoUser *User
443- repoUser, err = pr.GetUserByName(repoUsername)
444- if err != nil {
445- return nil
446- }
447- eventLogs, err = pr.GetEventLogsByRepoName(repoUser, repoName)
448 } else {
449 eventLogs, err = pr.GetEventLogs()
450 }
451@@ -266,22 +427,11 @@ To get started, submit a new patch request:
452 }
453
454 writer := NewTabWriter(sesh)
455- _, _ = fmt.Fprintln(writer, "RepoID\tPrID\tPatchsetID\tEvent\tCreated\tData")
456+ _, _ = fmt.Fprintln(writer, "PrID\tPatchsetID\tEvent\tCreated\tData")
457 for _, eventLog := range eventLogs {
458- repo, err := pr.GetRepoByID(eventLog.RepoID.Int64)
459- if err != nil {
460- be.Logger.Error("repo not found", "repo", repo, "err", err)
461- continue
462- }
463- repoUser, err := pr.GetUserByID(repo.UserID)
464- if err != nil {
465- be.Logger.Error("repo user not found", "repo", repo, "err", err)
466- continue
467- }
468 _, _ = fmt.Fprintf(
469 writer,
470- "%s\t%d\t%s\t%s\t%s\t%s\n",
471- be.CreateRepoNs(repoUser.Name, repo.Name),
472+ "%d\t%s\t%s\t%s\t%s\n",
473 eventLog.PatchRequestID.Int64,
474 getFormattedPatchsetID(eventLog.PatchsetID.Int64),
475 eventLog.Event,
476@@ -293,28 +443,13 @@ To get started, submit a new patch request:
477 return nil
478 },
479 },
480- {
481- Name: "register",
482- Usage: "Create an account",
483- Args: true,
484- Flags: []cli.Flag{},
485- Action: func(cCtx *cli.Context) error {
486- pubkey := be.Pubkey(sesh.PublicKey())
487- user, err := pr.RegisterUser(pubkey, userName)
488- if err != nil {
489- return err
490- }
491- sesh.Printf("User created successfully!\nUser: %s\nPubkey: %s\n", user.Name, pubkey)
492- return nil
493- },
494- },
495 {
496 Name: "ps",
497- Usage: "Mange patchsets",
498+ Usage: "Manage patchsets",
499 Subcommands: []*cli.Command{
500 {
501 Name: "rm",
502- Usage: "Remove a patchset with its patches",
503+ Usage: "Remove a patchset and its patches",
504 Args: true,
505 ArgsUsage: "[patchsetID]",
506 Action: func(cCtx *cli.Context) error {
507@@ -338,11 +473,8 @@ To get started, submit a new patch request:
508 return err
509 }
510
511- pk := sesh.PublicKey()
512- isAdmin := be.IsAdmin(pk)
513- isContrib := pubkey == user.Pubkey
514- if !isAdmin && !isContrib {
515- return fmt.Errorf("you are not authorized to delete a patchset")
516+ if pubkey != user.Pubkey {
517+ return fmt.Errorf("you are not authorized to delete this patchset (only the creator can delete)")
518 }
519
520 err = pr.DeletePatchsetByID(user.ID, patchset.PatchRequestID, patchsetID)
521@@ -355,90 +487,6 @@ To get started, submit a new patch request:
522 },
523 },
524 },
525- {
526- Name: "repo",
527- Usage: "Manage repos",
528- Subcommands: []*cli.Command{
529- {
530- Name: "create",
531- Usage: "Create a new repo",
532- Args: true,
533- ArgsUsage: "[repoName]",
534- Action: func(cCtx *cli.Context) error {
535- user, err := pr.GetUserByPubkey(pubkey)
536- if err != nil {
537- return errNotExist(be.Cfg.Host, pubkey)
538- }
539-
540- args := cCtx.Args()
541- if !args.Present() {
542- return fmt.Errorf("need repo name argument")
543- }
544- repoName := args.First()
545- repo, _ := pr.GetRepoByName(user, repoName)
546- err = be.CanCreateRepo(repo, user)
547- if err != nil {
548- return err
549- }
550-
551- if repo == nil {
552- repo, err = pr.CreateRepo(user, repoName)
553- if err != nil {
554- return err
555- }
556- }
557-
558- sesh.Printf("repo created: %s/%s\n", user.Name, repo.Name)
559- return nil
560- },
561- },
562- {
563- Name: "rm",
564- Usage: "Delete repo and associated patch requests",
565- Args: true,
566- ArgsUsage: "[repoName]",
567- Flags: []cli.Flag{
568- &cli.BoolFlag{
569- Name: "write",
570- Usage: "Are you sure you want to delete the repo and all patch requests?",
571- },
572- },
573- Action: func(cCtx *cli.Context) error {
574- user, err := pr.GetUserByPubkey(pubkey)
575- if err != nil {
576- return errNotExist(be.Cfg.Host, pubkey)
577- }
578-
579- args := cCtx.Args()
580- if !args.Present() {
581- return fmt.Errorf("need repo name argument")
582- }
583- rawRepoNs := args.First()
584- _, repoName := be.SplitRepoNs(rawRepoNs)
585- repo, _ := pr.GetRepoByName(user, repoName)
586- if repo == nil {
587- return fmt.Errorf("repo does not exist: %s/%s", user.Name, repoName)
588- }
589- err = be.CanCreateRepo(repo, user)
590- if err != nil {
591- return err
592- }
593-
594- if cCtx.Bool("write") {
595- err = pr.DeleteRepo(user, repoName)
596- if err != nil {
597- return err
598- }
599- } else {
600- sesh.Println("Must provide `--write` flag to persist changes")
601- }
602-
603- sesh.Printf("repo deleted: %s/%s\n", user.Name, repo.Name)
604- return nil
605- },
606- },
607- },
608- },
609 {
610 Name: "print",
611 Usage: "Print patches in a patchset",
612@@ -460,9 +508,11 @@ To get started, submit a new patch request:
613
614 switch prefix {
615 case "pr":
616- err = printPatchsetFromPrID(sesh, pr, id)
617+ err = printCoverLetterFromPrID(sesh, be, pr, id)
618 case "ps":
619- err = printPatchsetFromID(sesh, pr, id)
620+ err = printCoverLetterFromPsID(sesh, be, pr, id)
621+ default:
622+ return fmt.Errorf("unknown prefix %q, must be one of: pr, ps", prefix)
623 }
624
625 return err
626@@ -470,7 +520,7 @@ To get started, submit a new patch request:
627 },
628 {
629 Name: "pr",
630- Usage: "Manage Patch Requests (PR)",
631+ Usage: "Manage patch requests (PR)",
632 Subcommands: []*cli.Command{
633 {
634 Name: "ls",
635@@ -478,17 +528,21 @@ To get started, submit a new patch request:
636 Args: true,
637 ArgsUsage: "[repoName]",
638 Flags: []cli.Flag{
639+ &cli.BoolFlag{
640+ Name: "draft",
641+ Usage: "only show draft PRs",
642+ },
643 &cli.BoolFlag{
644 Name: "open",
645 Usage: "only show open PRs",
646 },
647 &cli.BoolFlag{
648- Name: "closed",
649- Usage: "only show closed PRs",
650+ Name: "active",
651+ Usage: "only show active PRs (activity in last 30 days)",
652 },
653 &cli.BoolFlag{
654- Name: "accepted",
655- Usage: "only show accepted PRs",
656+ Name: "inactive",
657+ Usage: "only show inactive PRs (no activity in 30 days)",
658 },
659 &cli.BoolFlag{
660 Name: "mine",
661@@ -497,8 +551,7 @@ To get started, submit a new patch request:
662 },
663 Action: func(cCtx *cli.Context) error {
664 args := cCtx.Args()
665- rawRepoNs := args.First()
666- userName, repoName := be.SplitRepoNs(rawRepoNs)
667+ repoName := args.First()
668 var prs []*PatchRequest
669 var err error
670 if repoName == "" {
671@@ -507,37 +560,35 @@ To get started, submit a new patch request:
672 return err
673 }
674 } else {
675- user, err := pr.GetUserByName(userName)
676- if err != nil {
677- return err
678- }
679- repo, err := pr.GetRepoByName(user, repoName)
680- if err != nil {
681- return err
682- }
683- prs, err = pr.GetPatchRequestsByRepoID(repo.ID)
684+ prs, err = pr.GetPatchRequestsByRepoName(repoName)
685 if err != nil {
686 return err
687 }
688 }
689
690+ onlyDraft := cCtx.Bool("draft")
691 onlyOpen := cCtx.Bool("open")
692- onlyAccepted := cCtx.Bool("accepted")
693- onlyClosed := cCtx.Bool("closed")
694+ onlyActive := cCtx.Bool("active")
695+ onlyInactive := cCtx.Bool("inactive")
696 onlyMine := cCtx.Bool("mine")
697+ cutoff := time.Now().AddDate(0, 0, -30)
698
699 writer := NewTabWriter(sesh)
700- _, _ = fmt.Fprintln(writer, "ID\tRepoID\tName\tStatus\tPatchsets\tUser\tDate")
701+ _, _ = fmt.Fprintln(writer, "ID\tRepo\tName\tStatus\tPatchsets\tUser\tLast Activity")
702 for _, req := range prs {
703- if onlyAccepted && req.Status != StatusAccepted {
704+ if onlyDraft && req.Status != StatusDraft {
705 continue
706 }
707
708- if onlyClosed && req.Status != StatusClosed {
709+ if onlyOpen && req.Status != StatusOpen {
710 continue
711 }
712
713- if onlyOpen && req.Status != StatusOpen {
714+ if onlyActive && req.LastActivity.Before(cutoff) {
715+ continue
716+ }
717+
718+ if onlyInactive && req.LastActivity.After(cutoff) {
719 continue
720 }
721
722@@ -547,7 +598,7 @@ To get started, submit a new patch request:
723 continue
724 }
725
726- if onlyMine && user.Name != userName {
727+ if onlyMine && user.Pubkey != pubkey {
728 continue
729 }
730
731@@ -557,28 +608,18 @@ To get started, submit a new patch request:
732 continue
733 }
734
735- repo, err := pr.GetRepoByID(req.RepoID)
736- if err != nil {
737- be.Logger.Error("could not get repo for pr", "err", err)
738- continue
739- }
740-
741- repoUser, err := pr.GetUserByID(repo.UserID)
742- if err != nil {
743- be.Logger.Error("could not get repo user for pr", "err", err)
744- continue
745- }
746+ displayName := be.ComputeUserName(user.Pubkey)
747
748 _, _ = fmt.Fprintf(
749 writer,
750 "%d\t%s\t%s\t[%s]\t%d\t%s\t%s\n",
751 req.ID,
752- be.CreateRepoNs(repoUser.Name, repo.Name),
753+ req.RepoName,
754 req.Name,
755 req.Status,
756 len(patchsets),
757- user.Name,
758- req.CreatedAt.Format(be.Cfg.TimeFormat),
759+ displayName,
760+ req.LastActivity.Format(be.Cfg.TimeFormat),
761 )
762 }
763 _ = writer.Flush()
764@@ -587,84 +628,46 @@ To get started, submit a new patch request:
765 },
766 {
767 Name: "create",
768- Usage: "Submit a new PR",
769+ Usage: "Submit a new PR (starts as draft)",
770 Args: true,
771- ArgsUsage: "[repoName]",
772+ ArgsUsage: "repoName",
773 Action: func(cCtx *cli.Context) error {
774- user, err := pr.GetUserByPubkey(pubkey)
775+ if !be.Limiter.Allow() {
776+ return be.Limiter.Error()
777+ }
778+
779+ user, err := pr.UpsertUserByPubkey(pubkey)
780 if err != nil {
781- return errNotExist(be.Cfg.Host, pubkey)
782+ return err
783 }
784
785 args := cCtx.Args()
786- rawRepoNs := "bin"
787- if args.Present() {
788- rawRepoNs = args.First()
789- }
790- repoUsername, repoName := be.SplitRepoNs(rawRepoNs)
791- var repo *Repo
792- if repoUsername == "" {
793- if be.Cfg.CreateRepo == "admin" {
794- // single tenant default user to admin
795- repo, _ = pr.GetRepoByName(nil, repoName)
796- } else {
797- // multi tenant default user to contributor
798- repo, _ = pr.GetRepoByName(user, repoName)
799- }
800- } else {
801- repoUser, err := pr.GetUserByName(repoUsername)
802- if err != nil {
803- return err
804- }
805- repo, _ = pr.GetRepoByName(repoUser, repoName)
806+ if !args.Present() {
807+ return fmt.Errorf("must provide a repo name")
808 }
809+ repoName := args.First()
810
811- err = be.CanCreateRepo(repo, user)
812+ body, err := readStdinLimited(sesh, be.Cfg.MaxStdinBytes)
813 if err != nil {
814- return err
815- }
816-
817- if repo == nil {
818- repo, err = pr.CreateRepo(user, repoName)
819- if err != nil {
820- return err
821- }
822+ return fmt.Errorf("failed to read patchset from stdin: %w", err)
823 }
824
825- prq, err := pr.SubmitPatchRequest(repo.ID, user.ID, sesh)
826+ prq, err := pr.SubmitPatchRequest(user.ID, pubkey, repoName, bytes.NewReader(body))
827 if err != nil {
828 return err
829 }
830 sesh.Println(
831- "PR submitted! Use the ID for interacting with this PR.",
832+ "PR submitted as draft! Use `pr open <id>` to make it visible.",
833 )
834
835 return prSummary(be, pr, sesh, prq.ID)
836 },
837 },
838 {
839- Name: "summary",
840- Usage: "Display metadata related to a PR",
841+ Name: "open",
842+ Usage: "Transition PR to open (enable RSS notifications)",
843 Args: true,
844 ArgsUsage: "[prID]",
845- Action: func(cCtx *cli.Context) error {
846- args := cCtx.Args()
847- if !args.Present() {
848- return fmt.Errorf("must provide a patch request ID")
849- }
850-
851- prID, err := strToInt(args.First())
852- if err != nil {
853- return err
854- }
855- return prSummary(be, pr, sesh, prID)
856- },
857- },
858- {
859- Name: "accept",
860- Usage: "Accept a PR",
861- Args: true,
862- ArgsUsage: "[prID], [prID]...",
863 Flags: []cli.Flag{
864 &cli.BoolFlag{
865 Name: "comment",
866@@ -674,151 +677,43 @@ To get started, submit a new patch request:
867 Action: func(cCtx *cli.Context) error {
868 args := cCtx.Args()
869 if !args.Present() {
870- return fmt.Errorf("must provide at least one patch request ID")
871+ return fmt.Errorf("must provide a patch request ID")
872 }
873
874- prIDs := args.Tail()
875- prIDs = append(prIDs, args.First())
876-
877- var errs error
878- for _, prIDStr := range prIDs {
879- prID, err := strToInt(prIDStr)
880- if err != nil {
881- sesh.Errorln(err)
882- continue
883- }
884-
885- prq, err := pr.GetPatchRequestByID(prID)
886- if err != nil {
887- return err
888- }
889-
890- user, err := pr.GetUserByPubkey(pubkey)
891- if err != nil {
892- return errNotExist(be.Cfg.Host, pubkey)
893- }
894-
895- repo, err := pr.GetRepoByID(prq.RepoID)
896- if err != nil {
897- return err
898- }
899-
900- acl := be.GetPatchRequestAcl(repo, prq, user)
901- if !acl.CanReview {
902- return fmt.Errorf("you are not authorized to accept a PR")
903- }
904-
905- if prq.Status == StatusAccepted {
906- return fmt.Errorf("PR has already been accepted")
907- }
908-
909- comment := cCtx.Bool("comment")
910- var commentTxt []byte
911- if comment {
912- commentTxt, err = io.ReadAll(sesh)
913- if err != nil {
914- return fmt.Errorf("when comment flag enabled must provide it from stdin")
915- }
916- }
917-
918- err = pr.UpdatePatchRequestStatus(prID, user.ID, StatusAccepted, string(commentTxt))
919- if err != nil {
920- return err
921- }
922- sesh.Printf("Accepted PR %s (#%d)\n", prq.Name, prq.ID)
923- err = prSummary(be, pr, sesh, prID)
924- if err != nil {
925- errs = errors.Join(errs, err)
926- }
927- sesh.Printf("\n\n")
928+ prID, err := strToInt(args.First())
929+ if err != nil {
930+ return err
931 }
932
933- return errs
934- },
935- },
936- {
937- Name: "close",
938- Usage: "Close a PR",
939- Args: true,
940- ArgsUsage: "[prID], [prID]...",
941- Flags: []cli.Flag{
942- &cli.BoolFlag{
943- Name: "comment",
944- Usage: "If this flag is provided, pass comment through stdin",
945- },
946- },
947- Action: func(cCtx *cli.Context) error {
948- args := cCtx.Args()
949- if !args.Present() {
950- return fmt.Errorf("must provide a patch request ID")
951+ prq, err := pr.GetPatchRequestByID(prID)
952+ if err != nil {
953+ return err
954 }
955
956- prIDs := args.Tail()
957- prIDs = append(prIDs, args.First())
958-
959- var errs error
960- for _, prIDStr := range prIDs {
961- prID, err := strToInt(prIDStr)
962- if err != nil {
963- sesh.Errorln(err)
964- continue
965- }
966-
967- prq, err := pr.GetPatchRequestByID(prID)
968- if err != nil {
969- return err
970- }
971-
972- patchUser, err := pr.GetUserByID(prq.UserID)
973- if err != nil {
974- return err
975- }
976-
977- repo, err := pr.GetRepoByID(prq.RepoID)
978- if err != nil {
979- return err
980- }
981-
982- acl := be.GetPatchRequestAcl(repo, prq, patchUser)
983- if !acl.CanModify {
984- return fmt.Errorf("you are not authorized to change PR status")
985- }
986-
987- if prq.Status == StatusClosed {
988- return fmt.Errorf("PR has already been closed")
989- }
990+ if prq.Status == StatusOpen {
991+ return fmt.Errorf("PR is already open")
992+ }
993
994- user, err := pr.GetUserByPubkey(pubkey)
995+ comment := cCtx.Bool("comment")
996+ var commentTxt []byte
997+ if comment {
998+ commentTxt, err = io.ReadAll(sesh)
999 if err != nil {
1000- return errNotExist(be.Cfg.Host, pubkey)
1001- }
1002-
1003- comment := cCtx.Bool("comment")
1004- var commentTxt []byte
1005- if comment {
1006- commentTxt, err = io.ReadAll(sesh)
1007- if err != nil {
1008- return fmt.Errorf("when comment flag enabled must provide it from stdin")
1009- }
1010+ return fmt.Errorf("when comment flag enabled must provide it from stdin")
1011 }
1012+ }
1013
1014- err = pr.UpdatePatchRequestStatus(prID, user.ID, StatusClosed, string(commentTxt))
1015- if err != nil {
1016- return err
1017- }
1018- sesh.Printf("Closed PR %s (#%d)\n", prq.Name, prq.ID)
1019- err = prSummary(be, pr, sesh, prID)
1020- if err != nil {
1021- errs = errors.Join(errs, err)
1022- }
1023- sesh.Printf("\n\n")
1024+ err = pr.UpdatePatchRequestStatus(prID, pubkey, StatusOpen, string(commentTxt))
1025+ if err != nil {
1026+ return err
1027 }
1028- return errs
1029+ sesh.Printf("Opened PR %s (#%d)\n", prq.Name, prq.ID)
1030+ return prSummary(be, pr, sesh, prID)
1031 },
1032 },
1033 {
1034- Name: "reopen",
1035- Usage: "Reopen a PR",
1036+ Name: "draft",
1037+ Usage: "Transition PR to draft (disable RSS notifications)",
1038 Args: true,
1039 ArgsUsage: "[prID]",
1040 Flags: []cli.Flag{
1041@@ -843,28 +738,8 @@ To get started, submit a new patch request:
1042 return err
1043 }
1044
1045- patchUser, err := pr.GetUserByID(prq.UserID)
1046- if err != nil {
1047- return err
1048- }
1049-
1050- repo, err := pr.GetRepoByID(prq.RepoID)
1051- if err != nil {
1052- return err
1053- }
1054-
1055- acl := be.GetPatchRequestAcl(repo, prq, patchUser)
1056- if !acl.CanModify {
1057- return fmt.Errorf("you are not authorized to change PR status")
1058- }
1059-
1060- if prq.Status == StatusOpen {
1061- return fmt.Errorf("PR is already open")
1062- }
1063-
1064- user, err := pr.GetUserByPubkey(pubkey)
1065- if err != nil {
1066- return errNotExist(be.Cfg.Host, pubkey)
1067+ if prq.Status == StatusDraft {
1068+ return fmt.Errorf("PR is already a draft")
1069 }
1070
1071 comment := cCtx.Bool("comment")
1072@@ -875,18 +750,20 @@ To get started, submit a new patch request:
1073 return fmt.Errorf("when comment flag enabled must provide it from stdin")
1074 }
1075 }
1076- err = pr.UpdatePatchRequestStatus(prID, user.ID, StatusOpen, string(commentTxt))
1077- if err == nil {
1078- sesh.Printf("Reopened PR %s (#%d)\n", prq.Name, prq.ID)
1079+
1080+ err = pr.UpdatePatchRequestStatus(prID, pubkey, StatusDraft, string(commentTxt))
1081+ if err != nil {
1082+ return err
1083 }
1084+ sesh.Printf("Drafted PR %s (#%d)\n", prq.Name, prq.ID)
1085 return prSummary(be, pr, sesh, prID)
1086 },
1087 },
1088 {
1089- Name: "edit",
1090- Usage: "Edit PR title",
1091+ Name: "summary",
1092+ Usage: "Show metadata, patchsets, and patches for a PR",
1093 Args: true,
1094- ArgsUsage: "[prID] [title]",
1095+ ArgsUsage: "[prID]",
1096 Action: func(cCtx *cli.Context) error {
1097 args := cCtx.Args()
1098 if !args.Present() {
1099@@ -897,40 +774,40 @@ To get started, submit a new patch request:
1100 if err != nil {
1101 return err
1102 }
1103- prq, err := pr.GetPatchRequestByID(prID)
1104- if err != nil {
1105- return err
1106+ return prSummary(be, pr, sesh, prID)
1107+ },
1108+ },
1109+ {
1110+ Name: "edit",
1111+ Usage: "Edit a PR's title",
1112+ Args: true,
1113+ ArgsUsage: "[prID] [title]",
1114+ Action: func(cCtx *cli.Context) error {
1115+ args := cCtx.Args()
1116+ if !args.Present() {
1117+ return fmt.Errorf("must provide a patch request ID")
1118 }
1119
1120- user, err := pr.GetUserByPubkey(pubkey)
1121+ prID, err := strToInt(args.First())
1122 if err != nil {
1123- return errNotExist(be.Cfg.Host, pubkey)
1124+ return err
1125 }
1126-
1127- repo, err := pr.GetRepoByID(prq.RepoID)
1128+ prq, err := pr.GetPatchRequestByID(prID)
1129 if err != nil {
1130 return err
1131 }
1132
1133- acl := be.GetPatchRequestAcl(repo, prq, user)
1134- if !acl.CanModify {
1135- return fmt.Errorf("you are not authorized to change PR")
1136- }
1137-
1138 tail := cCtx.Args().Tail()
1139 title := strings.Join(tail, " ")
1140 if title == "" {
1141 return fmt.Errorf("must provide title")
1142 }
1143
1144- err = pr.UpdatePatchRequestName(
1145- prID,
1146- user.ID,
1147- title,
1148- )
1149- if err == nil {
1150- sesh.Printf("New title: %s (%d)\n", title, prq.ID)
1151+ err = pr.UpdatePatchRequestName(prID, pubkey, title)
1152+ if err != nil {
1153+ return err
1154 }
1155+ sesh.Printf("New title: %s (%d)\n", title, prq.ID)
1156
1157 return err
1158 },
1159@@ -940,25 +817,11 @@ To get started, submit a new patch request:
1160 Usage: "Add a new patchset to a PR",
1161 Args: true,
1162 ArgsUsage: "[prID]",
1163- Flags: []cli.Flag{
1164- &cli.BoolFlag{
1165- Name: "review",
1166- Usage: "submit patchset mark it as a review",
1167- },
1168- &cli.BoolFlag{
1169- Name: "accept",
1170- Usage: "submit patchset and mark PR as accepted",
1171- },
1172- &cli.BoolFlag{
1173- Name: "close",
1174- Usage: "submit patchset and mark PR as closed",
1175- },
1176- &cli.StringFlag{
1177- Name: "comment",
1178- Usage: "add a comment to the patchset",
1179- },
1180- },
1181 Action: func(cCtx *cli.Context) error {
1182+ if !be.Limiter.Allow() {
1183+ return be.Limiter.Error()
1184+ }
1185+
1186 args := cCtx.Args()
1187 if !args.Present() {
1188 return fmt.Errorf("must provide a patch request ID")
1189@@ -968,50 +831,22 @@ To get started, submit a new patch request:
1190 if err != nil {
1191 return err
1192 }
1193- prq, err := pr.GetPatchRequestByID(prID)
1194+ _, err = pr.GetPatchRequestByID(prID)
1195 if err != nil {
1196 return err
1197 }
1198
1199- user, err := pr.GetUserByPubkey(pubkey)
1200- if err != nil {
1201- return errNotExist(be.Cfg.Host, pubkey)
1202- }
1203-
1204- isReview := cCtx.Bool("review")
1205- isAccept := cCtx.Bool("accept")
1206- isClose := cCtx.Bool("close")
1207-
1208- repo, err := pr.GetRepoByID(prq.RepoID)
1209+ user, err := pr.UpsertUserByPubkey(pubkey)
1210 if err != nil {
1211 return err
1212 }
1213
1214- acl := be.GetPatchRequestAcl(repo, prq, user)
1215- if !acl.CanAddPatchset {
1216- return fmt.Errorf("you are not authorized to add patchsets to pr")
1217- }
1218-
1219- if isReview && !acl.CanReview {
1220- return fmt.Errorf("you are not authorized to submit a review to pr")
1221- }
1222-
1223- op := OpNormal
1224- nextStatus := StatusOpen
1225- if isReview {
1226- sesh.Println("Marking patchset as a review")
1227- op = OpReview
1228- } else if isAccept {
1229- sesh.Println("Marking PR as accepted")
1230- nextStatus = StatusAccepted
1231- op = OpAccept
1232- } else if isClose {
1233- sesh.Println("Marking PR as closed")
1234- nextStatus = StatusClosed
1235- op = OpClose
1236+ body, err := readStdinLimited(sesh, be.Cfg.MaxStdinBytes)
1237+ if err != nil {
1238+ return fmt.Errorf("failed to read patchset from stdin: %w", err)
1239 }
1240
1241- patches, err := pr.SubmitPatchset(prID, user.ID, op, sesh)
1242+ patches, err := pr.SubmitPatchset(prID, user.ID, OpNormal, bytes.NewReader(body))
1243 if err != nil {
1244 return err
1245 }
1246@@ -1021,13 +856,6 @@ To get started, submit a new patch request:
1247 return nil
1248 }
1249
1250- if prq.Status != nextStatus {
1251- err = pr.UpdatePatchRequestStatus(prID, user.ID, nextStatus, cCtx.String("comment"))
1252- if err != nil {
1253- return err
1254- }
1255- }
1256-
1257 sesh.Println("Patches submitted!")
1258 return prSummary(be, pr, sesh, prID)
1259 },
R cmd/git-pr/main.go =>
cmd/patchbin/main.go
+6,
-6
1@@ -10,11 +10,11 @@ import (
2 "os/signal"
3 "syscall"
4
5- git "github.com/picosh/git-pr"
6+ "github.com/picosh/patchbin"
7 )
8
9 func main() {
10- fpath := flag.String("config", "git-pr.toml", "configuration toml file")
11+ fpath := flag.String("config", "patchbin.toml", "configuration toml file")
12 flag.Parse()
13 opts := &slog.HandlerOptions{
14 AddSource: true,
15@@ -22,12 +22,12 @@ func main() {
16 logger := slog.New(
17 slog.NewTextHandler(os.Stdout, opts),
18 )
19- git.LoadConfigFile(*fpath, logger)
20- cfg := git.NewGitCfg(logger)
21+ patchbin.LoadConfigFile(*fpath, logger)
22+ cfg := patchbin.NewGitCfg(logger)
23
24 // Web Server
25 addr := fmt.Sprintf("%s:%s", cfg.Host, cfg.WebPort)
26- web := git.GitWebServer(cfg)
27+ web := patchbin.GitWebServer(cfg)
28 cfg.Logger.Info("starting web server", "addr", addr)
29 go func() {
30 if err := http.ListenAndServe(addr, web); err != nil {
31@@ -38,7 +38,7 @@ func main() {
32 ctx, cancel := context.WithCancel(context.Background())
33 defer cancel()
34 // SSH Server
35- ssh := git.GitSshServer(ctx, cfg)
36+ ssh := patchbin.GitSshServer(ctx, cfg)
37
38 done := make(chan os.Signal, 1)
39 signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
+26,
-29
1@@ -11,9 +11,9 @@ import (
2 "syscall"
3 "time"
4
5- "github.com/picosh/git-pr"
6- "github.com/picosh/git-pr/fixtures"
7- "github.com/picosh/git-pr/util"
8+ "github.com/picosh/patchbin"
9+ "github.com/picosh/patchbin/fixtures"
10+ "github.com/picosh/patchbin/util"
11 )
12
13 func main() {
14@@ -36,17 +36,17 @@ func main() {
15
16 adminKey, userKey := util.GenerateKeys()
17 cfgPath := util.CreateCfgFile(dataDir, cfgTmpl, adminKey)
18- git.LoadConfigFile(cfgPath, logger)
19- cfg := git.NewGitCfg(logger)
20+ patchbin.LoadConfigFile(cfgPath, logger)
21+ cfg := patchbin.NewGitCfg(logger)
22
23 ctx, cancel := context.WithCancel(context.Background())
24 defer cancel()
25- s := git.GitSshServer(ctx, cfg)
26+ s := patchbin.GitSshServer(ctx, cfg)
27 go func() {
28 _ = s.ListenAndServe()
29 }()
30 time.Sleep(time.Millisecond * 100)
31- w := git.GitWebServer(cfg)
32+ w := patchbin.GitWebServer(cfg)
33 addr := fmt.Sprintf("%s:%s", cfg.Host, cfg.WebPort)
34 go func() {
35 _ = http.ListenAndServe(addr, w)
36@@ -72,35 +72,33 @@ func main() {
37 panic(err)
38 }
39
40- // Accepted patch
41+ // Opened patch (creator opens their own PR)
42 userKey.MustCmd(patch, "pr create test")
43- userKey.MustCmd(nil, "pr edit 1 Accepted patch")
44- adminKey.MustCmd(nil, `pr accept --comment "lgtm!" 1`)
45+ userKey.MustCmd(nil, "pr edit 1 Opened patch")
46+ userKey.MustCmd(nil, `pr open --comment "ready for review" 1`)
47
48- // Closed patch (admin)
49+ // Drafted patch (creator sets back to draft)
50 userKey.MustCmd(patch, "pr create test")
51- userKey.MustCmd(nil, "pr edit 2 Closed patch (admin)")
52- adminKey.MustCmd(nil, `pr close --comment "Thanks for the effort! I think we might use PR #1 though." 2`)
53+ userKey.MustCmd(nil, "pr edit 2 Drafted patch")
54+ userKey.MustCmd(nil, `pr draft --comment "need more work" 2`)
55
56- // Closed patch (contributor)
57+ // Opened then re-drafted by creator
58 userKey.MustCmd(patch, "pr create test")
59- userKey.MustCmd(nil, "pr edit 3 Closed patch (contributor)")
60- userKey.MustCmd(nil, `pr close --comment "Woops, didn't mean to submit yet" 3`)
61+ userKey.MustCmd(nil, "pr edit 3 Opened then re-drafted")
62+ userKey.MustCmd(nil, `pr open 3`)
63+ userKey.MustCmd(nil, `pr draft --comment "Woops, didn't mean to submit yet" 3`)
64
65- // Reviewed patch
66+ // Patchset added by another user, creator opens
67 userKey.MustCmd(patch, "pr create test")
68- userKey.MustCmd(nil, "pr edit 4 Reviewed patch")
69- adminKey.MustCmd(otherPatch, "pr add --review 4")
70+ userKey.MustCmd(nil, "pr edit 5 Patchset from another user")
71+ adminKey.MustCmd(otherPatch, `pr add 5`)
72+ userKey.MustCmd(nil, `pr open --comment "updated with feedback" 5`)
73
74- // Accepted patch with review
75+ // Patchset added by another user, creator drafts
76 userKey.MustCmd(patch, "pr create test")
77- userKey.MustCmd(nil, "pr edit 5 Accepted patch with review")
78- adminKey.MustCmd(otherPatch, `pr add --accept --comment "L G T M" 5`)
79-
80- // Closed patch with review
81- userKey.MustCmd(patch, "pr create test")
82- userKey.MustCmd(nil, "pr edit 6 Closed patch with review")
83- adminKey.MustCmd(otherPatch, `pr add --close --comment "So close! I think we might try something else instead." 6`)
84+ userKey.MustCmd(nil, "pr edit 6 Patchset then drafted")
85+ adminKey.MustCmd(otherPatch, `pr add 6`)
86+ userKey.MustCmd(nil, `pr draft --comment "taking a step back on this" 6`)
87
88 // Range Diff
89 userKey.MustCmd(rd1, "pr create test")
90@@ -118,5 +116,4 @@ var cfgTmpl = `
91 url = "localhost"
92 data_dir = %q
93 admins = [%q]
94-time_format = "01/02/2006 15:04:05 07:00"
95-create_repo = "user"`
96+time_format = "01/02/2006 15:04:05 07:00"`
+161,
-0
1@@ -0,0 +1,161 @@
2+package patchbin
3+
4+import (
5+ "crypto/sha256"
6+ "encoding/hex"
7+ "fmt"
8+ "strings"
9+ "time"
10+)
11+
12+// HasCoverLetter checks if the first patch is a cover letter (no diff).
13+func HasCoverLetter(patches []*Patch) bool {
14+ if len(patches) == 0 {
15+ return false
16+ }
17+ return !strings.Contains(patches[0].RawText, "diff --git")
18+}
19+
20+// pubkeyFingerprint returns a SHA256 fingerprint for an SSH public key.
21+func pubkeyFingerprint(pubkey string) string {
22+ keyBytes := []byte(strings.TrimSpace(pubkey) + "\n")
23+ hash := sha256.Sum256(keyBytes)
24+ return "SHA256:" + hex.EncodeToString(hash[:])
25+}
26+
27+// patchsetEventTypes are events that represent a new revision being submitted.
28+var patchsetEventTypes = map[string]bool{
29+ "pr_patchset_added": true,
30+ "pr_created": true,
31+}
32+
33+// BuildDiscussion formats event logs into a plain-text discussion thread.
34+// Uses SSH pubkey fingerprints for user identity.
35+// Interleaves "Submitted revision ps-X" lines for patchset events.
36+func BuildDiscussion(events []*EventLog, users map[int64]*User) string {
37+ if len(events) == 0 {
38+ return ""
39+ }
40+
41+ var buf strings.Builder
42+ for _, event := range events {
43+ user := users[event.UserID]
44+ if user == nil {
45+ continue
46+ }
47+
48+ fp := pubkeyFingerprint(user.Pubkey)
49+ ts := event.CreatedAt.Format(time.RFC3339)
50+
51+ // Insert revision marker for patchset events
52+ if patchsetEventTypes[event.Event] && event.PatchsetID.Valid {
53+ ps := fmt.Sprintf("ps-%d", event.PatchsetID.Int64)
54+ fmt.Fprintf(&buf, "[%s] %s:\n", ts, fp)
55+ fmt.Fprintf(&buf, " Submitted revision %s\n\n", ps)
56+ }
57+
58+ comment := event.Data.Comment
59+ if comment == "" {
60+ continue
61+ }
62+
63+ fmt.Fprintf(&buf, "[%s] %s:\n", ts, fp)
64+ // Indent comment lines
65+ for _, line := range strings.Split(comment, "\n") {
66+ fmt.Fprintf(&buf, " %s\n", line)
67+ }
68+ buf.WriteString("\n")
69+ }
70+
71+ result := buf.String()
72+ // Strip trailing newline for clean embedding
73+ return strings.TrimRight(result, "\n")
74+}
75+
76+// GenerateCoverLetterPatch creates a cover letter patch in mbox format.
77+// Empty tree, PR title as subject, References trailer + discussion in body.
78+func GenerateCoverLetterPatch(pr *PatchRequest, discussion string, cfgURL string) string {
79+ var buf strings.Builder
80+
81+ // mbox From line (fake SHA for empty tree commit)
82+ buf.WriteString("From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001\n")
83+
84+ fmt.Fprintf(&buf, "From: patchbin <patchbin@%s>\n", cfgURL)
85+ fmt.Fprintf(&buf, "Date: %s\n", pr.CreatedAt.Format(time.RFC1123Z))
86+ fmt.Fprintf(&buf, "Subject: [patchbin #%d] %s\n", pr.ID, pr.Name)
87+ buf.WriteString("\n")
88+
89+ // References trailer (in body, before discussion)
90+ fmt.Fprintf(&buf, "References: https://%s/pr/%d\n", cfgURL, pr.ID)
91+
92+ // Discussion in commit message body (before any --- separator)
93+ if discussion != "" {
94+ buf.WriteString("\n")
95+ buf.WriteString(discussion)
96+ buf.WriteString("\n")
97+ }
98+
99+ // Sign-off trailer
100+ buf.WriteString("\n-- \npatchbin cover letter\n")
101+
102+ return buf.String()
103+}
104+
105+// AugmentCoverLetterPatch appends References trailer and discussion to an
106+// existing cover letter patch. Preserves original content.
107+func AugmentCoverLetterPatch(rawText string, discussion string, cfgURL string, prID int64) string {
108+ // Insert References and discussion before the sign-off trailer "-- \n"
109+ // If no trailer exists, append before the end.
110+
111+ insert := fmt.Sprintf("\nReferences: https://%s/pr/%d\n", cfgURL, prID)
112+ if discussion != "" {
113+ insert += "\n" + discussion + "\n"
114+ }
115+
116+ trailer := "\n-- \n"
117+ idx := strings.Index(rawText, trailer)
118+ if idx != -1 {
119+ // Insert before the trailer
120+ before := rawText[:idx]
121+ after := rawText[idx:]
122+ return before + insert + after
123+ }
124+
125+ // No trailer found, append at end
126+ return rawText + insert
127+}
128+
129+// GenerateMboxWithCoverLetter returns the full mbox: cover letter + patches.
130+// If the first patch is already a cover letter, augments it with References + discussion.
131+// If not, generates a new cover letter from the PR name.
132+func GenerateMboxWithCoverLetter(pr *PatchRequest, patches []*Patch,
133+ events []*EventLog, users map[int64]*User, cfgURL string,
134+) string {
135+ discussion := BuildDiscussion(events, users)
136+
137+ var buf strings.Builder
138+
139+ if HasCoverLetter(patches) {
140+ // Augment existing cover letter
141+ augmented := AugmentCoverLetterPatch(patches[0].RawText, discussion, cfgURL, pr.ID)
142+ buf.WriteString(augmented)
143+
144+ // Append remaining patches
145+ for _, patch := range patches[1:] {
146+ buf.WriteString("\n")
147+ buf.WriteString(patch.RawText)
148+ }
149+ } else {
150+ // Generate new cover letter
151+ cover := GenerateCoverLetterPatch(pr, discussion, cfgURL)
152+ buf.WriteString(cover)
153+
154+ // Append all patches
155+ for _, patch := range patches {
156+ buf.WriteString("\n")
157+ buf.WriteString(patch.RawText)
158+ }
159+ }
160+
161+ return buf.String()
162+}
+431,
-0
1@@ -0,0 +1,431 @@
2+package patchbin
3+
4+import (
5+ "database/sql"
6+ "strings"
7+ "testing"
8+ "time"
9+)
10+
11+// buildTestPR returns a minimal PatchRequest for testing.
12+func buildTestPR(id int64, name string) *PatchRequest {
13+ return &PatchRequest{
14+ ID: id,
15+ Name: name,
16+ RepoName: "test-repo",
17+ Status: StatusOpen,
18+ CreatedAt: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC),
19+ }
20+}
21+
22+// buildTestUser returns a minimal User for testing.
23+func buildTestUser(id int64, pubkey string) *User {
24+ return &User{
25+ ID: id,
26+ Pubkey: pubkey,
27+ }
28+}
29+
30+// buildTestEvents returns event logs for testing.
31+func buildTestEvents() []*EventLog {
32+ return []*EventLog{
33+ {
34+ ID: 1,
35+ UserID: 1,
36+ PatchRequestID: sql.NullInt64{Int64: 42, Valid: true},
37+ Event: "pr_created",
38+ CreatedAt: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC),
39+ Data: EventData{Comment: "Initial submission."},
40+ },
41+ {
42+ ID: 2,
43+ UserID: 2,
44+ PatchRequestID: sql.NullInt64{Int64: 42, Valid: true},
45+ PatchsetID: sql.NullInt64{Int64: 3, Valid: true},
46+ Event: "pr_patchset_added",
47+ CreatedAt: time.Date(2025, 1, 16, 9, 0, 0, 0, time.UTC),
48+ Data: EventData{Comment: "LGTM. One suggestion: add rate limiting."},
49+ },
50+ }
51+}
52+
53+// buildTestUsers returns a user map for testing.
54+func buildTestUsers() map[int64]*User {
55+ return map[int64]*User{
56+ 1: buildTestUser(1, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGtest1 test1@host"),
57+ 2: buildTestUser(2, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGtest2 test2@host"),
58+ }
59+}
60+
61+// buildTestPatchesNoCover returns patches without a cover letter.
62+func buildTestPatchesNoCover() []*Patch {
63+ return []*Patch{
64+ {
65+ Title: "feat: add auth middleware",
66+ Body: "Adds JWT-based authentication.",
67+ RawText: "From abc123 Mon Sep 17 00:00:00 2001\nFrom: Test <test@example.com>\nDate: Wed, 3 Jul 2024 15:18:47 -0400\nSubject: [PATCH] feat: add auth middleware\n\nAdds JWT-based authentication.\n\ndiff --git a/auth.go b/auth.go\n",
68+ AuthorName: "Test",
69+ AuthorEmail: "test@example.com",
70+ },
71+ }
72+}
73+
74+// buildTestPatchesWithCover returns patches with a user-provided cover letter.
75+func buildTestPatchesWithCover() []*Patch {
76+ return []*Patch{
77+ {
78+ Title: "Add torch deps",
79+ Body: "I took the liberty of adding a requirements file for python.\n\nBob Sour (1):\n chore: add torch to requirements",
80+ RawText: "From def456 Mon Sep 17 00:00:00 2001\nFrom: Bob <bob@example.com>\nDate: Sun, 14 Jul 2024 07:14:44 -0400\nSubject: [PATCH 0/2] Add torch deps\n\nI took the liberty of adding a requirements file for python.\n\nBob Sour (1):\n chore: add torch to requirements\n\n-- \n2.45.2\n",
81+ AuthorName: "Bob",
82+ AuthorEmail: "bob@example.com",
83+ },
84+ {
85+ Title: "feat: build an rnn",
86+ Body: "Build a simple RNN.",
87+ RawText: "From abc123 Mon Sep 17 00:00:00 2001\nFrom: Bob <bob@example.com>\nDate: Wed, 3 Jul 2024 15:18:47 -0400\nSubject: [PATCH 1/2] feat: build an rnn\n\nBuild a simple RNN.\n\ndiff --git a/train.py b/train.py\n",
88+ AuthorName: "Bob",
89+ AuthorEmail: "bob@example.com",
90+ },
91+ }
92+}
93+
94+func TestHasCoverLetter_NoCover(t *testing.T) {
95+ patches := buildTestPatchesNoCover()
96+ if HasCoverLetter(patches) {
97+ t.Fatal("expected no cover letter, got true")
98+ }
99+}
100+
101+func TestHasCoverLetter_WithCover(t *testing.T) {
102+ patches := buildTestPatchesWithCover()
103+ if !HasCoverLetter(patches) {
104+ t.Fatal("expected cover letter, got false")
105+ }
106+}
107+
108+func TestHasCoverLetter_EmptyPatches(t *testing.T) {
109+ if HasCoverLetter(nil) {
110+ t.Fatal("expected no cover letter for nil patches")
111+ }
112+ if HasCoverLetter([]*Patch{}) {
113+ t.Fatal("expected no cover letter for empty patches")
114+ }
115+}
116+
117+func TestBuildDiscussion(t *testing.T) {
118+ events := buildTestEvents()
119+ users := buildTestUsers()
120+
121+ discussion := BuildDiscussion(events, users)
122+
123+ if discussion == "" {
124+ t.Fatal("discussion should not be empty")
125+ }
126+
127+ // Should contain pubkey fingerprints, not usernames
128+ if !strings.Contains(discussion, "SHA256:") {
129+ t.Fatal("discussion should contain SHA256 pubkey fingerprints")
130+ }
131+
132+ // Should contain event comments
133+ if !strings.Contains(discussion, "Initial submission") {
134+ t.Fatal("discussion should contain first event comment")
135+ }
136+ if !strings.Contains(discussion, "rate limiting") {
137+ t.Fatal("discussion should contain feedback comment")
138+ }
139+
140+ // Should contain timestamps
141+ if !strings.Contains(discussion, "2025-01-15") {
142+ t.Fatal("discussion should contain date")
143+ }
144+
145+ // Should contain revision markers for patchset events
146+ if !strings.Contains(discussion, "Submitted revision ps-3") {
147+ t.Fatalf("discussion should contain revision marker, got:\n%s", discussion)
148+ }
149+ // Revision marker should have pubkey fingerprint
150+ if !strings.Contains(discussion, "SHA256:") {
151+ t.Fatal("revision marker should include SHA256 fingerprint")
152+ }
153+}
154+
155+func TestBuildDiscussion_RevisionMarkers(t *testing.T) {
156+ events := []*EventLog{
157+ {
158+ ID: 1,
159+ UserID: 1,
160+ PatchRequestID: sql.NullInt64{Int64: 1, Valid: true},
161+ Event: "pr_created",
162+ CreatedAt: time.Date(2025, 1, 15, 10, 0, 0, 0, time.UTC),
163+ Data: EventData{Comment: "Initial submission."},
164+ },
165+ {
166+ ID: 2,
167+ UserID: 1,
168+ PatchRequestID: sql.NullInt64{Int64: 1, Valid: true},
169+ PatchsetID: sql.NullInt64{Int64: 3, Valid: true},
170+ Event: "pr_patchset_added",
171+ CreatedAt: time.Date(2025, 1, 16, 9, 0, 0, 0, time.UTC),
172+ Data: EventData{Comment: "Updated based on feedback."},
173+ },
174+ {
175+ ID: 3,
176+ UserID: 2,
177+ PatchRequestID: sql.NullInt64{Int64: 1, Valid: true},
178+ PatchsetID: sql.NullInt64{Int64: 5, Valid: true},
179+ Event: "pr_patchset_added",
180+ CreatedAt: time.Date(2025, 1, 17, 11, 0, 0, 0, time.UTC),
181+ Data: EventData{Comment: "LGTM."},
182+ },
183+ }
184+ users := map[int64]*User{
185+ 1: buildTestUser(1, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGtest1 test1@host"),
186+ 2: buildTestUser(2, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGtest2 test2@host"),
187+ }
188+
189+ discussion := BuildDiscussion(events, users)
190+
191+ // Should have revision markers interleaved with comments, same format as comments
192+ if !strings.Contains(discussion, "Submitted revision ps-3") {
193+ t.Fatal("should contain ps-3 revision marker")
194+ }
195+ if !strings.Contains(discussion, "Submitted revision ps-5") {
196+ t.Fatal("should contain ps-5 revision marker")
197+ }
198+
199+ // Revision markers should come before associated comments
200+ ps3Idx := strings.Index(discussion, "Submitted revision ps-3")
201+ updatedIdx := strings.Index(discussion, "Updated based on feedback")
202+ if ps3Idx >= updatedIdx {
203+ t.Fatal("revision marker should come before its comment")
204+ }
205+}
206+
207+func TestBuildDiscussion_EmptyEvents(t *testing.T) {
208+ discussion := BuildDiscussion(nil, buildTestUsers())
209+ if discussion != "" {
210+ t.Fatalf("expected empty discussion for no events, got: %q", discussion)
211+ }
212+}
213+
214+func TestGenerateCoverLetterPatch(t *testing.T) {
215+ pr := buildTestPR(42, "feat: add auth middleware")
216+ discussion := "[2025-01-15] SHA256:test:\n Hello world."
217+
218+ patch := GenerateCoverLetterPatch(pr, discussion, "patchbin.example.com")
219+
220+ // Should contain PR title in subject
221+ if !strings.Contains(patch, "feat: add auth middleware") {
222+ t.Fatal("cover letter should contain PR title in subject")
223+ }
224+
225+ // Should contain References trailer with URL
226+ expectedRef := "References: https://patchbin.example.com/pr/42"
227+ if !strings.Contains(patch, expectedRef) {
228+ t.Fatalf("cover letter should contain References trailer, got:\n%s", patch)
229+ }
230+
231+ // Should contain discussion
232+ if !strings.Contains(patch, "Hello world.") {
233+ t.Fatal("cover letter should contain discussion")
234+ }
235+
236+ // Should be a valid mbox (starts with "From ")
237+ if !strings.HasPrefix(patch, "From ") {
238+ t.Fatal("cover letter should start with 'From ' (mbox format)")
239+ }
240+
241+ // Should NOT contain diff --git (empty tree)
242+ if strings.Contains(patch, "diff --git") {
243+ t.Fatal("cover letter should not contain diffs (empty tree)")
244+ }
245+
246+ // Discussion should be BEFORE any --- separator (in commit message body)
247+ sepIdx := strings.Index(patch, "\n---\n")
248+ discIdx := strings.Index(patch, "Hello world.")
249+ if sepIdx != -1 && discIdx > sepIdx {
250+ t.Fatal("discussion should be before the --- separator (in commit message body)")
251+ }
252+}
253+
254+func TestGenerateCoverLetterPatch_NoDiscussion(t *testing.T) {
255+ pr := buildTestPR(1, "fix: typo")
256+ patch := GenerateCoverLetterPatch(pr, "", "patchbin.example.com")
257+
258+ if !strings.Contains(patch, "fix: typo") {
259+ t.Fatal("cover letter should contain PR title")
260+ }
261+ if !strings.Contains(patch, "References:") {
262+ t.Fatal("cover letter should contain References trailer")
263+ }
264+}
265+
266+func TestAugmentCoverLetterPatch(t *testing.T) {
267+ original := "From def456 Mon Sep 17 00:00:00 2001\nFrom: Bob <bob@example.com>\nDate: Sun, 14 Jul 2024 07:14:44 -0400\nSubject: [PATCH 0/2] Add torch deps\n\nI took the liberty of adding a requirements file.\n\n-- \n2.45.2\n"
268+ discussion := "[2025-01-15] SHA256:test:\n Great patch!"
269+
270+ augmented := AugmentCoverLetterPatch(original, discussion, "patchbin.example.com", 42)
271+
272+ // Should preserve original content
273+ if !strings.Contains(augmented, "Add torch deps") {
274+ t.Fatal("augmented cover letter should preserve original subject")
275+ }
276+ if !strings.Contains(augmented, "I took the liberty") {
277+ t.Fatal("augmented cover letter should preserve original body")
278+ }
279+
280+ // Should add References trailer
281+ if !strings.Contains(augmented, "References: https://patchbin.example.com/pr/42") {
282+ t.Fatal("augmented cover letter should contain References trailer")
283+ }
284+
285+ // Should add discussion
286+ if !strings.Contains(augmented, "Great patch!") {
287+ t.Fatal("augmented cover letter should contain discussion")
288+ }
289+
290+ // Should still be valid mbox
291+ if !strings.HasPrefix(augmented, "From ") {
292+ t.Fatal("augmented cover letter should start with 'From ' (mbox format)")
293+ }
294+}
295+
296+func TestAugmentCoverLetterPatch_NoDiscussion(t *testing.T) {
297+ original := "From def456 Mon Sep 17 00:00:00 2001\nFrom: Bob <bob@example.com>\nDate: Sun, 14 Jul 2024 07:14:44 -0400\nSubject: [PATCH 0/1] Simple patch\n\nJust a patch.\n\n-- \n2.45.2\n"
298+
299+ augmented := AugmentCoverLetterPatch(original, "", "patchbin.example.com", 1)
300+
301+ // Should preserve original content
302+ if !strings.Contains(augmented, "Simple patch") {
303+ t.Fatal("augmented cover letter should preserve original subject")
304+ }
305+ if !strings.Contains(augmented, "Just a patch.") {
306+ t.Fatal("augmented cover letter should preserve original body")
307+ }
308+
309+ // Should still add References
310+ if !strings.Contains(augmented, "References:") {
311+ t.Fatal("augmented cover letter should contain References trailer")
312+ }
313+}
314+
315+func TestGenerateMboxWithCoverLetter_NoExistingCover(t *testing.T) {
316+ pr := buildTestPR(42, "feat: add auth middleware")
317+ patches := buildTestPatchesNoCover()
318+ events := buildTestEvents()
319+ users := buildTestUsers()
320+
321+ mbox := GenerateMboxWithCoverLetter(pr, patches, events, users, "patchbin.example.com")
322+
323+ // Should start with a cover letter (From ... for the cover)
324+ if !strings.HasPrefix(mbox, "From ") {
325+ t.Fatal("mbox should start with 'From ' (cover letter)")
326+ }
327+
328+ // Should contain cover letter with PR title
329+ if !strings.Contains(mbox, "[patchbin #42] feat: add auth middleware") {
330+ t.Fatal("mbox should contain cover letter with PR title")
331+ }
332+
333+ // Should contain References
334+ if !strings.Contains(mbox, "References: https://patchbin.example.com/pr/42") {
335+ t.Fatal("mbox should contain References trailer")
336+ }
337+
338+ // Should contain discussion with pubkey fingerprints
339+ if !strings.Contains(mbox, "SHA256:") {
340+ t.Fatal("mbox should contain discussion with SHA256 fingerprints")
341+ }
342+
343+ // Should contain the original patches
344+ if !strings.Contains(mbox, "feat: add auth middleware") {
345+ t.Fatal("mbox should contain original patches")
346+ }
347+
348+ // Should contain the diff from original patches
349+ if !strings.Contains(mbox, "diff --git") {
350+ t.Fatal("mbox should contain diffs from original patches")
351+ }
352+}
353+
354+func TestGenerateMboxWithCoverLetter_WithExistingCover(t *testing.T) {
355+ pr := buildTestPR(7, "Add torch deps")
356+ patches := buildTestPatchesWithCover()
357+ events := buildTestEvents()
358+ users := buildTestUsers()
359+
360+ mbox := GenerateMboxWithCoverLetter(pr, patches, events, users, "patchbin.example.com")
361+
362+ // Should preserve the user's cover letter content
363+ if !strings.Contains(mbox, "I took the liberty") {
364+ t.Fatal("mbox should preserve user's cover letter body")
365+ }
366+
367+ // Should add References to the cover letter
368+ if !strings.Contains(mbox, "References: https://patchbin.example.com/pr/7") {
369+ t.Fatal("mbox should add References trailer to existing cover letter")
370+ }
371+
372+ // Should add discussion
373+ if !strings.Contains(mbox, "SHA256:") {
374+ t.Fatal("mbox should contain discussion with SHA256 fingerprints")
375+ }
376+
377+ // Should contain all original patches
378+ if !strings.Contains(mbox, "feat: build an rnn") {
379+ t.Fatal("mbox should contain original patches")
380+ }
381+}
382+
383+func TestGenerateMboxWithCoverLetter_NoEvents(t *testing.T) {
384+ pr := buildTestPR(1, "fix: typo")
385+ patches := buildTestPatchesNoCover()
386+
387+ mbox := GenerateMboxWithCoverLetter(pr, patches, nil, nil, "patchbin.example.com")
388+
389+ // Should still have a cover letter
390+ if !strings.Contains(mbox, "[patchbin #1] fix: typo") {
391+ t.Fatal("mbox should contain cover letter with PR title")
392+ }
393+
394+ // Should still have References
395+ if !strings.Contains(mbox, "References:") {
396+ t.Fatal("mbox should contain References trailer")
397+ }
398+
399+ // Should contain the original patches
400+ if !strings.Contains(mbox, "diff --git") {
401+ t.Fatal("mbox should contain diffs from original patches")
402+ }
403+}
404+
405+func TestGenerateMboxWithCoverLetter_PreservesPatchOrder(t *testing.T) {
406+ pr := buildTestPR(10, "multi-patch series")
407+ patches := []*Patch{
408+ {
409+ Title: "first commit",
410+ RawText: "From aaa Mon Sep 17 00:00:00 2001\nFrom: A <a@b.com>\nSubject: [PATCH 1/3] first commit\n\ndiff --git a/a.go b/a.go\n",
411+ },
412+ {
413+ Title: "second commit",
414+ RawText: "From bbb Mon Sep 17 00:00:00 2001\nFrom: A <a@b.com>\nSubject: [PATCH 2/3] second commit\n\ndiff --git a/b.go b/b.go\n",
415+ },
416+ {
417+ Title: "third commit",
418+ RawText: "From ccc Mon Sep 17 00:00:00 2001\nFrom: A <a@b.com>\nSubject: [PATCH 3/3] third commit\n\ndiff --git a/c.go b/c.go\n",
419+ },
420+ }
421+
422+ mbox := GenerateMboxWithCoverLetter(pr, patches, nil, nil, "patchbin.example.com")
423+
424+ // Verify patch order is preserved
425+ firstIdx := strings.Index(mbox, "first commit")
426+ secondIdx := strings.Index(mbox, "second commit")
427+ thirdIdx := strings.Index(mbox, "third commit")
428+
429+ if firstIdx >= secondIdx || secondIdx >= thirdIdx {
430+ t.Fatalf("patch order not preserved: first=%d, second=%d, third=%d", firstIdx, secondIdx, thirdIdx)
431+ }
432+}
+2,
-2
1@@ -18,8 +18,8 @@ services:
2 - "${GITPR_HTTP_V4:-80}:80"
3 - "${GITPR_HTTPS_V6:-[::1]:443}:443"
4 - "${GITPR_HTTP_V6:-[::1]:80}:80"
5- git-pr:
6- command: "/app/git-pr --config ${GITPR_CONFIG_PATH}"
7+ patchbin:
8+ command: "/app/patchbin --config ${GITPR_CONFIG_PATH}"
9 networks:
10 git:
11 aliases:
+3,
-3
1@@ -1,6 +1,6 @@
2 services:
3- git-pr:
4- image: ghcr.io/picosh/pico/git-pr:latest
5+ patchbin:
6+ image: ghcr.io/picosh/pico/patchbin:latest
7 restart: always
8 volumes:
9- - ./data/git-pr/data:/app/data
10+ - ./data/patchbin/data:/app/data
+34,
-92
1@@ -1,4 +1,4 @@
2-package git
3+package patchbin
4
5 import (
6 "context"
7@@ -7,9 +7,8 @@ import (
8 "testing"
9 "time"
10
11- "github.com/gkampitakis/go-snaps/snaps"
12- "github.com/picosh/git-pr/fixtures"
13- "github.com/picosh/git-pr/util"
14+ "github.com/picosh/patchbin/fixtures"
15+ "github.com/picosh/patchbin/util"
16 )
17
18 func TestE2E(t *testing.T) {
19@@ -33,23 +32,15 @@ func testSingleTenantE2E(t *testing.T) {
20 // Hack to wait for startup
21 time.Sleep(time.Millisecond * 100)
22
23- suite.userKey.MustCmd(suite.patch, "register")
24- suite.adminKey.MustCmd(suite.patch, "register")
25+ // Users are auto-created on first use, no registration needed
26+ t.Log("User should be able to create a PR")
27+ suite.userKey.MustCmd(suite.patch, "pr create test")
28
29- t.Log("User cannot create repo")
30- _, err := suite.userKey.Cmd(suite.patch, "pr create test")
31- if err == nil {
32- t.Fatal("user should not be able to create a PR")
33- }
34+ t.Log("Admin should also be able to create a PR")
35 suite.adminKey.MustCmd(suite.patch, "pr create test")
36
37- t.Log("User should be able to create a patch")
38- suite.userKey.MustCmd(suite.patch, "pr create test")
39-
40- t.Log("Snapshot test ls command")
41- actual, err := suite.userKey.Cmd(nil, "pr ls")
42- bail(err)
43- snaps.MatchSnapshot(t, actual)
44+ t.Log("List PRs")
45+ suite.userKey.MustCmd(nil, "pr ls")
46 }
47
48 func testMultiTenantE2E(t *testing.T) {
49@@ -68,83 +59,36 @@ func testMultiTenantE2E(t *testing.T) {
50
51 time.Sleep(time.Millisecond * 100)
52
53- suite.userKey.MustCmd(suite.patch, "register")
54- suite.adminKey.MustCmd(suite.patch, "register")
55+ // Users are auto-created on first use, no registration needed
56+ // In zero-trust model, no repo creation needed
57+ // Anyone can create PRs in any repo
58
59- t.Log("Admin should be able to create a repo")
60- suite.adminKey.MustCmd(nil, "repo create test")
61+ t.Log("User creates PR")
62+ output := suite.userKey.MustCmd(suite.patch, "pr create test")
63+ userPRID := util.ParsePRID(output)
64
65- t.Log("Accepted pr")
66- suite.userKey.MustCmd(suite.patch, "pr create admin/test")
67- suite.userKey.MustCmd(nil, "pr edit 1 Accepted patch")
68- _, err := suite.userKey.Cmd(nil, "pr accept 1")
69- if err == nil {
70- t.Fatal("contrib should not be able to accept their own PR")
71- }
72- suite.adminKey.MustCmd(nil, "pr accept 1")
73+ t.Log("User edits PR title (only creator can edit)")
74+ suite.userKey.MustCmd(nil, "pr edit "+userPRID+" Updated title")
75
76- t.Log("Closed pr (admin)")
77- suite.userKey.MustCmd(suite.patch, "pr create test")
78- suite.userKey.MustCmd(nil, "pr edit 2 Closed patch (admin)")
79- suite.adminKey.MustCmd(nil, "pr close 2")
80+ t.Log("User changes PR status to open (only creator can change status)")
81+ suite.userKey.MustCmd(nil, "pr open "+userPRID)
82
83- t.Log("Closed pr (contributor)")
84- suite.userKey.MustCmd(suite.patch, "pr create test")
85- suite.userKey.MustCmd(nil, "pr edit 3 Closed patch (contributor)")
86- suite.userKey.MustCmd(nil, "pr close 3")
87+ t.Log("Admin creates PR")
88+ suite.adminKey.MustCmd(suite.patch, "pr create admin-repo")
89
90- t.Log("Reviewed pr")
91- suite.userKey.MustCmd(suite.patch, "pr create test")
92- suite.userKey.MustCmd(nil, "pr edit 4 Reviewed patch")
93- suite.adminKey.MustCmd(suite.otherPatch, "pr add --review 4")
94+ t.Log("Admin adds patchset to user's PR (zero-trust: anyone can add)")
95+ suite.adminKey.MustCmd(suite.otherPatch, "pr add "+userPRID)
96
97- t.Log("Accepted pr with review")
98- suite.userKey.MustCmd(suite.patch, "pr create test")
99- suite.userKey.MustCmd(nil, "pr edit 5 Accepted patch with review")
100- suite.adminKey.MustCmd(suite.otherPatch, "pr add --accept 5")
101+ t.Log("User creates another PR and sets to open")
102+ output2 := suite.userKey.MustCmd(suite.patch, "pr create draft-repo")
103+ draftPRID := util.ParsePRID(output2)
104+ suite.userKey.MustCmd(nil, "pr open "+draftPRID)
105
106- t.Log("Closed pr with review")
107- suite.userKey.MustCmd(suite.patch, "pr create test")
108- suite.userKey.MustCmd(nil, "pr edit 6 Closed patch with review")
109- suite.adminKey.MustCmd(suite.otherPatch, "pr add --close 6")
110-
111- t.Log("Create pr with user repo and user can accept")
112- suite.userKey.MustCmd(nil, "repo create ai")
113- suite.adminKey.MustCmd(suite.patch, "pr create contributor/ai")
114- suite.userKey.MustCmd(suite.otherPatch, "pr accept 7")
115-
116- t.Log("Create pr with admin repo and admin can accept")
117- suite.adminKey.MustCmd(nil, "repo create ai")
118- suite.userKey.MustCmd(suite.patch, "pr create admin/ai")
119- suite.adminKey.MustCmd(suite.otherPatch, "pr add --accept 8")
120-
121- t.Log("Create pr with admin repo and user can accept with comment")
122- suite.adminKey.MustCmd(nil, "repo create ai")
123- suite.userKey.MustCmd(suite.patch, "pr create admin/ai")
124- suite.adminKey.MustCmd([]byte("nice work"), "pr accept --comment 9")
125-
126- t.Log("Create pr with default `bin` repo")
127- actual, err := suite.userKey.Cmd(suite.patch, "pr create")
128- bail(err)
129- snaps.MatchSnapshot(t, actual)
130-
131- t.Log("Snapshot test ls command")
132- actual, err = suite.userKey.Cmd(nil, "pr ls")
133- bail(err)
134- snaps.MatchSnapshot(t, actual)
135-
136- t.Log("Snapshot test logs command")
137- actual, err = suite.userKey.Cmd(nil, "logs --repo admin/ai")
138- bail(err)
139- snaps.MatchSnapshot(t, actual)
140-
141- t.Log("Delete repo")
142- suite.userKey.MustCmd(nil, "repo rm --write ai")
143-
144- t.Log("Snapshot test ls command with ai prs removed")
145- actual, err = suite.userKey.Cmd(nil, "pr ls")
146- bail(err)
147- snaps.MatchSnapshot(t, actual)
148+ t.Log("List PRs")
149+ suite.userKey.MustCmd(nil, "pr ls")
150+
151+ t.Log("View event logs")
152+ suite.userKey.MustCmd(nil, "logs")
153 }
154
155 type TestSuite struct {
156@@ -187,12 +131,10 @@ var cfgSingleTenantTmpl = `
157 url = "localhost"
158 data_dir = %q
159 admins = [%q]
160-time_format = "01/02/2006 15:04:05 07:00"
161-create_repo = "admin"`
162+time_format = "01/02/2006 15:04:05 07:00"`
163
164 var cfgMultiTenantTmpl = `
165 url = "localhost"
166 data_dir = %q
167 admins = [%q]
168-time_format = "01/02/2006 15:04:05 07:00"
169-create_repo = "user"`
170+time_format = "01/02/2006 15:04:05 07:00"`
+0,
-14
1@@ -1,14 +0,0 @@
2-1: 33c682a = 1: 33c682a chore: add torch and create random tensor
3-2: 22dde12 ! 2: dce20e7 docs: readme
4-@@ README.md
5- # Let's build an RNN
6-
7--This repo demonstrates building an RNN using `pytorch`
8-+This repo demonstrates building an RNN using `pytorch`.
9-+
10-+Here is some more readme information.
11-+
12-+Here is how to run this project locally:
13-+
14-+- install python and pip
15-+- `pip install -r requirements.txt`
+0,
-15
1@@ -1,15 +0,0 @@
2-# url is used for help commands, exclude protocol
3-url = "localhost"
4-# where we store the sqlite db, this toml file, and ssh host keys
5-data_dir = "./data"
6-# list of admin ssh pubkeys, authorized to submit review and other admin
7-# permissions
8-admins = []
9-# set datetime format for our clients
10-time_format = "2006-01-02"
11-# who can create new repos?
12-# admin: only admins
13-# user: admins and users
14-create_repo = "user"
15-# add a description box to the top of the index page, supports HTML
16-desc = ""
M
go.mod
+5,
-15
1@@ -1,11 +1,12 @@
2-module github.com/picosh/git-pr
3+module github.com/picosh/patchbin
4
5 go 1.25
6
7+// replace github.com/picosh/pico => ../pico
8+
9 require (
10 github.com/alecthomas/chroma/v2 v2.23.1
11 github.com/bluekeyes/go-gitdiff v0.8.0
12- github.com/gkampitakis/go-snaps v0.5.15
13 github.com/gorilla/feeds v1.2.0
14 github.com/jmoiron/sqlx v1.4.0
15 github.com/knadh/koanf/parsers/toml v0.1.0
16@@ -13,8 +14,8 @@ require (
17 github.com/knadh/koanf/providers/file v1.0.0
18 github.com/knadh/koanf/v2 v2.1.1
19 github.com/oddg/hungarian-algorithm v0.0.0-20170809162819-9567cbc363de
20- github.com/picosh/pico v1.13.2-0.20260226034118-391c4f989caa
21- github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
22+ github.com/picosh/pico v1.13.2-0.20260226141633-740c00adfc93
23+ github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82
24 github.com/urfave/cli/v2 v2.27.2
25 golang.org/x/crypto v0.47.0
26 modernc.org/sqlite v1.44.3
27@@ -28,16 +29,10 @@ require (
28 github.com/dlclark/regexp2 v1.11.5 // indirect
29 github.com/dustin/go-humanize v1.0.1 // indirect
30 github.com/fsnotify/fsnotify v1.7.0 // indirect
31- github.com/gkampitakis/ciinfo v0.3.2 // indirect
32- github.com/gkampitakis/go-diff v1.3.2 // indirect
33 github.com/go-andiamo/splitter v1.2.5 // indirect
34 github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 // indirect
35- github.com/goccy/go-yaml v1.18.0 // indirect
36 github.com/google/uuid v1.6.0 // indirect
37 github.com/knadh/koanf/maps v0.1.1 // indirect
38- github.com/kr/pretty v0.3.1 // indirect
39- github.com/kr/text v0.2.0 // indirect
40- github.com/maruel/natural v1.1.1 // indirect
41 github.com/mattn/go-isatty v0.0.20 // indirect
42 github.com/mitchellh/copystructure v1.2.0 // indirect
43 github.com/mitchellh/reflectwalk v1.0.2 // indirect
44@@ -49,12 +44,7 @@ require (
45 github.com/prometheus/common v0.67.5 // indirect
46 github.com/prometheus/procfs v0.19.2 // indirect
47 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
48- github.com/rogpeppe/go-internal v1.14.1 // indirect
49 github.com/russross/blackfriday/v2 v2.1.0 // indirect
50- github.com/tidwall/gjson v1.18.0 // indirect
51- github.com/tidwall/match v1.2.0 // indirect
52- github.com/tidwall/pretty v1.2.1 // indirect
53- github.com/tidwall/sjson v1.2.5 // indirect
54 github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect
55 go.yaml.in/yaml/v2 v2.4.3 // indirect
56 golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
M
go.sum
+4,
-38
1@@ -32,9 +32,6 @@ github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/q
2 github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
3 github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
4 github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
5-github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
6-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
7-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
8 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
9 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
10 github.com/delthas/go-libnp v0.2.0 h1:OVll7Z9CER0rYpgiglXN1QvuNtkyY5X9uzLqm+xDstA=
11@@ -51,12 +48,6 @@ github.com/forPelevin/gomoji v1.4.1 h1:7U+Bl8o6RV/dOQz7coQFWj/jX6Ram6/cWFOuFDEPE
12 github.com/forPelevin/gomoji v1.4.1/go.mod h1:mM6GtmCgpoQP2usDArc6GjbXrti5+FffolyQfGgPboQ=
13 github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
14 github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
15-github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
16-github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
17-github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
18-github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
19-github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
20-github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
21 github.com/go-andiamo/splitter v1.2.5 h1:P3NovWMY2V14TJJSolXBvlOmGSZo3Uz+LtTl2bsV/eY=
22 github.com/go-andiamo/splitter v1.2.5/go.mod h1:8WHU24t9hcMKU5FXDQb1hysSEC/GPuivIp0uKY1J8gw=
23 github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
24@@ -66,8 +57,6 @@ github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1
25 github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
26 github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 h1:TQcrn6Wq+sKGkpyPvppOz99zsMBaUOKXq6HSv655U1c=
27 github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
28-github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
29-github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
30 github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
31 github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
32 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
33@@ -99,11 +88,8 @@ github.com/knadh/koanf/providers/file v1.0.0 h1:DtPvSQBeF+N0QLPMz0yf2bx0nFSxUcnc
34 github.com/knadh/koanf/providers/file v1.0.0/go.mod h1:/faSBcv2mxPVjFrXck95qeoyoZ5myJ6uxN8OOVNJJCI=
35 github.com/knadh/koanf/v2 v2.1.1 h1:/R8eXqasSTsmDCsAyYj+81Wteg8AqrV9CP6gvsTsOmM=
36 github.com/knadh/koanf/v2 v2.1.1/go.mod h1:4mnTRbZCK+ALuBXHZMjDfG9y714L7TykVnZkXbMU3Es=
37-github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
38 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
39 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
40-github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
41-github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
42 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
43 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
44 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
45@@ -111,8 +97,6 @@ github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+
46 github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
47 github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI=
48 github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
49-github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
50-github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
51 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
52 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
53 github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
54@@ -135,12 +119,10 @@ github.com/oddg/hungarian-algorithm v0.0.0-20170809162819-9567cbc363de h1:kuqx+Z
55 github.com/oddg/hungarian-algorithm v0.0.0-20170809162819-9567cbc363de/go.mod h1:dv3Q0yoeN8DwXGhZiv8Vi6/rr9mPtf4ylV60eLTGjUo=
56 github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
57 github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
58-github.com/picosh/pico v1.13.2-0.20260226034118-391c4f989caa h1:yZGD5iMf3o1HYW1xB0+UOiypPWUVd9KSAEB0hi88UgY=
59-github.com/picosh/pico v1.13.2-0.20260226034118-391c4f989caa/go.mod h1:1Xml9JbPlipITzar/eu8/kcrfgXqXvx/XxL/xBj0xE8=
60+github.com/picosh/pico v1.13.2-0.20260226141633-740c00adfc93 h1:lvqE3uHcnF6orwdZyS+/TevRq+1Iqrvdl4JJhLwIFTw=
61+github.com/picosh/pico v1.13.2-0.20260226141633-740c00adfc93/go.mod h1:1Xml9JbPlipITzar/eu8/kcrfgXqXvx/XxL/xBj0xE8=
62 github.com/picosh/utils v0.0.0-20260125160622-5c3a9e231ec6 h1:9KfCtfcx7vrSyGU1K9whdE1crll9Aq+nAZ6c0FzuzvE=
63 github.com/picosh/utils v0.0.0-20260125160622-5c3a9e231ec6/go.mod h1:HogYEyJ43IGXrOa3D/kjM1pkzNAyh+pejRyv8Eo//pk=
64-github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
65-github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
66 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
67 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
68 github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
69@@ -155,30 +137,16 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
70 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
71 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
72 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
73-github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
74 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
75 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
76 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
77 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
78-github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
79-github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
80+github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 h1:6C8qej6f1bStuePVkLSFxoU22XBS165D3klxlzRg8F4=
81+github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82/go.mod h1:xe4pgH49k4SsmkQq5OT8abwhWmnzkhpgnXeekbx2efw=
82 github.com/soniakeys/quant v1.0.0 h1:N1um9ktjbkZVcywBVAAYpZYSHxEfJGzshHCxx/DaI0Y=
83 github.com/soniakeys/quant v1.0.0/go.mod h1:HI1k023QuVbD4H8i9YdfZP2munIHU4QpjsImz6Y6zds=
84-github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
85-github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
86 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
87 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
88-github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
89-github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
90-github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
91-github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
92-github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
93-github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
94-github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
95-github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
96-github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
97-github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
98-github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
99 github.com/urfave/cli/v2 v2.27.2 h1:6e0H+AkS+zDckwPCUrZkKX38mRaau4nL2uipkJpbkcI=
100 github.com/urfave/cli/v2 v2.27.2/go.mod h1:g0+79LmHHATl7DAcHO99smiR/T7uGLw84w8Y42x+4eM=
101 github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw=
102@@ -223,10 +191,8 @@ golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg
103 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
104 google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
105 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
106-gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
107 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
108 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
109-gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
110 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
111 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
112 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
D
logo.png
+0,
-0
M
mdw.go
+1,
-1
1@@ -1,4 +1,4 @@
2-package git
3+package patchbin
4
5 import (
6 "fmt"
+12,
-25
1@@ -1,4 +1,4 @@
2-package git
3+package patchbin
4
5 import (
6 "database/sql"
7@@ -13,10 +13,8 @@ import (
8 type Status string
9
10 const (
11- StatusOpen Status = "open"
12- StatusClosed Status = "closed"
13- StatusAccepted Status = "accepted"
14- StatusReviewed Status = "reviewed"
15+ StatusDraft Status = "draft"
16+ StatusOpen Status = "open"
17 )
18
19 // User is a db model for users.
20@@ -37,27 +35,17 @@ type Acl struct {
21 CreatedAt time.Time `db:"created_at"`
22 }
23
24-// Repo is a container for patch requests.
25-type Repo struct {
26- ID int64 `db:"id"`
27- Name string `db:"name"`
28- UserID int64 `db:"user_id"`
29- CreatedAt time.Time `db:"created_at"`
30- UpdatedAt time.Time `db:"updated_at"`
31-}
32-
33 // PatchRequest is a database model for patches submitted to a Repo.
34 type PatchRequest struct {
35- ID int64 `db:"id"`
36- UserID int64 `db:"user_id"`
37- RepoID int64 `db:"repo_id"`
38- Name string `db:"name"`
39- Text string `db:"text"`
40- Status Status `db:"status"`
41- CreatedAt time.Time `db:"created_at"`
42- UpdatedAt time.Time `db:"updated_at"`
43- // only used for aggregate queries
44- LastUpdated string `db:"last_updated"`
45+ ID int64 `db:"id"`
46+ UserID int64 `db:"user_id"`
47+ RepoName string `db:"repo_name"` // Plain string namespace
48+ Name string `db:"name"`
49+ Text string `db:"text"`
50+ Status Status `db:"status"`
51+ CreatedAt time.Time `db:"created_at"`
52+ UpdatedAt time.Time `db:"updated_at"`
53+ LastActivity time.Time `db:"last_activity"`
54 }
55
56 type Patchset struct {
57@@ -96,7 +84,6 @@ func (p *Patch) CalcDiff() string {
58 type EventLog struct {
59 ID int64 `db:"id"`
60 UserID int64 `db:"user_id"`
61- RepoID sql.NullInt64 `db:"repo_id"`
62 PatchRequestID sql.NullInt64 `db:"patch_request_id"`
63 PatchsetID sql.NullInt64 `db:"patchset_id"`
64 Event string `db:"event"`
+9,
-0
1@@ -0,0 +1,9 @@
2+url = "localhost" # used for help commands (exclude protocol)
3+data_dir = "./data" # loc for sqlite db, this toml file, and ssh host keys
4+admins = [] # list of admin ssh pubkeys
5+time_format = "2006-01-02" # set datetime format for our clients (golang datestr fmt)
6+desc = "" # adds description box to top of index page (supports HTML)
7+ssh_port = "2223"
8+rate_limit_count = 10 # max submissions (pr create, pr add, issue create) per rate_limit_interval, global across all users
9+rate_limit_interval = "1m" # golang time.ParseDuration string, e.g. "1m", "30s", "1h"
10+max_stdin_bytes = 5242880 # max stdin size (bytes) accepted for pr create, pr add, issue create
M
pr.go
+205,
-207
1@@ -1,11 +1,10 @@
2-package git
3+package patchbin
4
5 import (
6 "database/sql"
7 "errors"
8 "fmt"
9 "io"
10- "strings"
11 "time"
12
13 "github.com/jmoiron/sqlx"
14@@ -17,39 +16,32 @@ type PatchsetOp int
15
16 const (
17 OpNormal PatchsetOp = iota
18- OpReview
19- OpAccept
20- OpClose
21 )
22
23+var ErrNotPrOwner = fmt.Errorf("only the PR creator can perform this action")
24+
25 type GitPatchRequest interface {
26 GetUsers() ([]*User, error)
27 GetUserByID(userID int64) (*User, error)
28- GetUserByName(name string) (*User, error)
29 GetUserByPubkey(pubkey string) (*User, error)
30- GetRepos() ([]*Repo, error)
31- GetRepoByID(repoID int64) (*Repo, error)
32- GetRepoByName(user *User, repoName string) (*Repo, error)
33- CreateRepo(user *User, repoName string) (*Repo, error)
34- DeleteRepo(user *User, repoName string) error
35- RegisterUser(pubkey, name string) (*User, error)
36+ UpsertUserByPubkey(pubkey string) (*User, error)
37 IsBanned(pubkey, ipAddress string) error
38- SubmitPatchRequest(repoID int64, userID int64, patchset io.Reader) (*PatchRequest, error)
39+ SubmitPatchRequest(userID int64, userPubkey string, repoName string, patchset io.Reader) (*PatchRequest, error)
40 SubmitPatchset(prID, userID int64, op PatchsetOp, patchset io.Reader) ([]*Patch, error)
41 GetPatchRequestByID(prID int64) (*PatchRequest, error)
42 GetPatchRequests() ([]*PatchRequest, error)
43- GetPatchRequestsByRepoID(repoID int64) ([]*PatchRequest, error)
44+ GetPatchRequestsByRepoName(repoName string) ([]*PatchRequest, error)
45 GetPatchRequestsByPubkey(pubkey string) ([]*PatchRequest, error)
46 GetPatchsetsByPrID(prID int64) ([]*Patchset, error)
47 GetPatchsetByID(patchsetID int64) (*Patchset, error)
48 GetLatestPatchsetByPrID(prID int64) (*Patchset, error)
49- GetPatchesByPatchsetID(prID int64) ([]*Patch, error)
50- UpdatePatchRequestStatus(prID, userID int64, status Status, comment string) error
51- UpdatePatchRequestName(prID, userID int64, name string) error
52+ GetPatchesByPatchsetID(patchsetID int64) ([]*Patch, error)
53+ UpdatePatchRequestStatus(prID int64, userPubkey string, status Status, comment string) error
54+ UpdatePatchRequestName(prID int64, userPubkey string, name string) error
55 DeletePatchsetByID(userID, prID int64, patchsetID int64) error
56+ SubmitIssue(userID int64, userPubkey string, repoName, title, body string) (*PatchRequest, error)
57 CreateEventLog(tx *sqlx.Tx, eventLog EventLog) error
58 GetEventLogs() ([]*EventLog, error)
59- GetEventLogsByRepoName(user *User, repoName string) ([]*EventLog, error)
60 GetEventLogsByPrID(prID int64) ([]*EventLog, error)
61 GetEventLogsByUserID(userID int64) ([]*EventLog, error)
62 DiffPatchsets(aset *Patchset, bset *Patchset) ([]*RangeDiffOutput, error)
63@@ -84,12 +76,6 @@ func (pr PrCmd) GetUsers() ([]*User, error) {
64 return users, err
65 }
66
67-func (pr PrCmd) GetUserByName(name string) (*User, error) {
68- var user User
69- err := pr.Backend.DB.Get(&user, "SELECT * FROM app_users WHERE name=?", name)
70- return &user, err
71-}
72-
73 func (pr PrCmd) GetUserByID(id int64) (*User, error) {
74 var user User
75 err := pr.Backend.DB.Get(&user, "SELECT * FROM app_users WHERE id=?", id)
76@@ -102,97 +88,26 @@ func (pr PrCmd) GetUserByPubkey(pubkey string) (*User, error) {
77 return &user, err
78 }
79
80-func (pr PrCmd) computeUserName(name string) (string, error) {
81- var user User
82- err := pr.Backend.DB.Get(&user, "SELECT * FROM app_users WHERE name=?", name)
83- if err != nil {
84- return name, nil
85- }
86- // collision, generate random number and append
87- return fmt.Sprintf("%s%s", name, randSeq(4)), nil
88-}
89-
90-func (pr PrCmd) CreateRepo(user *User, repoName string) (*Repo, error) {
91- var repoID int64
92- row := pr.Backend.DB.QueryRow(
93- "INSERT INTO repos (user_id, name) VALUES (?, ?) RETURNING id",
94- user.ID,
95- repoName,
96- )
97- err := row.Scan(&repoID)
98- if err != nil {
99- return nil, err
100- }
101-
102- return pr.GetRepoByID(repoID)
103-}
104-
105-func (pr PrCmd) DeleteRepo(user *User, repoName string) error {
106- _, err := pr.Backend.DB.Exec(
107- "DELETE FROM repos WHERE user_id=? AND name=?",
108- user.ID,
109- repoName,
110- )
111- return err
112-}
113-
114-func (pr PrCmd) GetRepoByID(repoID int64) (*Repo, error) {
115- var repo Repo
116- err := pr.Backend.DB.Get(&repo, "SELECT * FROM repos WHERE id=?", repoID)
117- return &repo, err
118-}
119-
120-func (pr PrCmd) GetRepos() (repos []*Repo, err error) {
121- err = pr.Backend.DB.Select(
122- &repos,
123- "SELECT * from repos",
124- )
125- if err != nil {
126- return repos, err
127- }
128- if len(repos) == 0 {
129- return repos, fmt.Errorf("no repos found")
130- }
131- return repos, nil
132-}
133-
134-func (pr PrCmd) GetRepoByName(user *User, repoName string) (*Repo, error) {
135- var repo Repo
136- var err error
137-
138- if user == nil {
139- err = pr.Backend.DB.Get(&repo, "SELECT * FROM repos WHERE name=?", repoName)
140- } else {
141- err = pr.Backend.DB.Get(&repo, "SELECT * FROM repos WHERE user_id=? AND name=?", user.ID, repoName)
142- }
143-
144- if err != nil {
145- return nil, fmt.Errorf("repo not found: %s", repoName)
146+func (pr PrCmd) UpsertUserByPubkey(pubkey string) (*User, error) {
147+ user, err := pr.GetUserByPubkey(pubkey)
148+ if err == nil {
149+ return user, nil
150 }
151-
152- return &repo, nil
153+ return pr.createUser(pubkey)
154 }
155
156-func (pr PrCmd) createUser(pubkey, name string) (*User, error) {
157+func (pr PrCmd) createUser(pubkey string) (*User, error) {
158 if pubkey == "" {
159 return nil, fmt.Errorf("must provide pubkey when creating user")
160 }
161- if name == "" {
162- return nil, fmt.Errorf("must provide user name when creating user")
163- }
164-
165- userName, err := pr.computeUserName(name)
166- if err != nil {
167- pr.Backend.Logger.Error("could not compute username", "err", err)
168- }
169
170 var userID int64
171 row := pr.Backend.DB.QueryRow(
172 "INSERT INTO app_users (pubkey, name) VALUES (?, ?) RETURNING id",
173 pubkey,
174- userName,
175+ pubkey, // Use pubkey as name placeholder (will be computed on read)
176 )
177- err = row.Scan(&userID)
178+ err := row.Scan(&userID)
179 if err != nil {
180 return nil, err
181 }
182@@ -204,18 +119,6 @@ func (pr PrCmd) createUser(pubkey, name string) (*User, error) {
183 return user, err
184 }
185
186-func (pr PrCmd) RegisterUser(pubkey, name string) (*User, error) {
187- sanName := strings.ToLower(name)
188- if pubkey == "" {
189- return nil, fmt.Errorf("must provide pubkey during upsert")
190- }
191- _, err := pr.GetUserByPubkey(pubkey)
192- if err == nil {
193- return nil, fmt.Errorf("pubkey is already registered by another user")
194- }
195- return pr.createUser(pubkey, sanName)
196-}
197-
198 func (pr PrCmd) GetPatchsetsByPrID(prID int64) ([]*Patchset, error) {
199 patchsets := []*Patchset{}
200 err := pr.Backend.DB.Select(
201@@ -248,7 +151,7 @@ func (pr PrCmd) GetLatestPatchsetByPrID(prID int64) (*Patchset, error) {
202 return nil, err
203 }
204 if len(patchsets) == 0 {
205- return nil, fmt.Errorf("not patchsets found for patch request: %d", prID)
206+ return nil, fmt.Errorf("no patchsets found for patch request: %d", prID)
207 }
208 return patchsets[len(patchsets)-1], nil
209 }
210@@ -272,12 +175,40 @@ func (cmd PrCmd) GetPatchRequests() ([]*PatchRequest, error) {
211 return prs, err
212 }
213
214-func (cmd PrCmd) GetPatchRequestsByRepoID(repoID int64) ([]*PatchRequest, error) {
215+func (cmd PrCmd) GetPatchRequestsByStatus(status Status) ([]*PatchRequest, error) {
216+ prs := []*PatchRequest{}
217+ err := cmd.Backend.DB.Select(
218+ &prs,
219+ "SELECT * FROM patch_requests WHERE status=? ORDER BY last_activity DESC",
220+ status,
221+ )
222+ return prs, err
223+}
224+
225+func (cmd PrCmd) GetPatchRequestsActive() ([]*PatchRequest, error) {
226+ prs := []*PatchRequest{}
227+ err := cmd.Backend.DB.Select(
228+ &prs,
229+ "SELECT * FROM patch_requests WHERE status='open' AND last_activity >= datetime('now', '-14 days') ORDER BY last_activity DESC",
230+ )
231+ return prs, err
232+}
233+
234+func (cmd PrCmd) GetPatchRequestsInactive() ([]*PatchRequest, error) {
235 prs := []*PatchRequest{}
236 err := cmd.Backend.DB.Select(
237 &prs,
238- "SELECT * FROM patch_requests WHERE repo_id=? ORDER BY id DESC",
239- repoID,
240+ "SELECT * FROM patch_requests WHERE status='open' AND last_activity < datetime('now', '-14 days') ORDER BY last_activity DESC",
241+ )
242+ return prs, err
243+}
244+
245+func (cmd PrCmd) GetPatchRequestsByRepoName(repoName string) ([]*PatchRequest, error) {
246+ prs := []*PatchRequest{}
247+ err := cmd.Backend.DB.Select(
248+ &prs,
249+ "SELECT * FROM patch_requests WHERE repo_name=? ORDER BY id DESC",
250+ repoName,
251 )
252 return prs, err
253 }
254@@ -302,13 +233,35 @@ func (cmd PrCmd) GetPatchRequestByID(prID int64) (*PatchRequest, error) {
255 return &pr, err
256 }
257
258-// Status types: open, closed, accepted, reviewed.
259-func (cmd PrCmd) UpdatePatchRequestStatus(prID int64, userID int64, status Status, comment string) error {
260- tx, err := cmd.Backend.DB.Beginx()
261+func (cmd PrCmd) updateLastActivity(prID int64) error {
262+ _, err := cmd.Backend.DB.Exec(
263+ "UPDATE patch_requests SET last_activity=? WHERE id=?",
264+ time.Now(),
265+ prID,
266+ )
267+ return err
268+}
269+
270+// UpdatePatchRequestStatus changes the PR status. Only the PR creator (by pubkey) can do this.
271+func (cmd PrCmd) UpdatePatchRequestStatus(prID int64, userPubkey string, status Status, comment string) error {
272+ pr, err := cmd.GetPatchRequestByID(prID)
273+ if err != nil {
274+ return err
275+ }
276+
277+ // Verify the requester is the PR creator
278+ owner, err := cmd.GetUserByID(pr.UserID)
279 if err != nil {
280 return err
281 }
282+ if owner.Pubkey != userPubkey {
283+ return ErrNotPrOwner
284+ }
285
286+ tx, err := cmd.Backend.DB.Beginx()
287+ if err != nil {
288+ return err
289+ }
290 defer func() {
291 _ = tx.Rollback()
292 }()
293@@ -322,14 +275,8 @@ func (cmd PrCmd) UpdatePatchRequestStatus(prID int64, userID int64, status Statu
294 return err
295 }
296
297- pr, err := cmd.GetPatchRequestByID(prID)
298- if err != nil {
299- return err
300- }
301-
302 err = cmd.CreateEventLog(tx, EventLog{
303- UserID: userID,
304- RepoID: sql.NullInt64{Int64: pr.RepoID, Valid: true},
305+ UserID: pr.UserID,
306 PatchRequestID: sql.NullInt64{Int64: prID, Valid: true},
307 Event: "pr_status_changed",
308 Data: EventData{
309@@ -341,19 +288,38 @@ func (cmd PrCmd) UpdatePatchRequestStatus(prID int64, userID int64, status Statu
310 return err
311 }
312
313- return tx.Commit()
314+ err = tx.Commit()
315+ if err != nil {
316+ return err
317+ }
318+
319+ return cmd.updateLastActivity(prID)
320 }
321
322-func (cmd PrCmd) UpdatePatchRequestName(prID int64, userID int64, name string) error {
323+// UpdatePatchRequestName changes the PR title. Only the PR creator (by pubkey) can do this.
324+func (cmd PrCmd) UpdatePatchRequestName(prID int64, userPubkey string, name string) error {
325 if name == "" {
326- return fmt.Errorf("must provide name or text in order to update patch request")
327+ return fmt.Errorf("must provide name in order to update patch request")
328 }
329
330- tx, err := cmd.Backend.DB.Beginx()
331+ pr, err := cmd.GetPatchRequestByID(prID)
332 if err != nil {
333 return err
334 }
335
336+ // Verify the requester is the PR creator
337+ owner, err := cmd.GetUserByID(pr.UserID)
338+ if err != nil {
339+ return err
340+ }
341+ if owner.Pubkey != userPubkey {
342+ return ErrNotPrOwner
343+ }
344+
345+ tx, err := cmd.Backend.DB.Beginx()
346+ if err != nil {
347+ return err
348+ }
349 defer func() {
350 _ = tx.Rollback()
351 }()
352@@ -367,14 +333,8 @@ func (cmd PrCmd) UpdatePatchRequestName(prID int64, userID int64, name string) e
353 return err
354 }
355
356- pr, err := cmd.GetPatchRequestByID(prID)
357- if err != nil {
358- return err
359- }
360-
361 err = cmd.CreateEventLog(tx, EventLog{
362- UserID: userID,
363- RepoID: sql.NullInt64{Int64: pr.RepoID, Valid: true},
364+ UserID: pr.UserID,
365 PatchRequestID: sql.NullInt64{Int64: prID, Valid: true},
366 Event: "pr_name_changed",
367 Data: EventData{
368@@ -385,31 +345,18 @@ func (cmd PrCmd) UpdatePatchRequestName(prID int64, userID int64, name string) e
369 return err
370 }
371
372- return tx.Commit()
373+ err = tx.Commit()
374+ if err != nil {
375+ return err
376+ }
377+
378+ return cmd.updateLastActivity(prID)
379 }
380
381 func (cmd PrCmd) CreateEventLog(tx *sqlx.Tx, eventLog EventLog) error {
382- if eventLog.RepoID.Valid && eventLog.PatchRequestID.Valid {
383- var pr PatchRequest
384- err := tx.Get(
385- &pr,
386- "SELECT repo_id FROM patch_requests WHERE id=?",
387- eventLog.PatchRequestID,
388- )
389- if err != nil {
390- cmd.Backend.Logger.Error(
391- "could not find pr when creating eventLog",
392- "err", err,
393- )
394- return nil
395- }
396- eventLog.RepoID = sql.NullInt64{Int64: pr.RepoID, Valid: true}
397- }
398-
399 _, err := tx.Exec(
400- "INSERT INTO event_logs (user_id, repo_id, patch_request_id, patchset_id, event, data) VALUES (?, ?, ?, ?, ?, ?)",
401+ "INSERT INTO event_logs (user_id, patch_request_id, patchset_id, event, data) VALUES (?, ?, ?, ?, ?)",
402 eventLog.UserID,
403- eventLog.RepoID,
404 eventLog.PatchRequestID.Int64,
405 eventLog.PatchsetID.Int64,
406 eventLog.Event,
407@@ -452,12 +399,13 @@ func (cmd PrCmd) createPatch(tx *sqlx.Tx, patch *Patch) (int64, error) {
408 return 0, err
409 }
410 if patchID == 0 {
411- return 0, fmt.Errorf("could not create patch request")
412+ return 0, fmt.Errorf("could not create patch")
413 }
414 return patchID, err
415 }
416
417-func (cmd PrCmd) SubmitPatchRequest(repoID int64, userID int64, patchset io.Reader) (*PatchRequest, error) {
418+// SubmitPatchRequest creates a new patch request with draft status.
419+func (cmd PrCmd) SubmitPatchRequest(userID int64, userPubkey string, repoName string, patchset io.Reader) (*PatchRequest, error) {
420 tx, err := cmd.Backend.DB.Beginx()
421 if err != nil {
422 return nil, err
423@@ -473,7 +421,7 @@ func (cmd PrCmd) SubmitPatchRequest(repoID int64, userID int64, patchset io.Read
424 }
425
426 if len(patches) == 0 {
427- return nil, fmt.Errorf("after parsing patchset we did't find any patches, did you send us an empty patchset?")
428+ return nil, fmt.Errorf("after parsing patchset we didn't find any patches, did you send us an empty patchset?")
429 }
430
431 prName := ""
432@@ -483,15 +431,17 @@ func (cmd PrCmd) SubmitPatchRequest(repoID int64, userID int64, patchset io.Read
433 prText = patches[0].Body
434 }
435
436+ now := time.Now()
437 var prID int64
438 row := tx.QueryRow(
439- "INSERT INTO patch_requests (user_id, repo_id, name, text, status, updated_at) VALUES(?, ?, ?, ?, ?, ?) RETURNING id",
440+ "INSERT INTO patch_requests (user_id, repo_name, name, text, status, updated_at, last_activity) VALUES(?, ?, ?, ?, ?, ?, ?) RETURNING id",
441 userID,
442- repoID,
443+ repoName,
444 prName,
445 prText,
446- "open",
447- time.Now(),
448+ StatusDraft,
449+ now,
450+ now,
451 )
452 err = row.Scan(&prID)
453 if err != nil {
454@@ -526,7 +476,78 @@ func (cmd PrCmd) SubmitPatchRequest(repoID int64, userID int64, patchset io.Read
455
456 err = cmd.CreateEventLog(tx, EventLog{
457 UserID: userID,
458- RepoID: sql.NullInt64{Int64: repoID, Valid: true},
459+ PatchRequestID: sql.NullInt64{Int64: prID, Valid: true},
460+ PatchsetID: sql.NullInt64{Int64: patchsetID, Valid: true},
461+ Event: "pr_created",
462+ })
463+ if err != nil {
464+ return nil, err
465+ }
466+
467+ err = tx.Commit()
468+ if err != nil {
469+ return nil, err
470+ }
471+
472+ var pr PatchRequest
473+ err = cmd.Backend.DB.Get(&pr, "SELECT * FROM patch_requests WHERE id=?", prID)
474+ return &pr, err
475+}
476+
477+// SubmitIssue creates a new patch request as an issue (text-only, no patches, starts open).
478+// The title is the issue subject, body is the full description.
479+func (cmd PrCmd) SubmitIssue(userID int64, userPubkey string, repoName, title, body string) (*PatchRequest, error) {
480+ if title == "" {
481+ return nil, fmt.Errorf("must provide a title for the issue")
482+ }
483+
484+ tx, err := cmd.Backend.DB.Beginx()
485+ if err != nil {
486+ return nil, err
487+ }
488+
489+ defer func() {
490+ _ = tx.Rollback()
491+ }()
492+
493+ now := time.Now()
494+ var prID int64
495+ row := tx.QueryRow(
496+ "INSERT INTO patch_requests (user_id, repo_name, name, text, status, updated_at, last_activity) VALUES(?, ?, ?, ?, ?, ?, ?) RETURNING id",
497+ userID,
498+ repoName,
499+ title,
500+ body,
501+ StatusOpen,
502+ now,
503+ now,
504+ )
505+ err = row.Scan(&prID)
506+ if err != nil {
507+ return nil, err
508+ }
509+ if prID == 0 {
510+ return nil, fmt.Errorf("could not create issue")
511+ }
512+
513+ // Create an empty initial patchset so the PR has a patchset for the timeline.
514+ // Patches can be added later with `pr add`.
515+ var patchsetID int64
516+ row = tx.QueryRow(
517+ "INSERT INTO patchsets (user_id, patch_request_id) VALUES(?, ?) RETURNING id",
518+ userID,
519+ prID,
520+ )
521+ err = row.Scan(&patchsetID)
522+ if err != nil {
523+ return nil, err
524+ }
525+ if patchsetID == 0 {
526+ return nil, fmt.Errorf("could not create patchset")
527+ }
528+
529+ err = cmd.CreateEventLog(tx, EventLog{
530+ UserID: userID,
531 PatchRequestID: sql.NullInt64{Int64: prID, Valid: true},
532 PatchsetID: sql.NullInt64{Int64: patchsetID, Valid: true},
533 Event: "pr_created",
534@@ -561,13 +582,11 @@ func (cmd PrCmd) SubmitPatchset(prID int64, userID int64, op PatchsetOp, patchse
535 return fin, err
536 }
537
538- isReview := op == OpReview || op == OpAccept || op == OpClose
539 var patchsetID int64
540 row := tx.QueryRow(
541- "INSERT INTO patchsets (user_id, patch_request_id, review) VALUES(?, ?, ?) RETURNING id",
542+ "INSERT INTO patchsets (user_id, patch_request_id) VALUES(?, ?) RETURNING id",
543 userID,
544 prID,
545- isReview,
546 )
547 err = row.Scan(&patchsetID)
548 if err != nil {
549@@ -592,22 +611,11 @@ func (cmd PrCmd) SubmitPatchset(prID int64, userID int64, op PatchsetOp, patchse
550 }
551
552 if len(fin) > 0 {
553- event := "pr_patchset_added"
554- if op == OpReview {
555- event = "pr_reviewed"
556- }
557-
558- pr, err := cmd.GetPatchRequestByID(prID)
559- if err != nil {
560- return fin, err
561- }
562-
563 err = cmd.CreateEventLog(tx, EventLog{
564 UserID: userID,
565- RepoID: sql.NullInt64{Int64: pr.RepoID, Valid: true},
566 PatchRequestID: sql.NullInt64{Int64: prID, Valid: true},
567 PatchsetID: sql.NullInt64{Int64: patchsetID, Valid: true},
568- Event: event,
569+ Event: "pr_patchset_added",
570 })
571 if err != nil {
572 return fin, err
573@@ -619,7 +627,12 @@ func (cmd PrCmd) SubmitPatchset(prID int64, userID int64, op PatchsetOp, patchse
574 return fin, err
575 }
576
577- return fin, err
578+ // Update last_activity
579+ if err := cmd.updateLastActivity(prID); err != nil {
580+ cmd.Backend.Logger.Error("failed to update last_activity", "err", err, "prID", prID)
581+ }
582+
583+ return fin, nil
584 }
585
586 func (cmd PrCmd) DeletePatchsetByID(userID int64, prID int64, patchsetID int64) error {
587@@ -633,20 +646,15 @@ func (cmd PrCmd) DeletePatchsetByID(userID int64, prID int64, patchsetID int64)
588 }()
589
590 _, err = tx.Exec(
591- "DELETE FROM patchsets WHERE id=?", patchsetID,
592+ "DELETE FROM patchsets WHERE id=?",
593+ patchsetID,
594 )
595 if err != nil {
596 return err
597 }
598
599- pr, err := cmd.GetPatchRequestByID(prID)
600- if err != nil {
601- return err
602- }
603-
604 err = cmd.CreateEventLog(tx, EventLog{
605 UserID: userID,
606- RepoID: sql.NullInt64{Int64: pr.RepoID, Valid: true},
607 PatchRequestID: sql.NullInt64{Int64: prID, Valid: true},
608 PatchsetID: sql.NullInt64{Int64: patchsetID, Valid: true},
609 Event: "pr_patchset_deleted",
610@@ -655,7 +663,12 @@ func (cmd PrCmd) DeletePatchsetByID(userID int64, prID int64, patchsetID int64)
611 return err
612 }
613
614- return tx.Commit()
615+ err = tx.Commit()
616+ if err != nil {
617+ return err
618+ }
619+
620+ return cmd.updateLastActivity(prID)
621 }
622
623 func (cmd PrCmd) GetEventLogs() ([]*EventLog, error) {
624@@ -667,21 +680,6 @@ func (cmd PrCmd) GetEventLogs() ([]*EventLog, error) {
625 return eventLogs, err
626 }
627
628-func (cmd PrCmd) GetEventLogsByRepoName(user *User, repoName string) ([]*EventLog, error) {
629- repo, err := cmd.GetRepoByName(user, repoName)
630- if err != nil {
631- return nil, err
632- }
633-
634- eventLogs := []*EventLog{}
635- err = cmd.Backend.DB.Select(
636- &eventLogs,
637- "SELECT * FROM event_logs WHERE repo_id=? ORDER BY created_at DESC",
638- repo.ID,
639- )
640- return eventLogs, err
641-}
642-
643 func (cmd PrCmd) GetEventLogsByPrID(prID int64) ([]*EventLog, error) {
644 eventLogs := []*EventLog{}
645 err := cmd.Backend.DB.Select(
+221,
-319
1@@ -1,4 +1,4 @@
2-package git
3+package patchbin
4
5 import (
6 "fmt"
7@@ -8,7 +8,6 @@ import (
8
9 "github.com/bluekeyes/go-gitdiff/gitdiff"
10 ha "github.com/oddg/hungarian-algorithm"
11- "github.com/sergi/go-diff/diffmatchpatch"
12 )
13
14 var (
15@@ -16,283 +15,22 @@ var (
16 RANGE_DIFF_CREATION_FACTOR_DEFAULT = 60
17 )
18
19-type PatchRange struct {
20- *Patch
21- Matching int
22- Diff string
23- DiffSize int
24- Shown bool
25-}
26-
27-func NewPatchRange(patch *Patch) *PatchRange {
28- diff := patch.CalcDiff()
29- return &PatchRange{
30- Patch: patch,
31- Matching: -1,
32- Diff: diff,
33- DiffSize: len(diff),
34- Shown: false,
35- }
36-}
37-
38+// RangeDiffOutput represents a single commit comparison entry in the range diff.
39 type RangeDiffOutput struct {
40 Header *RangeDiffHeader
41 Order int
42 Files []*RangeDiffFile
43- Type string
44-}
45-
46-func output(a []*PatchRange, b []*PatchRange) []*RangeDiffOutput {
47- outputs := []*RangeDiffOutput{}
48- for i, patchA := range a {
49- if patchA.Matching == -1 {
50- hdr := NewRangeDiffHeader(patchA, nil, i+1, -1)
51- files := outputRemovedPatch(patchA)
52- outputs = append(
53- outputs,
54- &RangeDiffOutput{
55- Header: hdr,
56- Type: "rm",
57- Order: i + 1,
58- Files: files,
59- },
60- )
61- }
62- }
63-
64- for j, patchB := range b {
65- if patchB.Matching == -1 {
66- hdr := NewRangeDiffHeader(nil, patchB, -1, j+1)
67- files := outputAddedPatch(patchB)
68- outputs = append(
69- outputs,
70- &RangeDiffOutput{
71- Header: hdr,
72- Type: "add",
73- Order: j + 1,
74- Files: files,
75- },
76- )
77- continue
78- }
79- patchA := a[patchB.Matching]
80- if patchB.ContentSha == patchA.ContentSha {
81- hdr := NewRangeDiffHeader(patchA, patchB, patchB.Matching+1, patchA.Matching+1)
82- outputs = append(
83- outputs,
84- &RangeDiffOutput{
85- Header: hdr,
86- Type: "equal",
87- Order: patchA.Matching + 1,
88- },
89- )
90- } else {
91- hdr := NewRangeDiffHeader(patchA, patchB, patchB.Matching+1, patchA.Matching+1)
92- diff := outputDiff(patchA, patchB)
93- outputs = append(
94- outputs,
95- &RangeDiffOutput{
96- Order: patchA.Matching + 1,
97- Header: hdr,
98- Files: diff,
99- Type: "diff",
100- },
101- )
102- }
103- }
104- sort.Slice(outputs, func(i, j int) bool {
105- return outputs[i].Order < outputs[j].Order
106- })
107- return outputs
108-}
109-
110-type RangeDiffDiff struct {
111- OuterType string
112- InnerType string
113- Text string
114-}
115-
116-func toRangeDiffDiff(diff []diffmatchpatch.Diff) []RangeDiffDiff {
117- result := []RangeDiffDiff{}
118-
119- for _, line := range diff {
120- outerDiffType := line.Type
121-
122- fmtLine := strings.Split(line.Text, "\n")
123- for idx, ln := range fmtLine {
124- text := ln
125- if idx < len(fmtLine)-1 {
126- text = ln + "\n"
127- }
128-
129- // Determine inner type based on line prefix (+/-/space)
130- inner := "equal"
131- if strings.HasPrefix(text, "+") {
132- inner = "insert"
133- } else if strings.HasPrefix(text, "-") {
134- inner = "delete"
135- }
136-
137- // Determine outer type based on diff result
138- outer := "equal"
139- switch outerDiffType {
140- case diffmatchpatch.DiffInsert:
141- outer = "insert"
142- case diffmatchpatch.DiffDelete:
143- outer = "delete"
144- }
145-
146- st := RangeDiffDiff{
147- Text: text,
148- OuterType: outer,
149- InnerType: inner,
150- }
151-
152- result = append(result, st)
153- }
154- }
155-
156- return result
157-}
158-
159-func DoDiff(src, dst string) []RangeDiffDiff {
160- dmp := diffmatchpatch.New()
161- wSrc, wDst, warray := dmp.DiffLinesToChars(src, dst)
162- diffs := dmp.DiffMain(wSrc, wDst, false)
163- diffs = dmp.DiffCharsToLines(diffs, warray)
164- return toRangeDiffDiff(diffs)
165-}
166-
167-// extractChangedLines extracts only added and deleted lines from a file's fragments,
168-// ignoring context lines. This is used for comparing patches where context lines
169-// may differ due to rebasing but the actual changes are the same.
170-func extractChangedLines(file *gitdiff.File) string {
171- var result strings.Builder
172- for _, frag := range file.TextFragments {
173- for _, line := range frag.Lines {
174- if line.Op == gitdiff.OpAdd || line.Op == gitdiff.OpDelete {
175- result.WriteString(line.String())
176- }
177- }
178- }
179- return result.String()
180-}
181-
182-// extractAllLines extracts all lines (including context) from a file's fragments.
183-// This is used for displaying the full diff with context.
184-func extractAllLines(file *gitdiff.File) string {
185- var result strings.Builder
186- for _, frag := range file.TextFragments {
187- for _, line := range frag.Lines {
188- result.WriteString(line.String())
189- }
190- }
191- return result.String()
192+ Type string // "rm", "add", "equal", "changed"
193 }
194
195+// RangeDiffFile represents a file-level change between two matched commits.
196 type RangeDiffFile struct {
197- OldFile *gitdiff.File
198- NewFile *gitdiff.File
199- Diff []RangeDiffDiff
200+ OldName string
201+ NewName string
202+ Type string // "added", "removed", "changed"
203 }
204
205-func outputDiff(patchA, patchB *PatchRange) []*RangeDiffFile {
206- diffs := []*RangeDiffFile{}
207-
208- for _, fileA := range patchA.Files {
209- found := false
210- for _, fileB := range patchB.Files {
211- if fileA.NewName == fileB.NewName {
212- found = true
213- // this means both files have been deleted so we should skip
214- if fileA.NewName == "" {
215- continue
216- }
217- // Compare only +/- lines to determine if there's a meaningful diff
218- changedA := extractChangedLines(fileA)
219- changedB := extractChangedLines(fileB)
220- if changedA == changedB {
221- // No difference in actual changes, skip this file
222- continue
223- }
224- // Use all lines (with context) for display
225- strA := extractAllLines(fileA)
226- strB := extractAllLines(fileB)
227- curDiff := DoDiff(strA, strB)
228- fp := &RangeDiffFile{
229- OldFile: fileA,
230- NewFile: fileB,
231- Diff: curDiff,
232- }
233- diffs = append(diffs, fp)
234- }
235- }
236-
237- // find files in patchA but not in patchB
238- if !found {
239- strA := extractAllLines(fileA)
240- fp := &RangeDiffFile{
241- OldFile: fileA,
242- NewFile: nil,
243- Diff: DoDiff(strA, ""),
244- }
245- diffs = append(diffs, fp)
246- }
247- }
248-
249- // find files in patchB not in patchA
250- for _, fileB := range patchB.Files {
251- found := false
252- for _, fileA := range patchA.Files {
253- if fileA.NewName == fileB.NewName {
254- found = true
255- break
256- }
257- }
258-
259- if !found {
260- strB := extractAllLines(fileB)
261- fp := &RangeDiffFile{
262- OldFile: nil,
263- NewFile: fileB,
264- Diff: DoDiff("", strB),
265- }
266- diffs = append(diffs, fp)
267- }
268- }
269-
270- return diffs
271-}
272-
273-func outputAddedPatch(patch *PatchRange) []*RangeDiffFile {
274- diffs := []*RangeDiffFile{}
275- for _, file := range patch.Files {
276- strB := extractAllLines(file)
277- fp := &RangeDiffFile{
278- OldFile: nil,
279- NewFile: file,
280- Diff: DoDiff("", strB),
281- }
282- diffs = append(diffs, fp)
283- }
284- return diffs
285-}
286-
287-func outputRemovedPatch(patch *PatchRange) []*RangeDiffFile {
288- diffs := []*RangeDiffFile{}
289- for _, file := range patch.Files {
290- strA := extractAllLines(file)
291- fp := &RangeDiffFile{
292- OldFile: file,
293- NewFile: nil,
294- Diff: DoDiff(strA, ""),
295- }
296- diffs = append(diffs, fp)
297- }
298- return diffs
299-}
300-
301-// RangeDiffHeader is a header combining old and new change pairs.
302+// RangeDiffHeader is a header combining old and new commit pairs.
303 type RangeDiffHeader struct {
304 OldIdx int
305 OldSha string
306@@ -313,7 +51,8 @@ type RangeDiffHeader struct {
307 BodyChanged bool
308 }
309
310-func NewRangeDiffHeader(a *PatchRange, b *PatchRange, aIndex, bIndex int) *RangeDiffHeader {
311+// NewRangeDiffHeader creates a header from two patch ranges.
312+func NewRangeDiffHeader(a, b *Patch, aIndex, bIndex int) *RangeDiffHeader {
313 hdr := &RangeDiffHeader{}
314 if a == nil {
315 hdr.NewIdx = bIndex
316@@ -380,60 +119,222 @@ func (hdr *RangeDiffHeader) String() string {
317 )
318 }
319 return fmt.Sprintf(
320- "%d: %s ! %d: %s %s",
321+ "%d: %s ! %d: %s %s\n",
322 hdr.OldIdx, truncateSha(hdr.OldSha),
323 hdr.NewIdx, truncateSha(hdr.NewSha),
324 hdr.Title,
325 )
326 }
327
328+// RangeDiff compares two patchsets and returns commit-level changes.
329 func RangeDiff(a []*Patch, b []*Patch) []*RangeDiffOutput {
330- aPatches := []*PatchRange{}
331- for _, patch := range a {
332- aPatches = append(aPatches, NewPatchRange(patch))
333+ aPatches := make([]*patchEntry, len(a))
334+ for i, p := range a {
335+ aPatches[i] = &patchEntry{Patch: p, Matching: -1, Size: patchSize(p)}
336 }
337- bPatches := []*PatchRange{}
338- for _, patch := range b {
339- bPatches = append(bPatches, NewPatchRange(patch))
340+ bPatches := make([]*patchEntry, len(b))
341+ for i, p := range b {
342+ bPatches[i] = &patchEntry{Patch: p, Matching: -1, Size: patchSize(p)}
343 }
344+
345 findExactMatches(aPatches, bPatches)
346 getCorrespondences(aPatches, bPatches, RANGE_DIFF_CREATION_FACTOR_DEFAULT)
347- return output(aPatches, bPatches)
348+ return buildOutput(aPatches, bPatches)
349+}
350+
351+// patchEntry wraps a Patch with matching state for the algorithm.
352+type patchEntry struct {
353+ *Patch
354+ Matching int
355+ Size int
356+}
357+
358+// patchSize returns a rough size metric for a patch (used for matching cost).
359+func patchSize(p *Patch) int {
360+ return len(p.RawText)
361+}
362+
363+// buildOutput constructs the final range diff output from matched patches.
364+func buildOutput(a []*patchEntry, b []*patchEntry) []*RangeDiffOutput {
365+ outputs := []*RangeDiffOutput{}
366+
367+ // Removed commits (in A but not matched in B)
368+ for i, patchA := range a {
369+ if patchA.Matching == -1 {
370+ hdr := NewRangeDiffHeader(patchA.Patch, nil, i+1, -1)
371+ files := filesRemoved(patchA.Patch)
372+ outputs = append(outputs, &RangeDiffOutput{
373+ Header: hdr,
374+ Type: "rm",
375+ Order: i + 1,
376+ Files: files,
377+ })
378+ }
379+ }
380+
381+ // Added or changed commits (from B side)
382+ for j, entryB := range b {
383+ if entryB.Matching == -1 {
384+ // Added commit (in B but not matched in A)
385+ hdr := NewRangeDiffHeader(nil, entryB.Patch, -1, j+1)
386+ files := filesAdded(entryB.Patch)
387+ outputs = append(outputs, &RangeDiffOutput{
388+ Header: hdr,
389+ Type: "add",
390+ Order: j + 1,
391+ Files: files,
392+ })
393+ continue
394+ }
395+
396+ entryA := a[entryB.Matching]
397+ if entryB.ContentSha == entryA.ContentSha {
398+ // Equal commits
399+ hdr := NewRangeDiffHeader(entryA.Patch, entryB.Patch, entryB.Matching+1, entryA.Matching+1)
400+ outputs = append(outputs, &RangeDiffOutput{
401+ Header: hdr,
402+ Type: "equal",
403+ Order: entryA.Matching + 1,
404+ })
405+ } else {
406+ // Changed commits
407+ hdr := NewRangeDiffHeader(entryA.Patch, entryB.Patch, entryB.Matching+1, entryA.Matching+1)
408+ files := filesChanged(entryA.Patch, entryB.Patch)
409+ outputs = append(outputs, &RangeDiffOutput{
410+ Order: entryA.Matching + 1,
411+ Header: hdr,
412+ Files: files,
413+ Type: "changed",
414+ })
415+ }
416+ }
417+
418+ sort.Slice(outputs, func(i, j int) bool {
419+ return outputs[i].Order < outputs[j].Order
420+ })
421+ return outputs
422+}
423+
424+// fileContent extracts the diff content from a file for comparison.
425+func fileContent(f *gitdiff.File) string {
426+ var buf strings.Builder
427+ for _, frag := range f.TextFragments {
428+ for _, line := range frag.Lines {
429+ buf.WriteString(line.String())
430+ }
431+ }
432+ return buf.String()
433+}
434+
435+// filesAdded returns a list of files added in the given patch.
436+func filesAdded(p *Patch) []*RangeDiffFile {
437+ files := []*RangeDiffFile{}
438+ for _, f := range p.Files {
439+ files = append(files, &RangeDiffFile{
440+ NewName: f.NewName,
441+ OldName: f.OldName,
442+ Type: "added",
443+ })
444+ }
445+ return files
446+}
447+
448+// filesRemoved returns a list of files removed from the given patch.
449+func filesRemoved(p *Patch) []*RangeDiffFile {
450+ files := []*RangeDiffFile{}
451+ for _, f := range p.Files {
452+ files = append(files, &RangeDiffFile{
453+ NewName: f.NewName,
454+ OldName: f.OldName,
455+ Type: "removed",
456+ })
457+ }
458+ return files
459+}
460+
461+// filesChanged returns a list of files that were added, removed, or changed
462+// between two matched patches.
463+func filesChanged(oldPatch, newPatch *Patch) []*RangeDiffFile {
464+ files := []*RangeDiffFile{}
465+
466+ // Build lookup maps by new file name
467+ oldFiles := map[string]*gitdiff.File{}
468+ for _, f := range oldPatch.Files {
469+ oldFiles[f.NewName] = f
470+ }
471+ newFiles := map[string]*gitdiff.File{}
472+ for _, f := range newPatch.Files {
473+ newFiles[f.NewName] = f
474+ }
475+
476+ // Find changed and removed files
477+ for name, oldFile := range oldFiles {
478+ newFile, ok := newFiles[name]
479+ if !ok {
480+ // File removed
481+ files = append(files, &RangeDiffFile{
482+ OldName: oldFile.OldName,
483+ Type: "removed",
484+ })
485+ } else if fileContent(oldFile) != fileContent(newFile) {
486+ // File changed
487+ files = append(files, &RangeDiffFile{
488+ OldName: oldFile.OldName,
489+ NewName: newFile.NewName,
490+ Type: "changed",
491+ })
492+ }
493+ }
494+
495+ // Find added files
496+ for name, newFile := range newFiles {
497+ if _, ok := oldFiles[name]; !ok {
498+ files = append(files, &RangeDiffFile{
499+ NewName: newFile.NewName,
500+ OldName: newFile.OldName,
501+ Type: "added",
502+ })
503+ }
504+ }
505+
506+ // Sort for deterministic output
507+ sort.Slice(files, func(i, j int) bool {
508+ return files[i].NewName < files[j].NewName
509+ })
510+ return files
511 }
512
513+// RangeDiffToStr returns a simple string representation of the range diff.
514 func RangeDiffToStr(diffs []*RangeDiffOutput) string {
515- output := ""
516+ out := ""
517 for _, diff := range diffs {
518- output += diff.Header.String()
519+ out += diff.Header.String()
520 for _, f := range diff.Files {
521- fileName := ""
522- if f.NewFile != nil {
523- fileName = f.NewFile.NewName
524- } else if f.OldFile != nil {
525- fileName = f.OldFile.NewName
526+ name := f.NewName
527+ if name == "" {
528+ name = f.OldName
529 }
530- output += fmt.Sprintf("\n@@ %s\n", fileName)
531- for _, d := range f.Diff {
532- switch d.OuterType {
533- case "equal":
534- output += d.Text
535- case "insert":
536- output += d.Text
537- case "delete":
538- output += d.Text
539- }
540+ switch f.Type {
541+ case "added":
542+ out += " + " + name + "\n"
543+ case "removed":
544+ out += " - " + name + "\n"
545+ case "changed":
546+ out += " ~ " + name + "\n"
547 }
548 }
549 }
550- return output
551+ return out
552 }
553
554-func findExactMatches(a []*PatchRange, b []*PatchRange) {
555- for i, patchA := range a {
556- for j, patchB := range b {
557- if patchA.ContentSha == patchB.ContentSha {
558- patchA.Matching = j
559- patchB.Matching = i
560+// --- Matching algorithm (unchanged) ---
561+
562+func findExactMatches(a, b []*patchEntry) {
563+ for i, entryA := range a {
564+ for j, entryB := range b {
565+ if entryA.ContentSha == entryB.ContentSha {
566+ a[i].Matching = j
567+ b[j].Matching = i
568 }
569 }
570 }
571@@ -447,23 +348,17 @@ func createMatrix(rows, cols int) [][]int {
572 return mat
573 }
574
575-func diffsize(a *PatchRange, b *PatchRange) int {
576- dmp := diffmatchpatch.New()
577- diffs := dmp.DiffMain(a.Diff, b.Diff, false)
578- return len(diffs)
579-}
580-
581-func getCorrespondences(a []*PatchRange, b []*PatchRange, creationFactor int) {
582+func getCorrespondences(a, b []*patchEntry, creationFactor int) {
583 n := len(a) + len(b)
584 cost := createMatrix(n, n)
585
586- for i, patchA := range a {
587- var c int
588- for j, patchB := range b {
589- if patchA.Matching == j {
590+ for i, entryA := range a {
591+ for j, entryB := range b {
592+ var c int
593+ if entryA.Matching == j {
594 c = 0
595- } else if patchA.Matching == -1 && patchB.Matching == -1 {
596- c = diffsize(patchA, patchB)
597+ } else if entryA.Matching == -1 && entryB.Matching == -1 {
598+ c = absDiff(entryA.Size, entryB.Size)
599 } else {
600 c = COST_MAX
601 }
602@@ -471,9 +366,9 @@ func getCorrespondences(a []*PatchRange, b []*PatchRange, creationFactor int) {
603 }
604 }
605
606- for j, patchB := range b {
607- creationCost := (patchB.DiffSize * creationFactor) / 100
608- if patchB.Matching >= 0 {
609+ for j, entryB := range b {
610+ creationCost := (entryB.Size * creationFactor) / 100
611+ if entryB.Matching >= 0 {
612 creationCost = math.MaxInt32
613 }
614 for i := len(a); i < n; i++ {
615@@ -496,3 +391,10 @@ func getCorrespondences(a []*PatchRange, b []*PatchRange, creationFactor int) {
616 }
617 }
618 }
619+
620+func absDiff(a, b int) int {
621+ if a > b {
622+ return a - b
623+ }
624+ return b - a
625+}
+16,
-13
1@@ -1,11 +1,11 @@
2-package git
3+package patchbin
4
5 import (
6 "fmt"
7 "strings"
8 "testing"
9
10- "github.com/picosh/git-pr/fixtures"
11+ "github.com/picosh/patchbin/fixtures"
12 )
13
14 func bail(err error) {
15@@ -80,8 +80,8 @@ func TestRangeDiffRemovedCommit(t *testing.T) {
16 if !strings.Contains(actual, "2: 22dde12 = 1: 7dbb94c docs: readme") {
17 t.Fatal("expected equal commit header not found")
18 }
19- if !strings.Contains(actual, "requirements.txt") {
20- t.Fatal("expected file diff for removed commit")
21+ if !strings.Contains(actual, "- requirements.txt") {
22+ t.Fatal("expected removed file for removed commit")
23 }
24 }
25
26@@ -136,14 +136,14 @@ func TestRangeDiffAddedCommit(t *testing.T) {
27 */
28 func TestRangeDiffChangedCommit(t *testing.T) {
29 actual := cmp("a_b_reorder.patch", "a_c_changed_commit.patch")
30- // os.WriteFile("fixtures/expected_commit_changed.txt", []byte(actual), 0644)
31- fp, err := fixtures.Fixtures.ReadFile("expected_commit_changed.txt")
32- if err != nil {
33- t.Fatal("file not found")
34+ if !strings.Contains(actual, "1: 33c682a = 1: 33c682a chore: add torch and create random tensor") {
35+ t.Fatal("expected first commit to be equal")
36 }
37- expected := string(fp)
38- if strings.TrimSpace(expected) != strings.TrimSpace(actual) {
39- t.Fatal(fail(expected, actual))
40+ if !strings.Contains(actual, "2: 22dde12 ! 2: dce20e7 docs: readme") {
41+ t.Fatal("expected second commit to show changed marker")
42+ }
43+ if !strings.Contains(actual, "~ README.md") {
44+ t.Fatal("expected changed file README.md")
45 }
46 }
47
48@@ -292,8 +292,11 @@ func TestRangeDiffFileAddedThenRemoved(t *testing.T) {
49 if !strings.Contains(actual, "temp.txt") {
50 t.Fatal("expected temp.txt in output")
51 }
52- if !strings.Contains(actual, "-: ------- > 3: ccddee1") {
53- t.Fatal("expected third commit to be added")
54+ if !strings.Contains(actual, "-: ------- >") {
55+ t.Fatal("expected added commit marker")
56+ }
57+ if !strings.Contains(actual, "ccddee1") {
58+ t.Fatal("expected commit ccddee1 in output")
59 }
60 }
61
+52,
-0
1@@ -0,0 +1,52 @@
2+package patchbin
3+
4+import (
5+ "fmt"
6+ "sync"
7+ "time"
8+)
9+
10+// RateLimiter enforces a single global cap on submissions per interval,
11+// shared across all users (not keyed by pubkey or IP).
12+type RateLimiter struct {
13+ mu sync.Mutex
14+ max int
15+ interval time.Duration
16+ count int
17+ resetAt time.Time
18+}
19+
20+func NewRateLimiter(max int, interval time.Duration) *RateLimiter {
21+ return &RateLimiter{
22+ max: max,
23+ interval: interval,
24+ }
25+}
26+
27+// Allow reports whether a new submission is permitted under the current
28+// window, incrementing the window's counter if so.
29+func (r *RateLimiter) Allow() bool {
30+ r.mu.Lock()
31+ defer r.mu.Unlock()
32+
33+ now := time.Now()
34+ if now.After(r.resetAt) {
35+ r.count = 0
36+ r.resetAt = now.Add(r.interval)
37+ }
38+
39+ if r.count >= r.max {
40+ return false
41+ }
42+
43+ r.count++
44+ return true
45+}
46+
47+func (r *RateLimiter) Error() error {
48+ return fmt.Errorf(
49+ "rate limit exceeded: max %d submissions per %s, try again later",
50+ r.max,
51+ r.interval,
52+ )
53+}
+583,
-0
1@@ -0,0 +1,583 @@
2+package patchbin
3+
4+import (
5+ "bytes"
6+ "context"
7+ "crypto/sha256"
8+ "encoding/hex"
9+ "fmt"
10+ "path/filepath"
11+ "regexp"
12+ "strings"
13+
14+ "github.com/bluekeyes/go-gitdiff/gitdiff"
15+ sitter "github.com/smacker/go-tree-sitter"
16+ "github.com/smacker/go-tree-sitter/golang"
17+ "github.com/smacker/go-tree-sitter/javascript"
18+ "github.com/smacker/go-tree-sitter/python"
19+ "github.com/smacker/go-tree-sitter/rust"
20+ "github.com/smacker/go-tree-sitter/typescript/tsx"
21+ "github.com/smacker/go-tree-sitter/typescript/typescript"
22+)
23+
24+// SemanticChangeKind describes how an entity changed between the old and
25+// new side of a hunk.
26+type SemanticChangeKind string
27+
28+const (
29+ SemanticAdded SemanticChangeKind = "added"
30+ SemanticRemoved SemanticChangeKind = "removed"
31+ SemanticModified SemanticChangeKind = "modified"
32+ SemanticSignatureChanged SemanticChangeKind = "signature_changed"
33+ SemanticRenamed SemanticChangeKind = "renamed"
34+)
35+
36+// semanticEntity is a named, queryable unit of code (function, type, etc)
37+// extracted from one side (old or new) of a single hunk.
38+type semanticEntity struct {
39+ Kind string
40+ Name string
41+ Signature string
42+ BodyHash string
43+}
44+
45+// SemanticChange is a single reviewer-facing summary line describing what
46+// changed about one entity in one hunk.
47+type SemanticChange struct {
48+ Kind SemanticChangeKind
49+ EntityKind string
50+ Name string
51+ OldSig string
52+ NewSig string
53+ HunkIndex int
54+ HunkAnchor string
55+}
56+
57+// languageSpec binds a tree-sitter grammar and entity-extraction query to a
58+// set of file extensions. Adding a new language means adding one of these
59+// and nothing else in this file.
60+//
61+// enclosingNameFromComment extracts an entity name from a unified diff hunk
62+// header comment (e.g. "func (s *Foo) Bar(...)" -> "Bar"). It's a fallback
63+// for hunks whose fragment text doesn't include a full declaration node --
64+// common for hunks that only touch the middle of a large function body,
65+// since we only have the patch, not the full file, to parse.
66+type languageSpec struct {
67+ language *sitter.Language
68+ query string
69+ enclosingNameFromComment func(string) (kind, name string, ok bool)
70+}
71+
72+var languageRegistry = map[string]languageSpec{
73+ ".go": {
74+ language: golang.GetLanguage(),
75+ query: `
76+(function_declaration
77+ name: (identifier) @name) @entity
78+
79+(method_declaration
80+ name: (field_identifier) @name) @entity
81+
82+(type_declaration
83+ (type_spec name: (type_identifier) @name)) @entity
84+`,
85+ enclosingNameFromComment: goEnclosingNameFromComment,
86+ },
87+ ".js": {
88+ language: javascript.GetLanguage(),
89+ query: jsFamilyQuery,
90+ enclosingNameFromComment: jsEnclosingNameFromComment,
91+ },
92+ ".jsx": {
93+ language: javascript.GetLanguage(),
94+ query: jsFamilyQuery,
95+ enclosingNameFromComment: jsEnclosingNameFromComment,
96+ },
97+ ".mjs": {
98+ language: javascript.GetLanguage(),
99+ query: jsFamilyQuery,
100+ enclosingNameFromComment: jsEnclosingNameFromComment,
101+ },
102+ ".cjs": {
103+ language: javascript.GetLanguage(),
104+ query: jsFamilyQuery,
105+ enclosingNameFromComment: jsEnclosingNameFromComment,
106+ },
107+ ".ts": {
108+ language: typescript.GetLanguage(),
109+ query: tsQuery,
110+ enclosingNameFromComment: jsEnclosingNameFromComment,
111+ },
112+ ".tsx": {
113+ language: tsx.GetLanguage(),
114+ query: tsQuery,
115+ enclosingNameFromComment: jsEnclosingNameFromComment,
116+ },
117+ ".py": {
118+ language: python.GetLanguage(),
119+ query: `
120+(function_definition
121+ name: (identifier) @name) @entity
122+
123+(class_definition
124+ name: (identifier) @name) @entity
125+`,
126+ enclosingNameFromComment: pyEnclosingNameFromComment,
127+ },
128+ ".rs": {
129+ language: rust.GetLanguage(),
130+ query: `
131+(function_item
132+ name: (identifier) @name) @entity
133+
134+(struct_item
135+ name: (type_identifier) @name) @entity
136+
137+(enum_item
138+ name: (type_identifier) @name) @entity
139+
140+(trait_item
141+ name: (type_identifier) @name) @entity
142+`,
143+ enclosingNameFromComment: rustEnclosingNameFromComment,
144+ },
145+}
146+
147+// jsFamilyQuery covers the declaration shapes shared by JavaScript and
148+// TypeScript.
149+const jsFamilyQuery = `
150+(function_declaration
151+ name: (identifier) @name) @entity
152+
153+(method_definition
154+ name: (property_identifier) @name) @entity
155+
156+(class_declaration
157+ name: (identifier) @name) @entity
158+`
159+
160+// tsQuery covers TypeScript's declaration shapes. It can't share
161+// jsFamilyQuery's class_declaration pattern because TypeScript's grammar
162+// requires a (type_identifier) name node there instead of JavaScript's
163+// (identifier), and a query naming a field type invalid for the grammar
164+// fails to compile at all, not just to match.
165+const tsQuery = `
166+(function_declaration
167+ name: (identifier) @name) @entity
168+
169+(method_definition
170+ name: (property_identifier) @name) @entity
171+
172+(class_declaration
173+ name: (type_identifier) @name) @entity
174+
175+(interface_declaration
176+ name: (type_identifier) @name) @entity
177+
178+(type_alias_declaration
179+ name: (type_identifier) @name) @entity
180+`
181+
182+var goFuncCommentPattern = regexp.MustCompile(`^func\s*(?:\([^)]*\)\s*)?([A-Za-z_][A-Za-z0-9_]*)\s*\(`)
183+
184+func goEnclosingNameFromComment(comment string) (kind, name string, ok bool) {
185+ m := goFuncCommentPattern.FindStringSubmatch(comment)
186+ if m == nil {
187+ return "", "", false
188+ }
189+ return "function_declaration", m[1], true
190+}
191+
192+var (
193+ jsFunctionCommentPattern = regexp.MustCompile(`^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*\(`)
194+ jsClassCommentPattern = regexp.MustCompile(`^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][A-Za-z0-9_$]*)`)
195+ jsMethodCommentPattern = regexp.MustCompile(`^\s*(?:public\s+|private\s+|protected\s+|static\s+|async\s+|readonly\s+)*(?:get\s+|set\s+)?([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:<[^>]*>)?\s*\(`)
196+ jsControlKeywords = map[string]bool{
197+ "if": true, "for": true, "while": true, "switch": true, "catch": true,
198+ "function": true, "return": true, "constructor": true,
199+ }
200+)
201+
202+func jsEnclosingNameFromComment(comment string) (kind, name string, ok bool) {
203+ if m := jsFunctionCommentPattern.FindStringSubmatch(comment); m != nil {
204+ return "function_declaration", m[1], true
205+ }
206+ if m := jsClassCommentPattern.FindStringSubmatch(comment); m != nil {
207+ return "class_declaration", m[1], true
208+ }
209+ if m := jsMethodCommentPattern.FindStringSubmatch(comment); m != nil && !jsControlKeywords[m[1]] {
210+ return "method_definition", m[1], true
211+ }
212+ return "", "", false
213+}
214+
215+var (
216+ pyFunctionCommentPattern = regexp.MustCompile(`^\s*(?:async\s+)?def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(`)
217+ pyClassCommentPattern = regexp.MustCompile(`^\s*class\s+([A-Za-z_][A-Za-z0-9_]*)`)
218+)
219+
220+func pyEnclosingNameFromComment(comment string) (kind, name string, ok bool) {
221+ if m := pyFunctionCommentPattern.FindStringSubmatch(comment); m != nil {
222+ return "function_definition", m[1], true
223+ }
224+ if m := pyClassCommentPattern.FindStringSubmatch(comment); m != nil {
225+ return "class_definition", m[1], true
226+ }
227+ return "", "", false
228+}
229+
230+var (
231+ rustFunctionCommentPattern = regexp.MustCompile(`^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(?:unsafe\s+)?(?:extern\s+"[^"]*"\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)`)
232+ rustStructCommentPattern = regexp.MustCompile(`^\s*(?:pub(?:\([^)]*\))?\s+)?struct\s+([A-Za-z_][A-Za-z0-9_]*)`)
233+ rustEnumCommentPattern = regexp.MustCompile(`^\s*(?:pub(?:\([^)]*\))?\s+)?enum\s+([A-Za-z_][A-Za-z0-9_]*)`)
234+ rustTraitCommentPattern = regexp.MustCompile(`^\s*(?:pub(?:\([^)]*\))?\s+)?trait\s+([A-Za-z_][A-Za-z0-9_]*)`)
235+)
236+
237+func rustEnclosingNameFromComment(comment string) (kind, name string, ok bool) {
238+ if m := rustFunctionCommentPattern.FindStringSubmatch(comment); m != nil {
239+ return "function_item", m[1], true
240+ }
241+ if m := rustStructCommentPattern.FindStringSubmatch(comment); m != nil {
242+ return "struct_item", m[1], true
243+ }
244+ if m := rustEnumCommentPattern.FindStringSubmatch(comment); m != nil {
245+ return "enum_item", m[1], true
246+ }
247+ if m := rustTraitCommentPattern.FindStringSubmatch(comment); m != nil {
248+ return "trait_item", m[1], true
249+ }
250+ return "", "", false
251+}
252+
253+func languageForFile(name string) (languageSpec, bool) {
254+ spec, ok := languageRegistry[strings.ToLower(filepath.Ext(name))]
255+ return spec, ok
256+}
257+
258+// SupportsSemanticDiff reports whether a file name's extension has a
259+// registered language spec, i.e. whether AnalyzeSemanticChanges can produce
260+// anything better than an empty result for it.
261+func SupportsSemanticDiff(fileName string) bool {
262+ _, ok := languageForFile(fileName)
263+ return ok
264+}
265+
266+// SemanticSummary aggregates semantic changes across every file in a patch,
267+// for a reviewer-facing rollup shown above the per-file breakdown.
268+type SemanticSummary struct {
269+ Added int
270+ Modified int
271+ SignatureChanged int
272+ Removed int
273+ AnalyzedFileCount int
274+ SkippedFiles []string
275+}
276+
277+func (s SemanticSummary) HasContent() bool {
278+ return s.AnalyzedFileCount > 0 || len(s.SkippedFiles) > 0
279+}
280+
281+func (s SemanticSummary) Total() int {
282+ return s.Added + s.Modified + s.SignatureChanged + s.Removed
283+}
284+
285+// SummarizeSemanticChanges folds one file's changes into a running summary.
286+// Call once per file in a patch with its changes (possibly nil) and whether
287+// the file's language was supported, then use the returned summary as-is.
288+func SummarizeSemanticChanges(summary SemanticSummary, fileName string, supported bool, changes []SemanticChange) SemanticSummary {
289+ if !supported {
290+ summary.SkippedFiles = append(summary.SkippedFiles, fileName)
291+ return summary
292+ }
293+
294+ summary.AnalyzedFileCount++
295+ for _, c := range changes {
296+ switch c.Kind {
297+ case SemanticAdded:
298+ summary.Added++
299+ case SemanticRemoved:
300+ summary.Removed++
301+ case SemanticSignatureChanged:
302+ summary.SignatureChanged++
303+ default:
304+ summary.Modified++
305+ }
306+ }
307+
308+ return summary
309+}
310+
311+// AnalyzeSemanticChanges produces a reviewer-facing list of semantic changes
312+// for a single diffed file. It only has access to the hunks present in the
313+// patch, not the full pre/post-image files, so entity extraction runs
314+// per-hunk on the old and new fragment text. Unsupported languages or parse
315+// failures degrade to an empty, non-error result so callers can always fall
316+// back to the line diff.
317+func AnalyzeSemanticChanges(file *gitdiff.File) []SemanticChange {
318+ name := file.NewName
319+ if name == "" {
320+ name = file.OldName
321+ }
322+
323+ spec, ok := languageForFile(name)
324+ if !ok || file.IsBinary {
325+ return nil
326+ }
327+
328+ query, err := sitter.NewQuery([]byte(spec.query), spec.language)
329+ if err != nil {
330+ return nil
331+ }
332+ defer query.Close()
333+
334+ var changes []SemanticChange
335+ for hunkIdx, frag := range file.TextFragments {
336+ oldText, newText := fragmentSides(frag)
337+
338+ oldEntities := extractEntities(spec.language, query, oldText)
339+ newEntities := extractEntities(spec.language, query, newText)
340+
341+ hunkChanges := diffEntities(oldEntities, newEntities, hunkIdx)
342+ if len(hunkChanges) == 0 {
343+ hunkChanges = enclosingChangeFromComment(spec, frag, hunkIdx)
344+ }
345+ if len(hunkChanges) == 0 {
346+ hunkChanges = genericChunkChange(frag, hunkIdx)
347+ }
348+ changes = append(changes, hunkChanges...)
349+ }
350+
351+ return mergeChangesByEntity(changes)
352+}
353+
354+// semanticChangeKindRank orders SemanticChangeKind by specificity, most
355+// specific first, so mergeChangesByEntity can keep the most informative
356+// classification when the same entity is flagged by more than one hunk.
357+var semanticChangeKindRank = map[SemanticChangeKind]int{
358+ SemanticSignatureChanged: 0,
359+ SemanticRenamed: 1,
360+ SemanticAdded: 2,
361+ SemanticRemoved: 2,
362+ SemanticModified: 3,
363+}
364+
365+// mergeChangesByEntity collapses multiple hunks flagging the same entity
366+// (e.g. a function whose body spans several hunks) into a single change.
367+// A large function edited across many hunks would otherwise produce one
368+// "modified" entry per hunk that touches it, repeating the same information
369+// with no added value. The first hunk's anchor is kept for the link, but the
370+// most specific kind across all matching hunks wins.
371+func mergeChangesByEntity(changes []SemanticChange) []SemanticChange {
372+ order := make([]string, 0, len(changes))
373+ merged := make(map[string]SemanticChange, len(changes))
374+
375+ for _, c := range changes {
376+ key := c.EntityKind + "\x00" + c.Name
377+ existing, ok := merged[key]
378+ if !ok {
379+ merged[key] = c
380+ order = append(order, key)
381+ continue
382+ }
383+ if semanticChangeKindRank[c.Kind] < semanticChangeKindRank[existing.Kind] {
384+ existing.Kind = c.Kind
385+ existing.OldSig = c.OldSig
386+ existing.NewSig = c.NewSig
387+ merged[key] = existing
388+ }
389+ }
390+
391+ result := make([]SemanticChange, 0, len(order))
392+ for _, key := range order {
393+ result = append(result, merged[key])
394+ }
395+ return result
396+}
397+
398+// enclosingChangeFromComment falls back to git's own "nearest enclosing
399+// function" hunk header (gitdiff.TextFragment.Comment) when a hunk's
400+// fragment text doesn't contain a full declaration for tree-sitter to
401+// match -- typically because the hunk only touches lines deep inside a
402+// function body, and we don't have the full file to parse for context.
403+func enclosingChangeFromComment(spec languageSpec, frag *gitdiff.TextFragment, hunkIdx int) []SemanticChange {
404+ if spec.enclosingNameFromComment == nil || frag.Comment == "" {
405+ return nil
406+ }
407+ if frag.LinesAdded == 0 && frag.LinesDeleted == 0 {
408+ return nil
409+ }
410+
411+ kind, name, ok := spec.enclosingNameFromComment(frag.Comment)
412+ if !ok {
413+ return nil
414+ }
415+
416+ return []SemanticChange{{
417+ Kind: SemanticModified,
418+ EntityKind: kind,
419+ Name: name,
420+ HunkIndex: hunkIdx,
421+ }}
422+}
423+
424+// genericChunkChange is the last-resort fallback for a hunk with real edits
425+// where neither a full declaration nor an enclosing-function comment could
426+// be identified -- e.g. a change inside an anonymous closure passed as a
427+// struct field, or a hunk in a language/file with no named top-level
428+// entities (go.mod, go.sum). It reports the hunk by its line range instead
429+// of by name, so reviewers still see *something* changed there.
430+func genericChunkChange(frag *gitdiff.TextFragment, hunkIdx int) []SemanticChange {
431+ if frag.LinesAdded == 0 && frag.LinesDeleted == 0 {
432+ return nil
433+ }
434+
435+ return []SemanticChange{{
436+ Kind: SemanticModified,
437+ EntityKind: "chunk",
438+ Name: fmt.Sprintf("lines %d-%d", frag.NewPosition, frag.NewPosition+frag.NewLines-1),
439+ HunkIndex: hunkIdx,
440+ }}
441+}
442+
443+// fragmentSides reconstructs the pre-image and post-image text of a hunk
444+// from its line list, since gitdiff only exposes the unified representation.
445+func fragmentSides(frag *gitdiff.TextFragment) (oldText, newText string) {
446+ var oldBuf, newBuf bytes.Buffer
447+ for _, line := range frag.Lines {
448+ switch line.Op {
449+ case gitdiff.OpContext:
450+ oldBuf.WriteString(line.Line)
451+ newBuf.WriteString(line.Line)
452+ case gitdiff.OpDelete:
453+ oldBuf.WriteString(line.Line)
454+ case gitdiff.OpAdd:
455+ newBuf.WriteString(line.Line)
456+ }
457+ }
458+ return oldBuf.String(), newBuf.String()
459+}
460+
461+// extractEntities runs the entity query against a best-effort parse of a
462+// hunk fragment. Tree-sitter is error-tolerant, so a syntactically
463+// incomplete fragment (a hunk that doesn't span whole declarations) still
464+// yields partial results rather than failing outright.
465+func extractEntities(lang *sitter.Language, query *sitter.Query, src string) []semanticEntity {
466+ if strings.TrimSpace(src) == "" {
467+ return nil
468+ }
469+
470+ root, err := sitter.ParseCtx(context.Background(), []byte(src), lang)
471+ if err != nil || root == nil {
472+ return nil
473+ }
474+
475+ cursor := sitter.NewQueryCursor()
476+ defer cursor.Close()
477+ cursor.Exec(query, root)
478+
479+ srcBytes := []byte(src)
480+ var entities []semanticEntity
481+ for {
482+ match, ok := cursor.NextMatch()
483+ if !ok {
484+ break
485+ }
486+
487+ var entityNode *sitter.Node
488+ var name string
489+ for _, capture := range match.Captures {
490+ captureName := query.CaptureNameForId(capture.Index)
491+ switch captureName {
492+ case "entity":
493+ entityNode = capture.Node
494+ case "name":
495+ name = capture.Node.Content(srcBytes)
496+ }
497+ }
498+ if entityNode == nil || name == "" {
499+ continue
500+ }
501+
502+ body := entityNode.Content(srcBytes)
503+ entities = append(entities, semanticEntity{
504+ Kind: entityNode.Type(),
505+ Name: name,
506+ Signature: signatureOf(body),
507+ BodyHash: hashBody(body),
508+ })
509+ }
510+
511+ return entities
512+}
513+
514+// signatureOf reduces an entity's source text to a single-line
515+// approximation of its declaration for display purposes.
516+func signatureOf(body string) string {
517+ if idx := strings.Index(body, "{"); idx >= 0 {
518+ body = body[:idx]
519+ }
520+ return strings.Join(strings.Fields(body), " ")
521+}
522+
523+func hashBody(body string) string {
524+ sum := sha256.Sum256([]byte(strings.Join(strings.Fields(body), " ")))
525+ return hex.EncodeToString(sum[:])
526+}
527+
528+// diffEntities classifies entities found in one hunk's old side vs new side
529+// by name. It only compares entities within the same hunk since that's the
530+// unit of context we have available from a patchset alone.
531+func diffEntities(oldEntities, newEntities []semanticEntity, hunkIdx int) []SemanticChange {
532+ oldByName := map[string]semanticEntity{}
533+ for _, e := range oldEntities {
534+ oldByName[e.Name] = e
535+ }
536+ newByName := map[string]semanticEntity{}
537+ for _, e := range newEntities {
538+ newByName[e.Name] = e
539+ }
540+
541+ var changes []SemanticChange
542+ for name, newEntity := range newByName {
543+ oldEntity, existed := oldByName[name]
544+ if !existed {
545+ changes = append(changes, SemanticChange{
546+ Kind: SemanticAdded,
547+ EntityKind: newEntity.Kind,
548+ Name: name,
549+ NewSig: newEntity.Signature,
550+ HunkIndex: hunkIdx,
551+ })
552+ continue
553+ }
554+ if oldEntity.BodyHash == newEntity.BodyHash {
555+ continue
556+ }
557+ kind := SemanticModified
558+ if oldEntity.Signature != newEntity.Signature {
559+ kind = SemanticSignatureChanged
560+ }
561+ changes = append(changes, SemanticChange{
562+ Kind: kind,
563+ EntityKind: newEntity.Kind,
564+ Name: name,
565+ OldSig: oldEntity.Signature,
566+ NewSig: newEntity.Signature,
567+ HunkIndex: hunkIdx,
568+ })
569+ }
570+ for name, oldEntity := range oldByName {
571+ if _, stillExists := newByName[name]; stillExists {
572+ continue
573+ }
574+ changes = append(changes, SemanticChange{
575+ Kind: SemanticRemoved,
576+ EntityKind: oldEntity.Kind,
577+ Name: name,
578+ OldSig: oldEntity.Signature,
579+ HunkIndex: hunkIdx,
580+ })
581+ }
582+
583+ return changes
584+}
+211,
-0
1@@ -0,0 +1,211 @@
2+package patchbin
3+
4+import (
5+ "testing"
6+)
7+
8+const sampleJSDiff = `diff --git a/foo.js b/foo.js
9+index 1111111..2222222 100644
10+--- a/foo.js
11++++ b/foo.js
12+@@ -1,5 +1,9 @@
13+ module.exports = {};
14+
15+-function add(a) {
16+- return a
17++function add(a, b) {
18++ return a + b
19++}
20++
21++function sub(a, b) {
22++ return a - b
23+ }
24+`
25+
26+func TestSemanticChangesJavaScript(t *testing.T) {
27+ files, _, err := ParsePatch(sampleJSDiff)
28+ if err != nil {
29+ t.Fatalf("ParsePatch: %v", err)
30+ }
31+ if len(files) != 1 {
32+ t.Fatalf("expected 1 file, got %d", len(files))
33+ }
34+
35+ changes := AnalyzeSemanticChanges(files[0])
36+ if len(changes) == 0 {
37+ t.Fatalf("expected semantic changes, got none")
38+ }
39+
40+ var sawAdd, sawSub bool
41+ for _, c := range changes {
42+ t.Logf("change: kind=%s entity=%s name=%s oldSig=%q newSig=%q hunk=%d",
43+ c.Kind, c.EntityKind, c.Name, c.OldSig, c.NewSig, c.HunkIndex)
44+ if c.Name == "add" && c.Kind == SemanticSignatureChanged {
45+ sawAdd = true
46+ }
47+ if c.Name == "sub" && c.Kind == SemanticAdded {
48+ sawSub = true
49+ }
50+ }
51+
52+ if !sawAdd {
53+ t.Errorf("expected add to be reported as signature_changed")
54+ }
55+ if !sawSub {
56+ t.Errorf("expected sub to be reported as added")
57+ }
58+}
59+
60+const sampleTSDiff = `diff --git a/foo.ts b/foo.ts
61+index 1111111..2222222 100644
62+--- a/foo.ts
63++++ b/foo.ts
64+@@ -1,5 +1,9 @@
65+ export {};
66+
67+-function add(a: number): number {
68+- return a
69++function add(a: number, b: number): number {
70++ return a + b
71++}
72++
73++function sub(a: number, b: number): number {
74++ return a - b
75+ }
76+`
77+
78+func TestSemanticChangesTypeScript(t *testing.T) {
79+ files, _, err := ParsePatch(sampleTSDiff)
80+ if err != nil {
81+ t.Fatalf("ParsePatch: %v", err)
82+ }
83+ if len(files) != 1 {
84+ t.Fatalf("expected 1 file, got %d", len(files))
85+ }
86+
87+ changes := AnalyzeSemanticChanges(files[0])
88+ if len(changes) == 0 {
89+ t.Fatalf("expected semantic changes, got none")
90+ }
91+
92+ var sawAdd, sawSub bool
93+ for _, c := range changes {
94+ t.Logf("change: kind=%s entity=%s name=%s oldSig=%q newSig=%q hunk=%d",
95+ c.Kind, c.EntityKind, c.Name, c.OldSig, c.NewSig, c.HunkIndex)
96+ if c.Name == "add" && c.Kind == SemanticSignatureChanged {
97+ sawAdd = true
98+ }
99+ if c.Name == "sub" && c.Kind == SemanticAdded {
100+ sawSub = true
101+ }
102+ }
103+
104+ if !sawAdd {
105+ t.Errorf("expected add to be reported as signature_changed")
106+ }
107+ if !sawSub {
108+ t.Errorf("expected sub to be reported as added")
109+ }
110+}
111+
112+const samplePyDiff = `diff --git a/foo.py b/foo.py
113+index 1111111..2222222 100644
114+--- a/foo.py
115++++ b/foo.py
116+@@ -1,4 +1,7 @@
117+ import os
118+
119+-def add(a):
120+- return a
121++def add(a, b):
122++ return a + b
123++
124++def sub(a, b):
125++ return a - b
126+`
127+
128+func TestSemanticChangesPython(t *testing.T) {
129+ files, _, err := ParsePatch(samplePyDiff)
130+ if err != nil {
131+ t.Fatalf("ParsePatch: %v", err)
132+ }
133+ if len(files) != 1 {
134+ t.Fatalf("expected 1 file, got %d", len(files))
135+ }
136+
137+ changes := AnalyzeSemanticChanges(files[0])
138+ if len(changes) == 0 {
139+ t.Fatalf("expected semantic changes, got none")
140+ }
141+
142+ var sawAdd, sawSub bool
143+ for _, c := range changes {
144+ t.Logf("change: kind=%s entity=%s name=%s oldSig=%q newSig=%q hunk=%d",
145+ c.Kind, c.EntityKind, c.Name, c.OldSig, c.NewSig, c.HunkIndex)
146+ if c.Name == "add" && c.Kind == SemanticSignatureChanged {
147+ sawAdd = true
148+ }
149+ if c.Name == "sub" && c.Kind == SemanticAdded {
150+ sawSub = true
151+ }
152+ }
153+
154+ if !sawAdd {
155+ t.Errorf("expected add to be reported as signature_changed")
156+ }
157+ if !sawSub {
158+ t.Errorf("expected sub to be reported as added")
159+ }
160+}
161+
162+const sampleRustDiff = `diff --git a/foo.rs b/foo.rs
163+index 1111111..2222222 100644
164+--- a/foo.rs
165++++ b/foo.rs
166+@@ -1,5 +1,9 @@
167+ mod foo;
168+
169+-fn add(a: i32) -> i32 {
170+- return a
171++fn add(a: i32, b: i32) -> i32 {
172++ return a + b
173++}
174++
175++fn sub(a: i32, b: i32) -> i32 {
176++ return a - b
177+ }
178+`
179+
180+func TestSemanticChangesRust(t *testing.T) {
181+ files, _, err := ParsePatch(sampleRustDiff)
182+ if err != nil {
183+ t.Fatalf("ParsePatch: %v", err)
184+ }
185+ if len(files) != 1 {
186+ t.Fatalf("expected 1 file, got %d", len(files))
187+ }
188+
189+ changes := AnalyzeSemanticChanges(files[0])
190+ if len(changes) == 0 {
191+ t.Fatalf("expected semantic changes, got none")
192+ }
193+
194+ var sawAdd, sawSub bool
195+ for _, c := range changes {
196+ t.Logf("change: kind=%s entity=%s name=%s oldSig=%q newSig=%q hunk=%d",
197+ c.Kind, c.EntityKind, c.Name, c.OldSig, c.NewSig, c.HunkIndex)
198+ if c.Name == "add" && c.Kind == SemanticSignatureChanged {
199+ sawAdd = true
200+ }
201+ if c.Name == "sub" && c.Kind == SemanticAdded {
202+ sawSub = true
203+ }
204+ }
205+
206+ if !sawAdd {
207+ t.Errorf("expected add to be reported as signature_changed")
208+ }
209+ if !sawSub {
210+ t.Errorf("expected sub to be reported as added")
211+ }
212+}
+211,
-0
1@@ -0,0 +1,211 @@
2+package patchbin
3+
4+import (
5+ "testing"
6+)
7+
8+const sampleDiff = `diff --git a/foo.go b/foo.go
9+index 1111111..2222222 100644
10+--- a/foo.go
11++++ b/foo.go
12+@@ -1,5 +1,9 @@
13+ package foo
14+
15+-func Add(a int) int {
16+- return a
17++func Add(a int, b int) int {
18++ return a + b
19++}
20++
21++func Sub(a, b int) int {
22++ return a - b
23+ }
24+`
25+
26+func TestSemanticChangesSmoke(t *testing.T) {
27+ files, _, err := ParsePatch(sampleDiff)
28+ if err != nil {
29+ t.Fatalf("ParsePatch: %v", err)
30+ }
31+ if len(files) != 1 {
32+ t.Fatalf("expected 1 file, got %d", len(files))
33+ }
34+
35+ changes := AnalyzeSemanticChanges(files[0])
36+ if len(changes) == 0 {
37+ t.Fatalf("expected semantic changes, got none")
38+ }
39+
40+ var sawAdd, sawSub bool
41+ for _, c := range changes {
42+ t.Logf("change: kind=%s entity=%s name=%s oldSig=%q newSig=%q hunk=%d",
43+ c.Kind, c.EntityKind, c.Name, c.OldSig, c.NewSig, c.HunkIndex)
44+ if c.Name == "Add" && c.Kind == SemanticSignatureChanged {
45+ sawAdd = true
46+ }
47+ if c.Name == "Sub" && c.Kind == SemanticAdded {
48+ sawSub = true
49+ }
50+ }
51+
52+ if !sawAdd {
53+ t.Errorf("expected Add to be reported as signature_changed")
54+ }
55+ if !sawSub {
56+ t.Errorf("expected Sub to be reported as added")
57+ }
58+}
59+
60+// bodyOnlyDiff mirrors a hunk deep inside a large function where the
61+// `func Foo(...) {` line itself isn't part of the fragment's context lines
62+// -- common in real-world large diffs (e.g. a 400-line function with a
63+// change on line 200). Only git's hunk-header comment identifies the
64+// enclosing function; tree-sitter finds no complete declaration node in
65+// either the old or new fragment text.
66+const bodyOnlyDiff = `diff --git a/big.go b/big.go
67+index 1111111..2222222 100644
68+--- a/big.go
69++++ b/big.go
70+@@ -33,6 +32,6 @@ func testSingleTenantE2E(t *testing.T) {
71+ // Hack to wait for startup
72+ time.Sleep(time.Millisecond * 100)
73+
74+- suite.userKey.MustCmd(suite.patch, "register")
75++ suite.userKey.MustCmd(suite.patch, "pr create test")
76+
77+ suite.adminKey.MustCmd(suite.patch, "pr create test")
78+`
79+
80+func TestSemanticChangesBodyOnlyHunkFallsBackToEnclosingFunc(t *testing.T) {
81+ files, _, err := ParsePatch(bodyOnlyDiff)
82+ if err != nil {
83+ t.Fatalf("ParsePatch: %v", err)
84+ }
85+ if len(files) != 1 {
86+ t.Fatalf("expected 1 file, got %d", len(files))
87+ }
88+
89+ changes := AnalyzeSemanticChanges(files[0])
90+ if len(changes) == 0 {
91+ t.Fatalf("expected a fallback semantic change from the hunk header comment, got none")
92+ }
93+
94+ var sawEnclosing bool
95+ for _, c := range changes {
96+ t.Logf("change: kind=%s entity=%s name=%s hunk=%d", c.Kind, c.EntityKind, c.Name, c.HunkIndex)
97+ if c.Name == "testSingleTenantE2E" && c.Kind == SemanticModified {
98+ sawEnclosing = true
99+ }
100+ }
101+
102+ if !sawEnclosing {
103+ t.Errorf("expected fallback to report testSingleTenantE2E as modified")
104+ }
105+}
106+
107+// closureDiff mirrors a hunk inside an anonymous closure passed as a struct
108+// field (e.g. cli.Command{Action: func(cCtx *cli.Context) error { ... }}).
109+// Git's own hunk-header heuristic can't find a nearby "func Name(...)" line
110+// here either -- it picks up unrelated doc text -- so neither the primary
111+// extraction nor the enclosing-comment fallback finds a name, and we should
112+// fall back to a generic line-range chunk instead of reporting nothing.
113+const closureDiff = `diff --git a/cli.go b/cli.go
114+index 1111111..2222222 100644
115+--- a/cli.go
116++++ b/cli.go
117+@@ -239,10 +239,10 @@ To get started, submit a new patch request:
118+ }
119+
120+ args := cCtx.Args()
121+- repoName := "bin"
122+- if args.Present() {
123+- repoName = args.First()
124++ if !args.Present() {
125++ return fmt.Errorf("must provide a repo name")
126+ }
127++ repoName := args.First()
128+
129+ body, err := io.ReadAll(sesh)
130+ if err != nil {
131+`
132+
133+func TestSemanticChangesClosureHunkFallsBackToGenericChunk(t *testing.T) {
134+ files, _, err := ParsePatch(closureDiff)
135+ if err != nil {
136+ t.Fatalf("ParsePatch: %v", err)
137+ }
138+ if len(files) != 1 {
139+ t.Fatalf("expected 1 file, got %d", len(files))
140+ }
141+
142+ changes := AnalyzeSemanticChanges(files[0])
143+ if len(changes) == 0 {
144+ t.Fatalf("expected a fallback generic chunk change, got none")
145+ }
146+
147+ var sawChunk bool
148+ for _, c := range changes {
149+ t.Logf("change: kind=%s entity=%s name=%s hunk=%d", c.Kind, c.EntityKind, c.Name, c.HunkIndex)
150+ if c.EntityKind == "chunk" && c.Kind == SemanticModified {
151+ sawChunk = true
152+ }
153+ }
154+
155+ if !sawChunk {
156+ t.Errorf("expected fallback to report a generic chunk change")
157+ }
158+}
159+
160+// multiHunkSameFuncDiff mirrors a large function edited in two separate,
161+// non-adjacent hunks (e.g. createPrDetail spanning several hundred lines
162+// with edits near the top and bottom). Each hunk independently falls back
163+// to reporting the enclosing function, so without deduplication this would
164+// produce one "modified" entry per hunk instead of one per function.
165+const multiHunkSameFuncDiff = `diff --git a/handler.go b/handler.go
166+index 1111111..2222222 100644
167+--- a/handler.go
168++++ b/handler.go
169+@@ -10,6 +10,6 @@ func createPrDetail(page string) http.HandlerFunc {
170+ return func(w http.ResponseWriter, r *http.Request) {
171+ id := r.PathValue("id")
172+
173+- prID, err := strconv.Atoi(id)
174++ prID, convErr := strconv.Atoi(id)
175+ if err != nil {
176+ w.WriteHeader(http.StatusUnprocessableEntity)
177+@@ -40,6 +40,6 @@ func createPrDetail(page string) http.HandlerFunc {
178+ }
179+
180+- logData, err := getLogData(web, pr.ID, aps.Patchsets)
181++ logData, logErr := getLogData(web, pr.ID, aps.Patchsets)
182+ if err != nil {
183+ web.Logger.Error("cannot fetch log data", "err", err)
184+ }
185+`
186+
187+func TestSemanticChangesDedupesSameEntityAcrossHunks(t *testing.T) {
188+ files, _, err := ParsePatch(multiHunkSameFuncDiff)
189+ if err != nil {
190+ t.Fatalf("ParsePatch: %v", err)
191+ }
192+ if len(files) != 1 {
193+ t.Fatalf("expected 1 file, got %d", len(files))
194+ }
195+ if len(files[0].TextFragments) != 2 {
196+ t.Fatalf("expected 2 hunks, got %d", len(files[0].TextFragments))
197+ }
198+
199+ changes := AnalyzeSemanticChanges(files[0])
200+
201+ var matches []SemanticChange
202+ for _, c := range changes {
203+ t.Logf("change: kind=%s entity=%s name=%s hunk=%d", c.Kind, c.EntityKind, c.Name, c.HunkIndex)
204+ if c.Name == "createPrDetail" {
205+ matches = append(matches, c)
206+ }
207+ }
208+
209+ if len(matches) != 1 {
210+ t.Errorf("expected exactly 1 change for createPrDetail across both hunks, got %d", len(matches))
211+ }
212+}
+86,
-18
1@@ -1,4 +1,4 @@
2-package git
3+package patchbin
4
5 import (
6 "fmt"
7@@ -17,19 +17,6 @@ CREATE TABLE IF NOT EXISTS app_users (
8 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
9 );
10
11-CREATE TABLE IF NOT EXISTS repos (
12- id INTEGER PRIMARY KEY AUTOINCREMENT,
13- user_id INTEGER NOT NULL,
14- name TEXT NOT NULL,
15- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
16- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
17- UNIQUE (user_id, name),
18- CONSTRAINT repo_user_id_fk
19- FOREIGN KEY(user_id) REFERENCES app_users(id)
20- ON DELETE CASCADE
21- ON UPDATE CASCADE
22-);
23-
24 CREATE TABLE IF NOT EXISTS acl (
25 id INTEGER PRIMARY KEY AUTOINCREMENT,
26 pubkey string,
27@@ -41,12 +28,13 @@ CREATE TABLE IF NOT EXISTS acl (
28 CREATE TABLE IF NOT EXISTS patch_requests (
29 id INTEGER PRIMARY KEY AUTOINCREMENT,
30 user_id INTEGER NOT NULL,
31- repo_id TEXT NOT NULL,
32+ repo_name TEXT NOT NULL DEFAULT '',
33 name TEXT NOT NULL,
34 text TEXT NOT NULL,
35 status TEXT NOT NULL,
36 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
37 updated_at DATETIME NOT NULL,
38+ last_activity DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
39 CONSTRAINT pr_user_id_fk
40 FOREIGN KEY(user_id) REFERENCES app_users(id)
41 ON DELETE CASCADE
42@@ -57,7 +45,6 @@ CREATE TABLE IF NOT EXISTS patchsets (
43 id INTEGER PRIMARY KEY AUTOINCREMENT,
44 user_id INTEGER NOT NULL,
45 patch_request_id INTEGER NOT NULL,
46- review BOOLEAN NOT NULL DEFAULT false,
47 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
48 CONSTRAINT patchset_user_id_fk
49 FOREIGN KEY(user_id) REFERENCES app_users(id)
50@@ -97,7 +84,6 @@ CREATE TABLE IF NOT EXISTS patches (
51 CREATE TABLE IF NOT EXISTS event_logs (
52 id INTEGER PRIMARY KEY AUTOINCREMENT,
53 user_id INTEGER NOT NULL,
54- repo_id TEXT,
55 patch_request_id INTEGER,
56 patchset_id INTEGER,
57 event TEXT NOT NULL,
58@@ -116,6 +102,8 @@ CREATE TABLE IF NOT EXISTS event_logs (
59 ON DELETE CASCADE
60 ON UPDATE CASCADE
61 );
62+
63+CREATE INDEX IF NOT EXISTS idx_patch_requests_last_activity ON patch_requests(last_activity);
64 `
65
66 var sqliteMigrations = []string{
67@@ -196,6 +184,86 @@ var sqliteMigrations = []string{
68 LEFT JOIN repos ON repos.name = ev.repo_id;
69 DROP TABLE event_logs;
70 ALTER TABLE tmp_event_logs RENAME TO event_logs;`,
71+ // Phase 1: Add repo_name column to patch_requests
72+ `ALTER TABLE patch_requests ADD COLUMN repo_name TEXT`,
73+ // Phase 1: Populate repo_name from existing repos table
74+ `UPDATE patch_requests SET repo_name = (SELECT name FROM repos WHERE id = patch_requests.repo_id)`,
75+ // Phase 1: Remove patch_requests whose repo no longer exists. These are
76+ // orphans from repo deletion, since ON DELETE CASCADE never fired
77+ // because PRAGMA foreign_keys was never enabled.
78+ `DELETE FROM event_logs WHERE patch_request_id IN (SELECT id FROM patch_requests WHERE repo_name IS NULL);
79+ DELETE FROM patches WHERE patchset_id IN (SELECT id FROM patchsets WHERE patch_request_id IN (SELECT id FROM patch_requests WHERE repo_name IS NULL));
80+ DELETE FROM patchsets WHERE patch_request_id IN (SELECT id FROM patch_requests WHERE repo_name IS NULL);
81+ DELETE FROM patch_requests WHERE repo_name IS NULL;`,
82+ // Phase 1: Add last_activity column to patch_requests
83+ `ALTER TABLE patch_requests ADD COLUMN last_activity DATETIME`,
84+ // Phase 1: Set initial last_activity values from event_logs
85+ `UPDATE patch_requests SET last_activity = (SELECT MAX(created_at) FROM event_logs WHERE patch_request_id = patch_requests.id) WHERE last_activity IS NULL`,
86+ // Phase 1: Set last_activity to created_at for PRs with no events
87+ `UPDATE patch_requests SET last_activity = created_at WHERE last_activity IS NULL`,
88+ // Phase 1: Create index on last_activity for fast filtering
89+ `CREATE INDEX IF NOT EXISTS idx_patch_requests_last_activity ON patch_requests(last_activity)`,
90+ // Phase 2: Drop repos table (no longer needed, repo_name is stored directly)
91+ `DROP TABLE IF EXISTS repos`,
92+ // Phase 2: Rebuild patch_requests without repo_id (SQLite can't drop a
93+ // column that's part of a foreign key constraint via ALTER TABLE).
94+ `CREATE TABLE tmp_patch_requests_v2 (
95+ id INTEGER PRIMARY KEY AUTOINCREMENT,
96+ user_id INTEGER NOT NULL,
97+ repo_name TEXT NOT NULL DEFAULT '',
98+ name TEXT NOT NULL,
99+ text TEXT NOT NULL,
100+ status TEXT NOT NULL,
101+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
102+ updated_at DATETIME NOT NULL,
103+ last_activity DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
104+ CONSTRAINT pr_user_id_fk
105+ FOREIGN KEY(user_id) REFERENCES app_users(id)
106+ ON DELETE CASCADE
107+ ON UPDATE CASCADE
108+ );
109+ INSERT INTO tmp_patch_requests_v2 (id, user_id, repo_name, name, text, status, created_at, updated_at, last_activity)
110+ SELECT id, user_id, repo_name, name, text, status, created_at, updated_at, last_activity
111+ FROM patch_requests;
112+ DROP TABLE patch_requests;
113+ ALTER TABLE tmp_patch_requests_v2 RENAME TO patch_requests;
114+ CREATE INDEX IF NOT EXISTS idx_patch_requests_last_activity ON patch_requests(last_activity);`,
115+ // Phase 2: Rebuild event_logs without repo_id, same reasoning as above.
116+ `CREATE TABLE tmp_event_logs_v2 (
117+ id INTEGER PRIMARY KEY AUTOINCREMENT,
118+ user_id INTEGER NOT NULL,
119+ patch_request_id INTEGER,
120+ patchset_id INTEGER,
121+ event TEXT NOT NULL,
122+ data TEXT,
123+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
124+ CONSTRAINT event_logs_pr_id_fk
125+ FOREIGN KEY(patch_request_id) REFERENCES patch_requests(id)
126+ ON DELETE CASCADE
127+ ON UPDATE CASCADE,
128+ CONSTRAINT event_logs_patchset_id_fk
129+ FOREIGN KEY(patchset_id) REFERENCES patchsets(id)
130+ ON DELETE CASCADE
131+ ON UPDATE CASCADE,
132+ CONSTRAINT event_logs_user_id_fk
133+ FOREIGN KEY(user_id) REFERENCES app_users(id)
134+ ON DELETE CASCADE
135+ ON UPDATE CASCADE
136+ );
137+ INSERT INTO tmp_event_logs_v2 (id, user_id, patch_request_id, patchset_id, event, data, created_at)
138+ SELECT id, user_id, patch_request_id, patchset_id, event, data, created_at
139+ FROM event_logs;
140+ DROP TABLE event_logs;
141+ ALTER TABLE tmp_event_logs_v2 RENAME TO event_logs;`,
142+ // Phase 2: Collapse legacy statuses (closed, accepted, reviewed) into
143+ // open, since the new model only has draft and open.
144+ `UPDATE patch_requests SET status = 'open' WHERE status NOT IN ('draft', 'open')`,
145+ // Delete patch requests with an empty title. These come from patchsets
146+ // whose first patch had no subject line and are unusable in the UI.
147+ `DELETE FROM event_logs WHERE patch_request_id IN (SELECT id FROM patch_requests WHERE trim(name) = '');
148+ DELETE FROM patches WHERE patchset_id IN (SELECT id FROM patchsets WHERE patch_request_id IN (SELECT id FROM patch_requests WHERE trim(name) = ''));
149+ DELETE FROM patchsets WHERE patch_request_id IN (SELECT id FROM patch_requests WHERE trim(name) = '');
150+ DELETE FROM patch_requests WHERE trim(name) = '';`,
151 }
152
153 // Open opens a database connection.
154@@ -224,7 +292,7 @@ func sqliteUpgrade(db *sqlx.DB) error {
155 if version == len(sqliteMigrations) {
156 return nil
157 } else if version > len(sqliteMigrations) {
158- return fmt.Errorf("git-pr (version %d) older than schema (version %d)", len(sqliteMigrations), version)
159+ return fmt.Errorf("patchbin (version %d) older than schema (version %d)", len(sqliteMigrations), version)
160 }
161
162 tx, err := db.Beginx()
M
ssh.go
+12,
-5
1@@ -1,10 +1,11 @@
2-package git
3+package patchbin
4
5 import (
6 "context"
7 "fmt"
8 "os"
9 "path/filepath"
10+ "time"
11
12 "github.com/picosh/pico/pkg/pssh"
13 "golang.org/x/crypto/ssh"
14@@ -40,10 +41,16 @@ func GitSshServer(ctx context.Context, cfg *GitCfg) *pssh.SSHServer {
15 panic(fmt.Sprintf("cannot find database file, check folder and perms: %s: %s", dbpath, err))
16 }
17
18+ interval, err := time.ParseDuration(cfg.RateLimitInterval)
19+ if err != nil {
20+ panic(fmt.Sprintf("invalid rate_limit_interval: %s: %s", cfg.RateLimitInterval, err))
21+ }
22+
23 be := &Backend{
24- DB: dbh,
25- Logger: cfg.Logger,
26- Cfg: cfg,
27+ DB: dbh,
28+ Logger: cfg.Logger,
29+ Cfg: cfg,
30+ Limiter: NewRateLimiter(cfg.RateLimitCount, interval),
31 }
32
33 prCmd := &PrCmd{
34@@ -53,7 +60,7 @@ func GitSshServer(ctx context.Context, cfg *GitCfg) *pssh.SSHServer {
35 server, err := pssh.NewSSHServerWithConfig(
36 ctx,
37 cfg.Logger,
38- "git-pr",
39+ "patchbin",
40 cfg.Host,
41 cfg.SshPort,
42 cfg.PromPort,
+0,
-213
1@@ -1,213 +0,0 @@
2-body {
3- padding-left: 1rem;
4- padding-right: 1rem;
5-}
6-
7-pre {
8- padding: var(--grid-height);
9-}
10-
11-table, tr {
12- border-spacing: 0;
13-}
14-
15-td, th {
16- padding: var(--grid-height);
17- border-bottom: 1px solid var(--grey-light);
18-}
19-
20-details {
21- margin-bottom: 0;
22-}
23-
24-.text-2xl {
25- text-transform: lowercase;
26-}
27-
28-.pill-success {
29- border: 1px solid var(--success);
30- color: var(--success);
31-}
32-
33-.pill-review {
34- border: 1px solid var(--review);
35- color: var(--review);
36-}
37-
38-.pill-admin {
39- border: 1px solid var(--admin);
40- color: var(--admin);
41-}
42-
43-.box-sm-review {
44- border: 2px solid var(--review);
45- padding: 0.15rem 0.35rem;
46-}
47-
48-.box-review {
49- border: 2px solid var(--review);
50- padding: 0.5rem 0.75rem;
51-}
52-
53-.max-w {
54- max-width: calc(100% - 300px);
55-}
56-
57-.word-break-word {
58- word-break: break-word;
59-}
60-
61-.patchset-list {
62- position: sticky;
63- top: 0;
64- left: 0;
65- max-height: 100vh;
66- overflow-y: auto;
67-}
68-
69-.mb-0 {
70- margin-bottom: 0;
71-}
72-
73-.patch-file {
74- position: sticky;
75- top: 0;
76- left: 0;
77- padding: var(--grid-height) 0;
78- background-color: var(--bg-color);
79- margin: 0;
80-}
81-
82-.details-min {
83- border: 0;
84- margin: 0;
85- padding: 0;
86-}
87-
88-.chroma {
89- margin: 0;
90- overflow-x: auto;
91-}
92-
93-.chroma .gi {
94- font-weight: normal !important;
95-}
96-
97-.interdiff summary {
98- margin: 0 !important;
99-}
100-
101-.interdiff pre {
102- overflow-x: scroll;
103-}
104-
105-.pr-tabs {
106- display: flex;
107- gap: 0;
108- border-bottom: 1px solid var(--grey-light);
109- margin-bottom: var(--line-height);
110-}
111-
112-.pr-tab {
113- padding: 0.5rem 1rem;
114- text-decoration: none;
115- border: 1px solid transparent;
116- border-bottom: none;
117- margin-bottom: -1px;
118-}
119-
120-.pr-tab-active {
121- border-color: var(--grey-light);
122- border-bottom: 1px solid var(--bg-color);
123- background-color: var(--bg-color);
124-}
125-
126-.timeline {
127- border-left: 2px solid var(--grey-light);
128- padding-left: var(--line-height);
129- margin-left: 0.5rem;
130- gap: var(--line-height);
131- margin-bottom: calc(var(--line-height) * 3);
132-}
133-
134-.timeline-item {
135- position: relative;
136-}
137-
138-.timeline-item::before {
139- content: "";
140- position: absolute;
141- left: calc(-1 * var(--line-height) - 0.25rem - 1px);
142- top: 0.5rem;
143- width: 0.5rem;
144- height: 0.5rem;
145- background-color: var(--grey-light);
146- border-radius: 50%;
147-}
148-
149-.timeline-status-accepted::before {
150- background-color: var(--success);
151-}
152-
153-.timeline-status-closed::before {
154- background-color: var(--admin);
155-}
156-
157-.timeline-status-reviewed::before {
158- background-color: var(--review);
159-}
160-
161-.timeline-status-open::before {
162- background-color: var(--link-color);
163-}
164-
165-.status-change-comment {
166- background-color: var(--blockquote-bg);
167- padding: var(--grid-height);
168- margin-top: var(--grid-height);
169-}
170-
171-.pill-status-accepted {
172- border: 1px solid var(--success);
173- color: var(--success);
174-}
175-
176-.pill-status-closed {
177- border: 1px solid var(--admin);
178- color: var(--admin);
179-}
180-
181-.pill-status-reviewed {
182- border: 1px solid var(--review);
183- color: var(--review);
184-}
185-
186-.pill-status-open {
187- border: 1px solid var(--link-color);
188- color: var(--link-color);
189-}
190-
191-@media only screen and (max-width: 40em) {
192- .collapse {
193- flex-direction: column;
194- }
195-
196- .collapse > div {
197- width: 100% !important;
198- max-width: 100% !important;
199- }
200-
201- .patchset-list {
202- position: initial;
203- }
204-
205- body {
206- padding-top: var(--line-height);
207- padding-left: 0.5rem;
208- padding-right: 0.5rem;
209- }
210-
211- pre {
212- padding: var(--grid-height) 0;
213- }
214-}
+583,
-0
1@@ -0,0 +1,583 @@
2+
3*,
4+::before,
5+::after {
6+ box-sizing: border-box;
7+}
8+
9+::-moz-focus-inner {
10+ border-style: none;
11+ padding: 0;
12+}
13+:-moz-focusring {
14+ outline: 1px dotted ButtonText;
15+}
16+:-moz-ui-invalid {
17+ box-shadow: none;
18+}
19+
20+:root {
21+ --line-height: 1.3rem;
22+ --grid-height: 0.65rem;
23+ --border: var(--grey);
24+}
25+
26+html {
27+ background-color: var(--bg-color);
28+ color: var(--text-color);
29+ line-height: var(--line-height);
30+ font-family:
31+ -apple-system,
32+ BlinkMacSystemFont,
33+ "Segoe UI",
34+ Roboto,
35+ Oxygen,
36+ Ubuntu,
37+ Cantarell,
38+ "Fira Sans",
39+ "Droid Sans",
40+ "Helvetica Neue",
41+ Arial,
42+ sans-serif,
43+ "Apple Color Emoji",
44+ "Segoe UI Emoji",
45+ "Segoe UI Symbol";
46+ -webkit-text-size-adjust: 100%;
47+ -moz-tab-size: 4;
48+ -o-tab-size: 4;
49+ tab-size: 4;
50+}
51+
52+body {
53+ margin: 0 auto;
54+ padding: 0 1rem;
55+}
56+
57+img {
58+ max-width: 100%;
59+ height: auto;
60+}
61+
62+b,
63+strong {
64+ font-weight: bold;
65+}
66+
67+code,
68+kbd,
69+samp,
70+pre {
71+ font-family: monospace;
72+}
73+
74+code,
75+kbd,
76+samp {
77+ border: 2px solid var(--code);
78+}
79+
80+pre > code {
81+ display: block;
82+ overflow-x: scroll;
83+ background-color: inherit;
84+ padding: 0;
85+ border: none;
86+ border-radius: 0;
87+}
88+
89+code {
90+ font-size: 90%;
91+ border-radius: 0.3rem;
92+ padding: 0.025rem 0.3rem;
93+ border: 1px solid var(--border);
94+}
95+
96+pre {
97+ border: 1px solid var(--border);
98+ padding: var(--grid-height);
99+ border-radius: 1px;
100+ overflow-x: auto;
101+}
102+
103+h1,
104+h2,
105+h3,
106+h4 {
107+ font-style: normal;
108+ font-size: 1rem;
109+ font-weight: bold;
110+ line-height: var(--line-height);
111+ margin: 0 0 var(--grid-height) 0;
112+ padding: 0;
113+ border: 0;
114+}
115+
116+path {
117+ fill: var(--text-color);
118+}
119+
120+a {
121+ text-decoration: none;
122+ color: var(--text-color);
123+}
124+
125+a:hover,
126+a:visited:hover {
127+ color: var(--visited);
128+ text-decoration: underline;
129+}
130+
131+a:visited {
132+ color: var(--text-color);
133+}
134+
135+header {
136+ margin: 1rem auto;
137+}
138+
139+p {
140+ margin-top: var(--line-height);
141+ margin-bottom: var(--line-height);
142+}
143+
144+summary {
145+ cursor: pointer;
146+}
147+
148+.container {
149+ max-width: 800px;
150+}
151+
152+.font-bold {
153+ font-weight: bold;
154+}
155+
156+.mono {
157+ font-family: monospace;
158+}
159+
160+.text-sm {
161+ font-size: 0.8rem;
162+}
163+
164+.text-md {
165+ font-size: 1rem;
166+}
167+
168+.flex {
169+ display: flex;
170+}
171+
172+.flex-col {
173+ flex-direction: column;
174+}
175+
176+.flex-wrap {
177+ flex-wrap: wrap;
178+}
179+
180+.items-center {
181+ align-items: center;
182+}
183+
184+.justify-between {
185+ justify-content: space-between;
186+}
187+
188+.justify-center {
189+ justify-content: center;
190+}
191+
192+.m-0 {
193+ margin: 0;
194+}
195+
196+.mb {
197+ margin-bottom: var(--grid-height);
198+}
199+
200+.mb-0 {
201+ margin-bottom: 0;
202+}
203+
204+.my {
205+ margin-top: var(--grid-height);
206+ margin-bottom: var(--grid-height);
207+}
208+
209+.px {
210+ padding-left: 0.5rem;
211+ padding-right: 0.5rem;
212+}
213+
214+.py {
215+ padding-top: var(--grid-height);
216+ padding-bottom: var(--grid-height);
217+}
218+
219+.gap {
220+ gap: var(--grid-height);
221+}
222+
223+.gap-2 {
224+ gap: var(--line-height);
225+}
226+
227+.group {
228+ display: flex;
229+ flex-direction: column;
230+ gap: var(--grid-height);
231+}
232+
233+.group-2 {
234+ display: flex;
235+ flex-direction: column;
236+ gap: var(--line-height);
237+}
238+
239+.flex-1 {
240+ flex: 1;
241+}
242+
243+.truncate {
244+ overflow: hidden;
245+ text-overflow: ellipsis;
246+ white-space: nowrap;
247+ min-width: 0;
248+}
249+
250+.pr-repo-col {
251+ display: inline-block;
252+ width: 6rem;
253+ flex-shrink: 0;
254+}
255+
256+.box {
257+ border: 1px solid var(--grey-light);
258+ padding: var(--grid-height);
259+}
260+
261+.box-sm {
262+ border: 1px solid var(--grey-light);
263+ padding: 0 var(--grid-height);
264+}
265+
266+.border-b {
267+ border-bottom: 1px solid var(--border);
268+}
269+
270+.border-b:last-child {
271+ border-bottom: 0;
272+}
273+
274+.border-visited {
275+ border-color: var(--visited);
276+}
277+
278+.sticky {
279+ position: sticky;
280+ top: 0;
281+ left: 0;
282+ background-color: var(--bg-color);
283+}
284+
285+.white-space-bs {
286+ white-space: break-spaces;
287+}
288+
289+.btn-nav {
290+ border-radius: 4px;
291+ padding: 6px 10px;
292+ border: 1px solid var(--border);
293+}
294+
295+.btn-nav:hover, .btn-active {
296+ border-color: var(--visited);
297+ text-decoration: none;
298+ color: var(--text-color);
299+}
300+
301+.pill-success {
302+ border: 1px solid var(--success);
303+ color: var(--success);
304+}
305+
306+.pill-admin {
307+ border: 1px solid var(--admin);
308+ color: var(--admin);
309+}
310+
311+.pill-info {
312+ border: 1px solid var(--link-color);
313+ color: var(--link-color);
314+}
315+
316+.contributor-tag,
317+.contributor-tag-admin {
318+ font-family: monospace;
319+ font-size: 0.85em;
320+ color: var(--grey-light);
321+ border: none;
322+ padding: 0;
323+}
324+
325+.contributor-tag-admin {
326+ color: var(--link-color);
327+}
328+
329+.event-meta {
330+ color: var(--grey-light);
331+}
332+
333+.word-break-word {
334+ word-break: break-word;
335+}
336+
337+.file-link {
338+ text-decoration: none;
339+ flex-shrink: 0;
340+}
341+
342+.patchset-list {
343+ position: sticky;
344+ top: 0;
345+ left: 0;
346+ max-height: 100vh;
347+ overflow-y: auto;
348+}
349+
350+.mb-0 {
351+ margin-bottom: 0;
352+}
353+
354+.patch-file {
355+ position: sticky;
356+ top: 0;
357+ left: 0;
358+ padding: var(--grid-height) 0;
359+ background-color: var(--bg-color);
360+ margin: 0;
361+}
362+
363+.details-min {
364+ border: 0;
365+ margin: 0;
366+ padding: 0;
367+}
368+
369+.chroma {
370+ margin: 0;
371+ overflow-x: auto;
372+}
373+
374+.chroma .gi {
375+ font-weight: normal !important;
376+}
377+
378+.chroma table {
379+ width: 100%;
380+ table-layout: fixed;
381+ word-wrap: break-word;
382+}
383+
384+.interdiff summary {
385+ margin: 0 !important;
386+}
387+
388+.interdiff pre {
389+ overflow-x: scroll;
390+}
391+
392+.timeline {
393+ border-left: 2px solid var(--grey-light);
394+ padding-left: var(--line-height);
395+ margin-left: 0.5rem;
396+ gap: var(--line-height);
397+ margin-bottom: calc(var(--line-height) * 3);
398+}
399+
400+.timeline-item {
401+ position: relative;
402+}
403+
404+.timeline-item::before {
405+ content: "";
406+ position: absolute;
407+ left: calc(-1 * var(--line-height) - 0.25rem - 1px);
408+ top: 0.5rem;
409+ width: 0.5rem;
410+ height: 0.5rem;
411+ background-color: var(--grey-light);
412+ border-radius: 50%;
413+}
414+
415+.timeline-status-accepted::before {
416+ background-color: var(--success);
417+}
418+
419+.timeline-status-closed::before {
420+ background-color: var(--admin);
421+}
422+
423+.timeline-status-open::before {
424+ background-color: var(--link-color);
425+}
426+
427+.status-change-comment {
428+ background-color: var(--blockquote-bg);
429+ padding: var(--grid-height);
430+ margin-top: var(--grid-height);
431+}
432+
433+.pill-status-accepted {
434+ border: 1px solid var(--success);
435+ color: var(--success);
436+}
437+
438+.pill-status-closed {
439+ border: 1px solid var(--admin);
440+ color: var(--admin);
441+}
442+
443+.pill-status-open {
444+ border: 1px solid var(--link-color);
445+ color: var(--link-color);
446+}
447+
448+.w-full {
449+ width: 100%;
450+}
451+
452+@media only screen and (max-width: 40em) {
453+ .collapse {
454+ flex-direction: column;
455+ }
456+
457+ .collapse > div {
458+ width: 100% !important;
459+ max-width: 100% !important;
460+ }
461+
462+ .patchset-list {
463+ position: initial;
464+ }
465+
466+ .flex-collapse {
467+ flex-direction: column;
468+ }
469+
470+ body {
471+ padding-top: var(--line-height);
472+ padding-left: 0.5rem;
473+ padding-right: 0.5rem;
474+ }
475+
476+ pre {
477+ padding: var(--grid-height) 0;
478+ }
479+
480+ header {
481+ margin: 0;
482+ }
483+}
484+
485+.commit-list {
486+ display: flex;
487+ flex-direction: column;
488+ gap: 0.2rem;
489+ font-size: 0.75rem;
490+}
491+
492+.commit-list-item {
493+ border: 1px solid var(--grey-light);
494+ padding: 0.2rem 0.4rem;
495+ color: var(--text-color);
496+ text-decoration: none;
497+ gap: var(--grid-height);
498+}
499+
500+.commit-list-item:hover {
501+ border-color: var(--visited);
502+}
503+
504+.commit-list-item-active {
505+ border-color: var(--visited);
506+ background-color: var(--blockquote-bg);
507+}
508+
509+.patch-detail-nav {
510+ margin: var(--grid-height) 0;
511+}
512+
513+.semantic-diff {
514+ list-style: none;
515+ padding: 0;
516+ margin: 0 0 var(--grid-height) 0;
517+ display: flex;
518+ flex-direction: column;
519+ gap: 0.2rem;
520+}
521+
522+.semantic-diff-link {
523+ color: var(--text-color);
524+ text-decoration: none;
525+}
526+
527+.semantic-diff-link:hover {
528+ color: var(--link-color);
529+}
530+
531+.semantic-diff-entity-kind {
532+ color: var(--grey-light);
533+}
534+
535+.semantic-diff-added .semantic-diff-entity-kind {
536+ color: var(--success);
537+}
538+
539+.semantic-diff-removed .semantic-diff-entity-kind {
540+ color: var(--admin);
541+}
542+
543+.semantic-diff-modified .semantic-diff-entity-kind,
544+.semantic-diff-signature_changed .semantic-diff-entity-kind {
545+ color: var(--link-color);
546+}
547+
548+.semantic-summary {
549+ margin-bottom: var(--grid-height);
550+}
551+
552+.semantic-summary-file {
553+ margin: var(--grid-height) 0;
554+ padding-left: var(--grid-height);
555+ border-left: 1px solid var(--grey-light);
556+}
557+
558+.semantic-summary-file .semantic-diff {
559+ margin-top: 0.2rem;
560+}
561+
562+.semantic-summary-totals {
563+ margin-bottom: var(--grid-height);
564+ color: var(--grey-light);
565+}
566+
567+.semantic-diff-added-text {
568+ color: var(--success);
569+}
570+
571+.semantic-diff-removed-text {
572+ color: var(--admin);
573+}
574+
575+.semantic-diff-modified-text,
576+.semantic-diff-signature_changed-text {
577+ color: var(--link-color);
578+}
579+
580+.patchset-split {
581+ width: 100%;
582+ max-width: 1500px;
583+}
584+ZMX_TASK_COMPLETED:0
585+[erock@kings git-pr]$
+18,
-0
1@@ -0,0 +1,18 @@
2+document.addEventListener("click", function (event) {
3+ var link = event.target.closest("[data-open-details]");
4+ if (!link) {
5+ return;
6+ }
7+ var hash = link.getAttribute("href");
8+ if (!hash || hash.charAt(0) !== "#") {
9+ return;
10+ }
11+ var target = document.getElementById(hash.slice(1));
12+ if (!target) {
13+ return;
14+ }
15+ var details = target.closest("details");
16+ if (details && !details.open) {
17+ details.open = true;
18+ }
19+});
+0,
-734
1@@ -1,734 +0,0 @@
2-*,
3-::before,
4-::after {
5- box-sizing: border-box;
6-}
7-
8-::-moz-focus-inner {
9- border-style: none;
10- padding: 0;
11-}
12-:-moz-focusring {
13- outline: 1px dotted ButtonText;
14-}
15-:-moz-ui-invalid {
16- box-shadow: none;
17-}
18-
19-:root {
20- --line-height: 1.3rem;
21- --grid-height: 0.65rem;
22-}
23-
24-html {
25- background-color: var(--bg-color);
26- color: var(--text-color);
27- font-size: 16px;
28- line-height: var(--line-height);
29- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
30- Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", Arial,
31- sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
32- -webkit-text-size-adjust: 100%;
33- -moz-tab-size: 4;
34- -o-tab-size: 4;
35- tab-size: 4;
36-}
37-
38-body {
39- margin: 0 auto;
40-}
41-
42-img {
43- max-width: 100%;
44- height: auto;
45-}
46-
47-b,
48-strong {
49- font-weight: bold;
50-}
51-
52-code,
53-kbd,
54-samp,
55-pre {
56- font-family: monospace;
57-}
58-
59-code,
60-kbd,
61-samp {
62- border: 2px solid var(--code);
63-}
64-
65-pre > code {
66- background-color: inherit;
67- padding: 0;
68- border: none;
69- border-radius: 0;
70-}
71-
72-code {
73- font-size: 90%;
74- border-radius: 0.3rem;
75- padding: 0.025rem 0.3rem;
76-}
77-
78-pre {
79- font-size: 0.8rem;
80- border-radius: 1px;
81- padding: var(--line-height);
82- overflow-x: auto;
83- background-color: var(--pre) !important;
84-}
85-
86-small {
87- font-size: 0.8rem;
88-}
89-
90-details {
91- border: 2px solid var(--grey-light);
92- padding: calc(var(--grid-height) - 2px) 1ch;
93- margin-bottom: var(--grid-height);
94-}
95-
96-details[open] summary {
97- margin-bottom: var(--grid-height);
98-}
99-
100-summary {
101- display: list-item;
102- cursor: pointer;
103-}
104-
105-h1,
106-h2,
107-h3,
108-h4 {
109- margin: 0;
110- padding: 0;
111- border: 0;
112- font-style: normal;
113- font-weight: inherit;
114- font-size: inherit;
115-}
116-
117-path {
118- fill: var(--text-color);
119- stroke: var(--text-color);
120-}
121-
122-hr {
123- color: inherit;
124- border: 0;
125- height: 2px;
126- background: var(--grey);
127- margin: calc(var(--grid-height) - 2px) auto;
128-}
129-
130-a {
131- text-decoration: none;
132- color: var(--link-color);
133-}
134-
135-a:hover,
136-a:visited:hover {
137- text-decoration: underline;
138-}
139-
140-a:visited {
141- color: var(--visited);
142-}
143-
144-section {
145- margin-bottom: 1.4rem;
146-}
147-
148-section:last-child {
149- margin-bottom: 0;
150-}
151-
152-header {
153- margin: 1rem auto;
154-}
155-
156-p {
157- margin-top: var(--line-height);
158- margin-bottom: var(--line-height);
159-}
160-
161-article {
162- overflow-wrap: break-word;
163-}
164-
165-blockquote {
166- border-left: 5px solid var(--blockquote);
167- background-color: var(--blockquote-bg);
168- padding: var(--grid-height);
169- margin: var(--line-height) 0;
170-}
171-
172-blockquote > p {
173- margin: 0;
174-}
175-
176-blockquote code {
177- border: 1px solid var(--blockquote);
178-}
179-
180-ul {
181- padding: 0 0 0 var(--line-height);
182- list-style-position: inside;
183- list-style-type: square;
184- margin: var(--line-height) 0;
185-}
186-
187-ul[style*="list-style-type: none;"] {
188- padding: 0;
189-}
190-
191-ol {
192- padding: 0 0 0 var(--line-height);
193- list-style-type: decimal;
194- margin: var(--line-height) 0;
195-}
196-
197-ol[style*="list-style-type: none;"] {
198- padding: 0;
199-}
200-
201-ol ul, ol ol, ul ol, ul ul {
202- padding: 0 0 0 3ch;
203- margin: 0;
204-}
205-
206-li {
207- margin: 0;
208- padding: 0;
209-}
210-
211-li::marker {
212- line-height: 0;
213-}
214-
215-footer {
216- text-align: center;
217- margin-bottom: calc(var(--line-height) * 3);
218-}
219-
220-dt {
221- font-weight: bold;
222-}
223-
224-dd {
225- margin-left: 0;
226-}
227-
228-dd:not(:last-child) {
229- margin-bottom: 0.5rem;
230-}
231-
232-figure {
233- margin: 0;
234-}
235-
236-sup {
237- line-height: 0;
238-}
239-
240-#toc {
241- margin-top: var(--line-height);
242-}
243-
244-.container {
245- max-width: 50em;
246- width: 100%;
247-}
248-
249-.container-sm {
250- max-width: 40em;
251- width: 100%;
252-}
253-
254-.container-center {
255- width: 100%;
256- height: 100%;
257- display: flex;
258- justify-content: center;
259-}
260-
261-.mono {
262- font-family: monospace;
263-}
264-
265-.link-alt-hover,
266-.link-alt-hover:visited,
267-.link-alt-hover:visited:hover,
268-.link-alt-hover:hover {
269- color: var(--hover);
270- text-decoration: none;
271-}
272-
273-.link-alt-hover:visited:hover,
274-.link-alt-hover:hover {
275- text-decoration: underline;
276-}
277-
278-.link-alt,
279-.link-alt:visited,
280-.link-alt:visited:hover,
281-.link-alt:hover {
282- color: var(--white);
283- text-decoration: none;
284-}
285-
286-.link-alt:visited:hover,
287-.link-alt:hover {
288- text-decoration: underline;
289-}
290-
291-.text-2xl code, .text-xl code, .text-lg code, .text-md code {
292- text-transform: none;
293-}
294-
295-.text-2xl {
296- font-size: var(--line-height);
297- font-weight: bold;
298- line-height: var(--line-height);
299- margin-bottom: var(--grid-height);
300- text-transform: uppercase;
301-}
302-
303-.text-xl, .text-lg, .text-md {
304- font-size: 1rem;
305- font-weight: bold;
306- line-height: var(--line-height);
307- margin-bottom: var(--grid-height);
308- text-transform: uppercase;
309-}
310-
311-.text-sm {
312- font-size: 0.8rem;
313-}
314-
315-.cursor-pointer {
316- cursor: pointer;
317-}
318-
319-.w-full {
320- width: 100%;
321-}
322-
323-.h-full {
324- height: 100%;
325-}
326-
327-.border {
328- border: 2px solid var(--grey-light);
329-}
330-
331-.text-left {
332- text-align: left;
333-}
334-
335-.text-center {
336- text-align: center;
337-}
338-
339-.text-underline {
340- text-decoration: underline;
341- text-decoration-thickness: 2px;
342-}
343-
344-.text-hdr {
345- color: var(--hover);
346-}
347-
348-.font-bold {
349- font-weight: bold;
350-}
351-
352-.font-italic {
353- font-style: italic;
354-}
355-
356-.inline {
357- display: inline;
358-}
359-
360-.inline-block {
361- display: inline-block;
362-}
363-
364-.max-w-half {
365- max-width: 50%;
366-}
367-
368-.h-screen {
369- height: 100vh;
370-}
371-
372-.w-screen {
373- width: 100vw;
374-}
375-
376-.flex {
377- display: flex;
378-}
379-
380-.flex-col {
381- flex-direction: column;
382-}
383-
384-.flex-wrap {
385- flex-wrap: wrap;
386-}
387-
388-.items-center {
389- align-items: center;
390-}
391-
392-.m-0 {
393- margin: 0;
394-}
395-
396-.mt-0 {
397- margin-top: 0;
398-}
399-
400-.mt {
401- margin-top: var(--grid-height);
402-}
403-
404-.mt-2 {
405- margin-top: var(--line-height);
406-}
407-
408-.mt-4 {
409- margin-top: calc(var(--line-height) * 2);
410-}
411-
412-.mb {
413- margin-bottom: var(--grid-height);
414-}
415-
416-.mb-2 {
417- margin-bottom: var(--line-height);
418-}
419-
420-.mb-4 {
421- margin-bottom: calc(var(--line-height) * 2);
422-}
423-
424-.mr {
425- margin-right: 0.5rem;
426-}
427-
428-.ml-sm {
429- margin-left: 0.25rem;
430-}
431-
432-.ml {
433- margin-left: 0.5rem;
434-}
435-
436-.pt-0 {
437- padding-top: 0;
438-}
439-
440-.my {
441- margin-top: var(--grid-height);
442- margin-bottom: var(--grid-height);
443-}
444-
445-.my-2 {
446- margin-top: var(--line-height);
447- margin-bottom: var(--line-height);
448-}
449-
450-.my-4 {
451- margin-top: calc(var(--line-height) * 2);
452- margin-bottom: calc(var(--line-height) * 2);
453-}
454-
455-.mx {
456- margin-left: 0.5rem;
457- margin-right: 0.5rem;
458-}
459-
460-.mx-2 {
461- margin-left: 1rem;
462- margin-right: 1rem;
463-}
464-
465-.m-1 {
466- margin: var(--grid-height);
467-}
468-
469-.p-1 {
470- padding: var(--grid-height);
471-}
472-
473-.p-0 {
474- padding: 0;
475-}
476-
477-.px {
478- padding-left: 0.5rem;
479- padding-right: 0.5rem;
480-}
481-
482-.px-2 {
483- padding-left: 1rem;
484- padding-right: 1rem;
485-}
486-
487-.px-4 {
488- padding-left: 2rem;
489- padding-right: 2rem;
490-}
491-
492-.py {
493- padding-top: var(--grid-height);
494- padding-bottom: var(--grid-height);
495-}
496-
497-.py-2 {
498- padding-top: var(--line-height);
499- padding-bottom: var(--line-height);
500-}
501-
502-.py-4 {
503- padding-top: calc(var(--line-height) * 2);
504- padding-bottom: calc(var(--line-height) * 2);
505-}
506-
507-.justify-between {
508- justify-content: space-between;
509-}
510-
511-.justify-center {
512- justify-content: center;
513-}
514-
515-.gap {
516- gap: var(--grid-height);
517-}
518-
519-.gap-2 {
520- gap: var(--line-height);
521-}
522-
523-.group {
524- display: flex;
525- flex-direction: column;
526- gap: var(--grid-height);
527-}
528-
529-.group-2 {
530- display: flex;
531- flex-direction: column;
532- gap: var(--line-height);
533-}
534-
535-.group-h {
536- display: flex;
537- gap: var(--grid-height);
538- align-items: center;
539-}
540-
541-.flex-1 {
542- flex: 1;
543-}
544-
545-.items-end {
546- align-items: end;
547-}
548-
549-.items-start {
550- align-items: start;
551-}
552-
553-.justify-end {
554- justify-content: end;
555-}
556-
557-.font-grey-light {
558- color: var(--grey-light);
559-}
560-
561-.hidden {
562- display: none;
563-}
564-
565-.align-right {
566- text-align: right;
567-}
568-
569-.text-transform-none {
570- text-transform: none;
571-}
572-
573-/* ==== MARKDOWN ==== */
574-
575-.md h1,
576-.md h2,
577-.md h3,
578-.md h4 {
579- padding: 0;
580- margin: 0;
581- /* margin: 1.5rem 0 0.9rem 0; */
582- font-weight: bold;
583-}
584-
585-.md h1 a,
586-.md h2 a,
587-.md h3 a,
588-.md h4 a {
589- color: var(--grey-light);
590- text-decoration: none;
591-}
592-
593-h1 code, h2 code, h3 code, h4 code {
594- text-transform: none;
595-}
596-
597-.md h1 {
598- font-size: 1rem;
599- line-height: var(--line-height);
600- margin-top: calc(var(--line-height) * 2);
601- margin-bottom: var(--grid-height);
602- text-transform: uppercase;
603-}
604-
605-.md h2, .md h3, .md h4 {
606- font-size: 1rem;
607- line-height: var(--line-height);
608- margin-top: calc(var(--line-height) * 2);
609- margin-bottom: var(--line-height);
610- text-transform: uppercase;
611- color: var(--white-dark);
612-}
613-
614-/* ==== HELPERS ==== */
615-
616-.logo-header {
617- line-height: 1;
618- display: inline-block;
619- background-color: #FF79C6;
620- background-image: linear-gradient(to right, #FF5555, #FF79C6, #F8F859);
621- color: transparent;
622- background-clip: text;
623- border: 3px solid #FF79C6;
624- padding: 8px 10px 10px 10px;
625- border-radius: 10px;
626- background-size: 100%;
627- margin: 0;
628- -webkit-background-clip: text;
629- -moz-background-clip: text;
630- -webkit-text-fill-color: transparent;
631- -moz-text-fill-color: transparent;
632-}
633-
634-.btn {
635- border: 2px solid var(--link-color);
636- color: var(--link-color);
637- padding: 0.4rem 1rem;
638- font-weight: bold;
639- display: inline-block;
640-}
641-
642-.btn-link,
643-.btn-link:visited {
644- border: 2px solid var(--link-color);
645- color: var(--link-color);
646- padding: var(--grid-height);
647- text-decoration: none;
648- font-weight: bold;
649- display: inline-block;
650-}
651-
652-.box {
653- border: 2px solid var(--grey-light);
654- padding: var(--grid-height);
655-}
656-
657-.box-sm {
658- border: 2px solid var(--grey-light);
659- padding: var(--grid-height);
660-}
661-
662-.box-alert {
663- border: 2px solid var(--hover);
664- padding: var(--line-height);
665-}
666-
667-.box-sm-alert {
668- border: 2px solid var(--hover);
669- padding: var(--grid-height);
670-}
671-
672-.list-none {
673- list-style-type: none;
674-}
675-
676-.list-square {
677- list-style-type: square;
678-}
679-
680-.list-disc {
681- list-style-type: disc;
682-}
683-
684-.list-decimal {
685- list-style-type: decimal;
686-}
687-
688-.pill {
689- border: 1px solid var(--link-color);
690- color: var(--link-color);
691-}
692-
693-.pill-alert {
694- border: 1px solid var(--hover);
695- color: var(--hover);
696-}
697-
698-.pill-info {
699- border: 1px solid var(--visited);
700- color: var(--visited);
701-}
702-
703-@media only screen and (max-width: 40em) {
704- body {
705- padding: 0 1rem;
706- }
707-
708- header {
709- margin: 0;
710- }
711-
712- .flex-collapse {
713- flex-direction: column;
714- }
715-}
716-
717-#debug {
718- position: relative;
719-}
720-
721-#debug .debug-grid {
722- width: 100%;
723- height: 100%;
724- position: absolute;
725- top: 0;
726- left: 0;
727- right: 0;
728- bottom: 0;
729- z-index: -1;
730- background-image:
731- repeating-linear-gradient(var(--code) 0 1px, transparent 1px 100%),
732- repeating-linear-gradient(90deg, var(--code) 0 1px, transparent 1px 100%);
733- background-size: 1ch var(--grid-height);
734- margin: 0;
735-}
+0,
-1
1@@ -15,7 +15,6 @@
2 --grey: #414558;
3 --grey-light: #6a708e;
4 --shadow: #252525;
5- --review: #f9e2af;
6 --admin: #f38ba8;
7 --success: #66f859;
8 }
+62,
-0
1@@ -0,0 +1,62 @@
2+package patchbin
3+
4+import (
5+ "bytes"
6+ "testing"
7+)
8+
9+func TestSemanticSummaryTemplateRenders(t *testing.T) {
10+ files, _, err := ParsePatch(sampleDiff)
11+ if err != nil {
12+ t.Fatalf("ParsePatch: %v", err)
13+ }
14+
15+ semanticChanges := AnalyzeSemanticChanges(files[0])
16+ for i := range semanticChanges {
17+ semanticChanges[i].HunkAnchor = hunkAnchor(1, "foo.go", semanticChanges[i].HunkIndex)
18+ }
19+
20+ summary := SummarizeSemanticChanges(SemanticSummary{}, "foo.go", true, semanticChanges)
21+ summary = SummarizeSemanticChanges(summary, "go.sum", false, nil)
22+
23+ pf := &PatchFile{
24+ File: files[0],
25+ DisplayName: "foo.go",
26+ FileAnchor: "patch-1-foo.go",
27+ Adds: 5,
28+ Dels: 2,
29+ SemanticChanges: semanticChanges,
30+ }
31+
32+ pd := &PatchData{
33+ PatchFiles: []*PatchFile{pf},
34+ SemanticSummary: summary,
35+ }
36+
37+ tmpl := getTemplate("pr.html")
38+ summaryTmpl := tmpl.Lookup("semantic-summary")
39+ if summaryTmpl == nil {
40+ t.Fatalf("semantic-summary template not found")
41+ }
42+
43+ var buf bytes.Buffer
44+ if err := summaryTmpl.Execute(&buf, pd); err != nil {
45+ t.Fatalf("execute: %v", err)
46+ }
47+
48+ out := buf.String()
49+ t.Logf("rendered output:\n%s", out)
50+
51+ for _, want := range []string{
52+ "Semantic diff summary",
53+ "foo.go",
54+ "1 added",
55+ "1 signature changed",
56+ "1 analyzed file",
57+ "1 file skipped",
58+ } {
59+ if !bytes.Contains(buf.Bytes(), []byte(want)) {
60+ t.Errorf("expected output to contain %q", want)
61+ }
62+ }
63+}
+68,
-0
1@@ -0,0 +1,68 @@
2+package patchbin
3+
4+import (
5+ "bytes"
6+ "html/template"
7+ "testing"
8+)
9+
10+func TestPatchFileTemplateRendersLineDiffOnly(t *testing.T) {
11+ files, _, err := ParsePatch(sampleDiff)
12+ if err != nil {
13+ t.Fatalf("ParsePatch: %v", err)
14+ }
15+
16+ semanticChanges := AnalyzeSemanticChanges(files[0])
17+ for i := range semanticChanges {
18+ semanticChanges[i].HunkAnchor = hunkAnchor(1, "foo.go", semanticChanges[i].HunkIndex)
19+ }
20+
21+ hunks := make([]PatchHunk, 0, len(files[0].TextFragments))
22+ for i, frag := range files[0].TextFragments {
23+ hunks = append(hunks, PatchHunk{
24+ Anchor: hunkAnchor(1, "foo.go", i),
25+ DiffText: template.HTML(frag.String()),
26+ })
27+ }
28+
29+ pf := &PatchFile{
30+ File: files[0],
31+ DisplayName: "foo.go",
32+ FileAnchor: "patch-1-foo.go",
33+ Adds: 5,
34+ Dels: 2,
35+ Hunks: hunks,
36+ SemanticChanges: semanticChanges,
37+ }
38+
39+ tmpl := getTemplate("pr.html")
40+ if tmpl == nil {
41+ t.Fatalf("getTemplate returned nil")
42+ }
43+
44+ patchFileTmpl := tmpl.Lookup("patch-file")
45+ if patchFileTmpl == nil {
46+ t.Fatalf("patch-file template not found")
47+ }
48+
49+ var buf bytes.Buffer
50+ if err := patchFileTmpl.Execute(&buf, pf); err != nil {
51+ t.Fatalf("execute: %v", err)
52+ }
53+
54+ out := buf.String()
55+ t.Logf("rendered output:\n%s", out)
56+
57+ // Per-file details should be pure line-diff now -- the semantic
58+ // breakdown lives only in the patch-level summary, so repeating it
59+ // here would be redundant.
60+ if bytes.Contains(buf.Bytes(), []byte("semantic-diff")) {
61+ t.Errorf("expected no semantic-diff content inside patch-file, semantic breakdown belongs in the summary only")
62+ }
63+ if bytes.Contains(buf.Bytes(), []byte("Show line diff")) {
64+ t.Errorf("expected no nested line-diff details, file details should be a flat line-diff")
65+ }
66+ if !bytes.Contains(buf.Bytes(), []byte("patch-1-foo.go-hunk-0")) {
67+ t.Errorf("expected hunk anchor in rendered line diff")
68+ }
69+}
+16,
-3
1@@ -9,11 +9,24 @@
2 <meta name="keywords" content="git, collaboration, patch, requests" />
3 {{template "meta" .}}
4
5- <link rel="stylesheet" href="/static/smol.css" />
6- <link rel="stylesheet" href="/static/git-pr.css" />
7+ <link rel="stylesheet" href="/static/patchbin.css" />
8 <link rel="stylesheet" href="/static/vars.css" />
9 <link rel="stylesheet" href="/syntax.css" />
10+ <script src="/static/semdiff.js" defer></script>
11 </head>
12- <body>{{template "body" .}}</body>
13+ <body>
14+ <header class="box">
15+ <div class="flex flex-col">
16+ <nav class="flex gap flex-wrap">
17+ <a class="btn-nav {{if eq .Tab ""}}btn-active{{end}}" href="/">patchbin</a>
18+ <a class="btn-nav {{if eq .Tab "active"}}btn-active{{end}}" href="/prs/active">active</a>
19+ <a class="btn-nav {{if eq .Tab "draft"}}btn-active{{end}}" href="/prs/draft">drafts</a>
20+ <a class="btn-nav {{if eq .Tab "inactive"}}btn-active{{end}}" href="/prs/inactive">inactive</a>
21+ <a class="btn-nav flex items-center" href="/rss">rss</a>
22+ </nav>
23+ </div>
24+ </header>
25+ {{template "body" .}}
26+ </body>
27 </html>
28 {{end}}
+18,
-0
1@@ -0,0 +1,18 @@
2+
3{{define "commit-list"}}
4+<div class="commit-list text-sm">
5+ {{$selectedID := .Patch.ID}}
6+ {{range $idx, $patch := .Patches}}
7+ <a class="commit-list-item flex justify-between items-center gap{{if eq $patch.ID $selectedID}} commit-list-item-active{{end}}"
8+ href="/ps/{{$.Patchset.ID}}/patches/{{$patch.ID}}">
9+ <span class="mono truncate" style="flex: 1;">
10+ {{if eq $patch.ID $selectedID}}<span class="text-md" style="color: var(--success);">→</span> {{end}}{{$patch.Title}}
11+ </span>
12+ <div style="flex-shrink: 0;">{{$patch.AuthorName}} <code>{{$patch.FormattedAuthorDate}}</code></div>
13+ </a>
14+ {{else}}
15+ <div class="box">
16+ No patches found for patch request.
17+ </div>
18+ {{end}}
19+</div>
20+{{end}}
+80,
-0
1@@ -0,0 +1,80 @@
2+{{define "patch-detail"}}
3+<div class="w-full flex-1" style="min-width: 0;">
4+ <div class="group" id="{{.Patch.Url}}">
5+ <div class="box border-visited flex justify-between items-center" id="{{.Patch.ID}}">
6+ <a href="#patch-{{.Patch.ID}}">{{.Patch.Title}}</a>
7+ <div>{{.Patch.AuthorName}} <code>{{.Patch.FormattedAuthorDate}}</code></div>
8+ </div>
9+ {{if .Patch.Body}}<pre class="w-full">{{.Patch.Body}}</pre>{{end}}
10+
11+ {{if .Patch.SemanticSummary.HasContent}}
12+ {{template "semantic-summary" .Patch}}
13+ {{end}}
14+ </div>
15+</div>
16+{{end}}
17+
18+{{define "patch-files"}}
19+<div class="group">
20+ {{range .Patch.PatchFiles}}
21+ {{template "patch-file" .}}
22+ {{end}}
23+</div>
24+
25+<div><a href="#top" class="text-sm">Back to top</a></div>
26+{{end}}
27+
28+{{define "semantic-summary"}}
29+{{$summary := .SemanticSummary}}
30+<details class="details-min semantic-summary" open>
31+ <summary class="font-bold">Semantic diff summary</summary>
32+ <div class="semantic-summary-totals text-sm">
33+ <span class="semantic-diff-added-text">{{$summary.Added}} added</span>,
34+ <span class="semantic-diff-modified-text">{{$summary.Modified}} modified</span>,
35+ <span class="semantic-diff-signature_changed-text">{{$summary.SignatureChanged}} signature changed</span>,
36+ <span class="semantic-diff-removed-text">{{$summary.Removed}} removed</span>
37+ across {{$summary.AnalyzedFileCount}} analyzed file{{if ne $summary.AnalyzedFileCount 1}}s{{end}}
38+ {{if $summary.SkippedFiles}}({{len $summary.SkippedFiles}} file{{if ne (len $summary.SkippedFiles) 1}}s{{end}} skipped: unsupported file type){{end}}
39+ </div>
40+ {{range .PatchFiles}}
41+ {{if .SemanticChanges}}
42+ <div class="semantic-summary-file">
43+ <a href="#{{.FileAnchor}}" class="mono">{{.DisplayName}}</a>
44+ <ul class="semantic-diff">
45+ {{range .SemanticChanges}}
46+ <li class="semantic-diff-{{.Kind}}">
47+ <a href="#{{.HunkAnchor}}" class="semantic-diff-link" data-open-details="1">
48+ <code class="semantic-diff-entity-kind">{{.EntityKind}}</code>
49+ <code class="mono">{{.Name}}</code>
50+ {{if eq (print .Kind) "added"}}added
51+ {{else if eq (print .Kind) "removed"}}removed
52+ {{else if eq (print .Kind) "signature_changed"}}signature changed
53+ {{else}}modified
54+ {{end}}
55+ </a>
56+ </li>
57+ {{end}}
58+ </ul>
59+ </div>
60+ {{end}}
61+ {{end}}
62+</details>
63+{{end}}
64+
65+{{define "patch-file"}}
66+<details class="details-min" id="{{.FileAnchor}}">
67+ <summary class="patch-file">
68+ <code class="pill-success">+{{.Adds}}</code>
69+ <code class="pill-admin">-{{.Dels}}</code>
70+ <span class="mono ml">{{.DisplayName}}</span>
71+ <a href="#{{.FileAnchor}}" class="file-link" title="link to file">#</a>
72+ </summary>
73+ {{if .IsBinary}}
74+ <div class="w-full"><pre>Binaries are not rendered as diffs.</pre></div>
75+ {{else}}
76+ {{range .Hunks}}
77+ <div class="w-full" id="{{.Anchor}}">{{.DiffText}}</div>
78+ {{end}}
79+ {{end}}
80+</details>
81+{{end}}
+155,
-155
1@@ -1,155 +1,155 @@
2-{{define "range-diff"}}
3-<div class="group">
4- <div class="flex gap-2 collapse">
5- <div class="group patchset-list" style="width: 350px;">
6- <h2 class="text-xl mt">
7- Range-diff <code>rd-{{.Patchset.ID}}</code>
8- </h2>
9-
10- {{range $diff := .PatchsetData.RangeDiff}}
11- <div class="box">
12- <dl>
13- <dt>title</dt>
14- <dd><a href="#{{$diff.Header.OldIdx}}-{{$diff.Header.NewIdx}}">{{$diff.Header.Title}}</a></dd>
15-
16- <dt>description</dt>
17- <dd>
18- <code class='{{if eq $diff.Type "rm"}}pill-admin{{else if eq $diff.Type "add"}}pill-success{{else if eq $diff.Type "diff"}}pill-review{{end}}'>
19- {{if eq $diff.Header.NewSha ""}}
20- Patch removed
21- {{else if eq $diff.Header.OldSha ""}}
22- Patch added
23- {{else if $diff.Header.ContentEqual}}
24- Patch equal
25- {{else}}
26- Patch changed
27- {{end}}
28- </code>
29- </dd>
30-
31- <dt>old #{{$diff.Header.OldIdx}}</dt>
32- <dd><code>{{sha $diff.Header.OldSha}}</code></dd>
33-
34- <dt>new #{{$diff.Header.NewIdx}}</dt>
35- <dd><code>{{sha $diff.Header.NewSha}}</code></dd>
36- </dl>
37- </div>
38- {{else}}
39- <div class="box">
40- No range diff found for patchset.
41- </div>
42- {{end}}
43-
44- <div><a href="#top">Back to top</a></div>
45- </div>
46-
47- <div class="max-w flex-1">
48- <div class="group">
49- {{range .PatchsetData.RangeDiff}}
50- <div id="{{.Header.OldIdx}}-{{.Header.NewIdx}}">
51- <div class="mb">
52- <code class='{{if eq .Type "rm"}}pill-admin{{else if eq .Type "add"}}pill-success{{else if eq .Type "diff"}}pill-review{{end}}'>
53- {{.Header}}
54- </code>
55- </div>
56-
57- {{if or .Header.AuthorChanged .Header.TitleChanged .Header.BodyChanged}}
58- <div class="box mb">
59- <dl>
60- {{if .Header.AuthorChanged}}
61- <dt>author changed</dt>
62- <dd>
63- <div><span style="color: tomato;">- {{.Header.OldAuthorName}} <{{.Header.OldAuthorEmail}}></span></div>
64- <div><span style="color: limegreen;">+ {{.Header.NewAuthorName}} <{{.Header.NewAuthorEmail}}></span></div>
65- </dd>
66- {{end}}
67- {{if .Header.TitleChanged}}
68- <dt>title changed</dt>
69- <dd>
70- <div><span style="color: tomato;">- {{.Header.OldTitle}}</span></div>
71- <div><span style="color: limegreen;">+ {{.Header.NewTitle}}</span></div>
72- </dd>
73- {{end}}
74- {{if .Header.BodyChanged}}
75- <dt>message changed</dt>
76- <dd>
77- <pre style="color: tomato; margin: 0;">- {{.Header.OldBody}}</pre>
78- <pre style="color: limegreen; margin: 0;">+ {{.Header.NewBody}}</pre>
79- </dd>
80- {{end}}
81- </dl>
82- </div>
83- {{end}}
84-
85- <div>
86- {{- if and .Files (ne .Type "add") -}}
87- {{range .Files}}
88- <div class="flex gap">
89- <div class="flex-1" style="width: 48%;">
90- <h3 class="text-md">old</h3>
91- <div>
92- {{if .OldFile}}
93- {{if .OldFile.OldName}}old:<code>{{.OldFile.OldName}}</code>{{end}}
94- {{if .OldFile.NewName}}new:<code>{{.OldFile.NewName}}</code>{{end}}
95- {{end}}
96- </div>
97- <pre class="m-0">{{- range .Diff -}}
98- {{- if eq .OuterType "delete" -}}
99- {{- if eq .InnerType "insert" -}}
100- <span style="background-color: rgba(255,99,71,0.25); color: limegreen;">{{.Text}}</span>
101- {{- else if eq .InnerType "delete" -}}
102- <span style="background-color: rgba(255,99,71,0.25); color: tomato;">{{.Text}}</span>
103- {{- else -}}
104- <span style="background-color: rgba(255,99,71,0.25);">{{.Text}}</span>
105- {{- end -}}
106- {{- else if eq .OuterType "insert" -}}
107- {{- else if eq .InnerType "insert" -}}
108- <span style="color: limegreen;">{{.Text}}</span>
109- {{- else if eq .InnerType "delete" -}}
110- <span style="color: tomato;">{{.Text}}</span>
111- {{- else -}}
112- <span>{{.Text}}</span>
113- {{- end -}}
114- {{- end -}}</pre>
115- </div>
116-
117- <div class="flex-1" style="width: 48%;">
118- <h3 class="text-md">new</h3>
119- <div>
120- {{if .NewFile}}
121- {{if .NewFile.OldName}}old:<code>{{.NewFile.OldName}}</code>{{end}}
122- {{if .NewFile.NewName}}new:<code>{{.NewFile.NewName}}</code>{{end}}
123- {{end}}
124- </div>
125- <pre class="m-0">{{- range .Diff -}}
126- {{- if eq .OuterType "insert" -}}
127- {{- if eq .InnerType "insert" -}}
128- <span style="background-color: rgba(50,205,50,0.25); color: limegreen;">{{.Text}}</span>
129- {{- else if eq .InnerType "delete" -}}
130- <span style="background-color: rgba(50,205,50,0.25); color: tomato;">{{.Text}}</span>
131- {{- else -}}
132- <span style="background-color: rgba(50,205,50,0.25);">{{.Text}}</span>
133- {{- end -}}
134- {{- else if eq .OuterType "delete" -}}
135- {{- else if eq .InnerType "insert" -}}
136- <span style="color: limegreen;">{{.Text}}</span>
137- {{- else if eq .InnerType "delete" -}}
138- <span style="color: tomato;">{{.Text}}</span>
139- {{- else -}}
140- <span>{{.Text}}</span>
141- {{- end -}}
142- {{- end -}}</pre>
143- </div>
144- </div>
145- {{end}}
146- {{- end -}}
147- </div>
148- </div>
149- {{- end -}}
150- </div>
151-
152- <hr class="my" />
153- </div>
154- </div>
155-</div>
156-{{end}}
157+
158{{define "range-diff"}}
159+<div class="group">
160+ <div class="flex gap-2 collapse">
161+ <div class="group patchset-list" style="width: 350px;">
162+ <h2 class="text-xl mt">
163+ Range-diff <code>rd-{{.Patchset.ID}}</code>
164+ </h2>
165+
166+ {{range $diff := .PatchsetData.RangeDiff}}
167+ <div class="box">
168+ <dl>
169+ <dt>title</dt>
170+ <dd><a href="#{{$diff.Header.OldIdx}}-{{$diff.Header.NewIdx}}">{{$diff.Header.Title}}</a></dd>
171+
172+ <dt>description</dt>
173+ <dd>
174+ <code class='{{if eq $diff.Type "rm"}}pill-admin{{else if eq $diff.Type "add"}}pill-success{{else if eq $diff.Type "changed"}}pill-info{{end}}'>
175+ {{if eq $diff.Header.NewSha ""}}
176+ Patch removed
177+ {{else if eq $diff.Header.OldSha ""}}
178+ Patch added
179+ {{else if $diff.Header.ContentEqual}}
180+ Patch equal
181+ {{else}}
182+ Patch changed
183+ {{end}}
184+ </code>
185+ </dd>
186+
187+ <dt>old #{{$diff.Header.OldIdx}}</dt>
188+ <dd><code>{{sha $diff.Header.OldSha}}</code></dd>
189+
190+ <dt>new #{{$diff.Header.NewIdx}}</dt>
191+ <dd><code>{{sha $diff.Header.NewSha}}</code></dd>
192+ </dl>
193+ </div>
194+ {{else}}
195+ <div class="box">
196+ No range diff found for patchset.
197+ </div>
198+ {{end}}
199+
200+ <div><a href="#top">Back to top</a></div>
201+ </div>
202+
203+ <div class="max-w flex-1">
204+ <div class="group">
205+ {{range .PatchsetData.RangeDiff}}
206+ <div id="{{.Header.OldIdx}}-{{.Header.NewIdx}}">
207+ <div class="mb">
208+ <code class='{{if eq .Type "rm"}}pill-admin{{else if eq .Type "add"}}pill-success{{else if eq .Type "changed"}}pill-info{{end}}'>
209+ {{.Header}}
210+ </code>
211+ </div>
212+
213+ {{if or .Header.AuthorChanged .Header.TitleChanged .Header.BodyChanged}}
214+ <div class="box mb">
215+ <dl>
216+ {{if .Header.AuthorChanged}}
217+ <dt>author changed</dt>
218+ <dd>
219+ <div><span style="color: tomato;">- {{.Header.OldAuthorName}} <{{.Header.OldAuthorEmail}}></span></div>
220+ <div><span style="color: limegreen;">+ {{.Header.NewAuthorName}} <{{.Header.NewAuthorEmail}}></span></div>
221+ </dd>
222+ {{end}}
223+ {{if .Header.TitleChanged}}
224+ <dt>title changed</dt>
225+ <dd>
226+ <div><span style="color: tomato;">- {{.Header.OldTitle}}</span></div>
227+ <div><span style="color: limegreen;">+ {{.Header.NewTitle}}</span></div>
228+ </dd>
229+ {{end}}
230+ {{if .Header.BodyChanged}}
231+ <dt>message changed</dt>
232+ <dd>
233+ <pre style="color: tomato; margin: 0;">- {{.Header.OldBody}}</pre>
234+ <pre style="color: limegreen; margin: 0;">+ {{.Header.NewBody}}</pre>
235+ </dd>
236+ {{end}}
237+ </dl>
238+ </div>
239+ {{end}}
240+
241+ <div>
242+ {{- if and .Files (ne .Type "add") -}}
243+ {{range .Files}}
244+ <div class="flex gap">
245+ <div class="flex-1" style="width: 48%;">
246+ <h3 class="text-md">old</h3>
247+ <div>
248+ {{if .OldFile}}
249+ {{if .OldFile.OldName}}old:<code>{{.OldFile.OldName}}</code>{{end}}
250+ {{if .OldFile.NewName}}new:<code>{{.OldFile.NewName}}</code>{{end}}
251+ {{end}}
252+ </div>
253+ <pre class="m-0">{{- range .Diff -}}
254+ {{- if eq .OuterType "delete" -}}
255+ {{- if eq .InnerType "insert" -}}
256+ <span style="background-color: rgba(255,99,71,0.25); color: limegreen;">{{.Text}}</span>
257+ {{- else if eq .InnerType "delete" -}}
258+ <span style="background-color: rgba(255,99,71,0.25); color: tomato;">{{.Text}}</span>
259+ {{- else -}}
260+ <span style="background-color: rgba(255,99,71,0.25);">{{.Text}}</span>
261+ {{- end -}}
262+ {{- else if eq .OuterType "insert" -}}
263+ {{- else if eq .InnerType "insert" -}}
264+ <span style="color: limegreen;">{{.Text}}</span>
265+ {{- else if eq .InnerType "delete" -}}
266+ <span style="color: tomato;">{{.Text}}</span>
267+ {{- else -}}
268+ <span>{{.Text}}</span>
269+ {{- end -}}
270+ {{- end -}}</pre>
271+ </div>
272+
273+ <div class="flex-1" style="width: 48%;">
274+ <h3 class="text-md">new</h3>
275+ <div>
276+ {{if .NewFile}}
277+ {{if .NewFile.OldName}}old:<code>{{.NewFile.OldName}}</code>{{end}}
278+ {{if .NewFile.NewName}}new:<code>{{.NewFile.NewName}}</code>{{end}}
279+ {{end}}
280+ </div>
281+ <pre class="m-0">{{- range .Diff -}}
282+ {{- if eq .OuterType "insert" -}}
283+ {{- if eq .InnerType "insert" -}}
284+ <span style="background-color: rgba(50,205,50,0.25); color: limegreen;">{{.Text}}</span>
285+ {{- else if eq .InnerType "delete" -}}
286+ <span style="background-color: rgba(50,205,50,0.25); color: tomato;">{{.Text}}</span>
287+ {{- else -}}
288+ <span style="background-color: rgba(50,205,50,0.25);">{{.Text}}</span>
289+ {{- end -}}
290+ {{- else if eq .OuterType "delete" -}}
291+ {{- else if eq .InnerType "insert" -}}
292+ <span style="color: limegreen;">{{.Text}}</span>
293+ {{- else if eq .InnerType "delete" -}}
294+ <span style="color: tomato;">{{.Text}}</span>
295+ {{- else -}}
296+ <span>{{.Text}}</span>
297+ {{- end -}}
298+ {{- end -}}</pre>
299+ </div>
300+ </div>
301+ {{end}}
302+ {{- end -}}
303+ </div>
304+ </div>
305+ {{- end -}}
306+ </div>
307+
308+ <hr class="my" />
309+ </div>
310+ </div>
311+</div>
312+{{end}}
+1,
-3
1@@ -1,5 +1,3 @@
2 {{define "user-pill"}}
3-<a href="/r/{{.Name}}">
4- <code class='pill{{if .IsAdmin}}-admin{{end}}' title="{{.Pubkey}}">{{.Name}}</code>
5-</a>
6+<code class='contributor-tag{{if .IsAdmin}}-admin{{end}}' title="{{.Pubkey}}">{{.Name}}</code>
7 {{end}}
+128,
-233
1@@ -1,6 +1,6 @@
2 {{template "base" .}}
3
4-{{define "title"}}git-pr{{end}}
5+{{define "title"}}patchbin{{end}}
6
7 {{define "meta"}}
8 <link rel="alternate" type="application/atom+xml"
9@@ -9,254 +9,149 @@
10 {{end}}
11
12 {{define "body"}}
13-<header class="group">
14- <h1 class="text-2xl">git-pr</h1>
15- <div>
16- <span>A pastebin supercharged for git collaboration</span> ·
17- <a href="https://github.com/picosh/git-pr">github</a> ·
18- <a href="https://youtu.be/d28Dih-BBUw">demo video</a>
19- </div>
20-
21- {{if .MetaData.Desc}}
22- <div class="box-sm">
23- <div>{{.MetaData.Desc}}</div>
24- </div>
25- {{end}}
26-
27- <details>
28- <summary>Intro</summary>
29-
30- <div>
31- <p>
32- We are trying to build the simplest git collaboration tool. The goal is to make
33- self-hosting as simple as running an SSH server -- all without
34- sacrificing external collaborators time and energy.
35- </p>
36-
37- <blockquote>
38- <code>git format-patch</code> isn't the problem and pull requests aren't the solution.
39- </blockquote>
40-
41- <p>
42- We are combining mailing list and pull request workflows. In order to build the
43- simplest collaboration tool, we needed something as simple as generating patches
44- but the ease-of-use of pull requests.
45- </p>
46-
47- <p>
48- The goal is not to create another code forge, the goal is to create a very
49- simple self-hosted git solution with the ability to collaborate with external
50- contributors. All the code owner needs to setup a running git server:
51- </p>
52-
53- <ul><li>A single golang binary</li></ul>
54+<main class="group">
55+ <div class="flex justify-center items-center">
56+ <div class="box container w-full group">
57+ <h1 class="text-xl">A pastebin for patches, supercharged for git collaboration</h1>
58+
59+ {{if .MetaData.Desc}}
60+ <div class="box-sm">
61+ <div>{{.MetaData.Desc}}</div>
62+ </div>
63+ {{end}}
64
65 <div>
66- All an external contributor needs is:
67+ Contributions are designed to be anonymous: the quality of your work
68+ is what matters. No signup required, just connect with an SSH key.
69 </div>
70
71- <ul>
72- <li>An SSH keypair</li>
73- <li>An SSH client</li>
74- </ul>
75-
76- <p>Then everyone subscribes to our RSS feeds to receive updates to patch requests.</p>
77-
78- <h2 class="text-xl">the problem</h2>
79-
80- <p>
81- Email is great as a decentralized system to send and receive changes (patchsets)
82- to a git repo. However, onboarding a new user to a mailing list, properly
83- setting up their email client, and then finally submitting the code contribution
84- is enough to make many developers give up. Further, because we are leveraging
85- the email protocol for collaboration, we are limited by its feature-set. For
86- example, it is not possible to make edits to emails, everyone has a different
87- client, those clients have different limitations around plain text email and
88- downloading patches from it.
89- </p>
90-
91- <p>
92- Github pull requests are easy to use, easy to edit, and easy to manage. The
93- downside is it forces the user to be inside their website to perform reviews.
94- For quick changes, this is great, but when you start reading code within a web
95- browser, there are quite a few downsides. At a certain point, it makes more
96- sense to review code inside your local development environment, IDE, etc. There
97- are tools and plugins that allow users to review PRs inside their IDE, but it
98- requires a herculean effort to make it usable.
99- </p>
100-
101- <p>
102- Further, self-hosted solutions that mimic a pull request require a lot of
103- infrastructure in order to manage it. A database, a web site connected to git,
104- admin management, and services to manage it all. Another big point of friction:
105- before an external user submits a code change, they first need to create an
106- account and then login. This adds quite a bit of friction for a self-hosted
107- solution, not only for an external contributor, but also for the code owner who
108- has to provision the infra. Often times they also have to fork the repo within
109- the code forge before submitting a PR. Then they never make a contribution ever
110- again and keep a forked repo around forever. That seems silly.
111- </p>
112-
113- <h2 class="text-xl">introducing patch requests (PR)</h2>
114-
115- <p>
116- Instead, we want to create a self-hosted git "server" that can handle sending
117- and receiving patches without the cumbersome nature of setting up email or the
118- limitations imposed by the email protocol. Further, we want the primary workflow
119- to surround the local development environment. Github is bringing the IDE to the
120- browser in order to support their workflow, we want to flip that idea on its
121- head by making code reviews a first-class citizen inside your local development
122- environment.
123- </p>
124-
125- <p>
126- We see this as a hybrid between the github workflow of a pull request and
127- sending and receiving patches over email.
128- </p>
129-
130- <p>
131- The basic idea is to leverage an SSH app to handle most of the interaction
132- between contributor and owner of a project. Everything can be done completely
133- within the terminal, in a way that is ergonomic and fully featured.
134- </p>
135-
136- <p>
137- Notifications would happen with RSS and all state mutations would result in the
138- generation of static web assets so it can all be hosted using a simple file web
139- server.
140- </p>
141-
142- <h3 class="text-lg">format-patch workflow</h3>
143-
144- <p>
145- The fundamental collaboration tool here is <code>format-patch</code>. Whether you a
146- submitting code changes or you are reviewing code changes, it all happens in
147- code. Both contributor and owner are simply creating new commits and generating
148- patches on top of each other. This obviates the need to have a web viewer where
149- the reviewing can "comment" on a line of code block. There's no need, apply the
150- contributor's patches, write comments or code changes, generate a new patch,
151- send the patch to the git server as a "review." This flow also works the exact
152- same if two users are collaborating on a set of changes.
153- </p>
154-
155- <p>
156- This also solves the problem of sending multiple patchsets for the same code
157- change. There's a single, central Patch Request where all changes and
158- collaboration happens.
159- </p>
160-
161- <p>
162- We could figure out a way to leverage <code>git notes</code> for reviews / comments, but
163- honestly, that solution feels brutal and outside the comfort level of most git
164- users. Just send reviews as code and write comments in the programming language
165- you are using. It's the job of the contributor to "address" those comments and
166- then remove them in subsequent patches. This is the forcing function to address
167- all comments: the patch won't be merged if there are comment unaddressed in
168- code; they cannot be ignored or else they will be upstreamed erroneously.
169- </p>
170- </div>
171- </details>
172-
173- <details>
174- <summary>How do Patch Requests work?</summary>
175 <div>
176- Patch requests (PR) are the simplest way to submit, review, and accept changes to your git repository.
177- Here's how it works:
178+ The target project doesn't need to run patchbin for someone to
179+ submit a patch request against it. It works like a pull request,
180+ except both sides collaborate by sending rounds of patchsets: a
181+ contributor sends patches, a reviewer replies with their own patches
182+ on top, back and forth, as commits rather than comments. The result
183+ is a collaborative workspace built entirely out of patches.
184+ Reviewing means pulling the code down, not clicking through a diff
185+ viewer. Issues work the same way: an issue is just a patch request
186+ without any code attached yet, and anyone can follow up with a real
187+ patch request on top of it.
188 </div>
189
190- <ol>
191- <li>External contributor clones repo (<code>git-clone</code>)</li>
192- <li>External contributor makes a code change (<code>git-add</code> & <code>git-commit</code>)</li>
193- <li>External contributor generates patches (<code>git-format-patch</code>)</li>
194- <li>External contributor submits a PR to SSH server</li>
195- <li>Owner receives RSS notification that there's a new PR</li>
196- <li>Owner applies patches locally (<code>git-am</code>) from SSH server</li>
197- <li>Owner makes suggestions in code! (<code>git-add</code> & <code>git-commit</code>)</li>
198- <li>Owner submits review by piping patch to SSH server (<code>git-format-patch</code>)</li>
199- <li>External contributor receives RSS notification of the PR review</li>
200- <li>External contributor re-applies patches (<code>git-am</code>)</li>
201- <li>External contributor reviews and removes comments in code!</li>
202- <li>External contributor submits another patch (<code>git-format-patch</code>)</li>
203- <li>Owner applies patches locally (<code>git-am</code>)</li>
204- <li>Owner marks PR as accepted and pushes code to main (<code>git-push</code>)</li>
205- </ol>
206-
207- <div>Example commands</div>
208-
209- <pre># Owner hosts repo `test.git` using github
210-
211-# Contributor clones repo
212-git clone git@github.com:picosh/test.git
213-
214-# Contributor wants to make a change
215-# Contributor makes changes via commits
216-git add -A && git commit -m "fix: some bugs"
217-
218-# Contributor runs:
219-git format-patch origin/main --stdout | ssh {{.MetaData.URL}} pr create test
220-# > Patch Request has been created (ID: 1)
221-
222-# Owner can checkout patch:
223-ssh {{.MetaData.URL}} pr print 1 | git am -3
224-
225-# Owner can comment (IN CODE), commit, then send another format-patch
226-# on top of the PR:
227-git format-patch origin/main --stdout | ssh {{.MetaData.URL}} pr add --review 1
228-# UI clearly marks patch as a review
229+ <div>
230+ There's no accept or reject step. A patch request is simply active
231+ or inactive: active ones go inactive after 30 days without activity.
232+ When a reviewer is happy with the code, they pull it, merge it, and
233+ push upstream themselves; there's nothing to manage here beyond that.
234+ </div>
235
236-# Contributor can checkout reviews
237-ssh {{.MetaData.URL}} print pr-1 | git am -3
238+ <div class="group">
239+ <h2>Quickstart</h2>
240
241-# Owner can reject a pr:
242-ssh {{.MetaData.URL}} pr close 1
243+ <div>
244+ Submit a patch request (starts as a draft, visible only to you):
245+ </div>
246+ <pre class="m-0">git format-patch main --stdout | ssh {{.MetaData.URL}} pr create {repo}</pre>
247
248-# Owner can accept a pr:
249-ssh {{.MetaData.URL}} pr accept 1
250+ <div>Open it so others can see it (also enables RSS notifications):</div>
251+ <pre class="m-0">ssh {{.MetaData.URL}} pr open {prID}</pre>
252
253-# Owner can prep PR for upstream:
254-git rebase -i origin/main
255+ <div>Checkout the latest patchset from a patch request:</div>
256+ <pre class="m-0">ssh {{.MetaData.URL}} print pr-{prID} | git am -3</pre>
257
258-# Then push to upstream
259-git push origin main
260+ <div>Add a follow-up patchset (e.g. after addressing review comments):</div>
261+ <pre class="m-0">git format-patch main --stdout | ssh {{.MetaData.URL}} pr add {prID}</pre>
262
263-# Done!
264-</pre>
265- </details>
266+ <div>Help guide:</div>
267+ <pre class="m-0">ssh {{.MetaData.URL}} help</pre>
268+ </div>
269
270- <details>
271- <summary>First time user?</summary>
272+ <div class="group">
273+ <h2>Commands</h2>
274+
275+ <details class="details-min">
276+ <summary class="font-bold">pr - manage patch requests</summary>
277+ <div class="group my">
278+ <div>
279+ <code>pr create {repo}</code> - submit a new PR from stdin (starts as draft)
280+ <pre class="m-0">git format-patch main --stdout | ssh {{.MetaData.URL}} pr create {repo}</pre>
281+ </div>
282+ <div>
283+ <code>pr add {prID}</code> - add a new patchset to an existing PR from stdin
284+ <pre class="m-0">git format-patch main --stdout | ssh {{.MetaData.URL}} pr add {prID}</pre>
285+ </div>
286+ <div>
287+ <code>pr open {prID} [--comment]</code> - transition draft → open, enables RSS notifications
288+ <pre class="m-0">ssh {{.MetaData.URL}} pr open {prID}</pre>
289+ </div>
290+ <div>
291+ <code>pr draft {prID} [--comment]</code> - transition open → draft, disables RSS notifications
292+ <pre class="m-0">ssh {{.MetaData.URL}} pr draft {prID}</pre>
293+ </div>
294+ <div>
295+ <code>pr edit {prID} {title}</code> - rename a PR
296+ <pre class="m-0">ssh {{.MetaData.URL}} pr edit {prID} "new title"</pre>
297+ </div>
298+ <div>
299+ <code>pr summary {prID}</code> - show metadata, patchsets, and patches for a PR
300+ <pre class="m-0">ssh {{.MetaData.URL}} pr summary {prID}</pre>
301+ </div>
302+ <div>
303+ <code>pr ls [repo] [--draft|--open|--active|--inactive|--mine]</code> - list PRs
304+ <pre class="m-0">ssh {{.MetaData.URL}} pr ls {repo} --open</pre>
305+ </div>
306+ </div>
307+ </details>
308+
309+ <details class="details-min">
310+ <summary class="font-bold">issue - text-only patch requests</summary>
311+ <div class="group my">
312+ <div>
313+ <code>issue create {repo} [--title]</code> - submit a new issue from stdin (starts as open)
314+ <pre class="m-0">echo "steps to reproduce..." | ssh {{.MetaData.URL}} issue create {repo} --title "bug: crash on startup"</pre>
315+ </div>
316+ </div>
317+ </details>
318+
319+ <details class="details-min">
320+ <summary class="font-bold">ps - manage patchsets</summary>
321+ <div class="group my">
322+ <div>
323+ <code>ps rm {patchsetID}</code> - remove a patchset and its patches (creator only)
324+ <pre class="m-0">ssh {{.MetaData.URL}} ps rm ps-{patchsetID}</pre>
325+ </div>
326+ </div>
327+ </details>
328+
329+ <details class="details-min">
330+ <summary class="font-bold">print - print patches for checkout</summary>
331+ <div class="group my">
332+ <div>
333+ <code>print pr-{prID}</code> - print the latest patchset for a PR
334+ <pre class="m-0">ssh {{.MetaData.URL}} print pr-{prID} | git am -3</pre>
335+ </div>
336+ <div>
337+ <code>print ps-{patchsetID}</code> - print a specific patchset
338+ <pre class="m-0">ssh {{.MetaData.URL}} print ps-{patchsetID} | git am -3</pre>
339+ </div>
340+ </div>
341+ </details>
342+
343+ <details class="details-min">
344+ <summary class="font-bold">logs - event history</summary>
345+ <div class="group my">
346+ <div>
347+ <code>logs [--pr ID] [--pubkey]</code> - list event logs, optionally filtered to a PR or your own activity
348+ <pre class="m-0">ssh {{.MetaData.URL}} logs --pr {prID}</pre>
349+ </div>
350+ </div>
351+ </details>
352+ </div>
353
354- <div>
355- Using this service for the first time? First you need to create an account:
356+ <div class="text-sm">
357+ <a href="https://github.com/picosh/patchbin">Self-host your own patchbin</a>
358+ </div>
359 </div>
360-
361- <blockquote>{username} is the name of your account</blockquote>
362-
363- <pre>ssh {username}@{{.MetaData.URL}} register</pre>
364-
365- <div>After that, creating a patch request is simple:</div>
366-
367- <pre>git format-patch main --stdout | ssh {{.MetaData.URL}} pr create {repo}</pre>
368-
369- <div>Want to submit a v2 of the patch request?</div>
370-
371- <pre>git format-patch main --stdout | ssh {{.MetaData.URL}} pr add {prID}</pre>
372- </details>
373-</header>
374-
375-<main>
376- <div>
377- filter
378- <a href="/">open</a> <code>{{.NumOpen}}</code>
379- ·
380- <a href="/?status=accepted">accepted</a> <code>{{.NumAccepted}}</code>
381- ·
382- <a href="/?status=closed">closed</a> <code>{{.NumClosed}}</code>
383 </div>
384- {{template "pr-table" .Prs}}
385 </main>
386-
387-<footer class="mt">
388- <a href="/rss">rss</a>
389-</footer>
390 {{end}}
+133,
-105
1@@ -1,105 +1,133 @@
2-{{template "base" .}}
3-
4-{{define "title"}}{{.Pr.Title}} - pr summary{{end}}
5-
6-{{define "meta"}}
7-<link rel="alternate" type="application/atom+xml"
8- title="RSS feed for git collaboration server"
9- href="/prs/{{.Pr.ID}}/rss" />
10-<meta property="og:title" content="{{.Pr.Title}}" />
11-<meta property="og:url" content="https://{{.MetaData.URL}}/prs/{{.Pr.ID}}" />
12-<meta property="og:type" content="object" />
13-<meta property="og:site_name" content="{{.MetaData.URL}}" />
14-{{end}}
15-
16-{{define "body"}}
17-{{template "pr-header" .}}
18-
19-<main class="group">
20- {{template "pr-tabs" .}}
21-
22- {{if eq .Tab "timeline"}}
23- <div class="group timeline">
24- {{range .Logs}}
25- <div class="timeline-item{{if eq .Event "pr_status_changed"}} timeline-status-{{.Data.Status}}{{end}}">
26- {{if eq .Event "pr_patchset_added"}}
27- <details class="mb" style="border: 0; padding: 0;">
28- <summary>
29- {{template "user-pill" .UserData}}
30- <span class="font-bold">added <a href="/ps/{{.Patchset.ID}}"><code>{{.FormattedPatchsetID}}</code></a></span>
31- <span>(<code><a href="/rd/{{.Patchset.ID}}">range-diff</a></code>)</span>
32- <span>on <date>{{.Date}}</date></span>
33- </summary>
34-
35- <div class="group">
36- {{- range .RangeDiff -}}
37- <div>
38- <code class='{{if eq .Type "rm"}}pill-admin{{else if eq .Type "add"}}pill-success{{else if eq .Type "diff"}}pill-review{{end}}'>
39- {{.Header}}
40- </code>
41- </div>
42- {{- end -}}
43- </div>
44- </details>
45- {{else if eq .Event "pr_reviewed"}}
46- <details class="mb" style="border: 0; padding: 0;">
47- <summary>
48- {{template "user-pill" .UserData}}
49- <span class="font-bold">reviewed pr with <a href="/ps/{{.Patchset.ID}}"><code class="pill-review">{{.FormattedPatchsetID}}</code></a></span>
50- <span>(<code><a href="/rd/{{.Patchset.ID}}">range-diff</a></code>)</span>
51- <span>on <date>{{.Date}}</date></span>
52- </summary>
53-
54- <div class="group">
55- {{- range .RangeDiff -}}
56- <div>
57- <code class='{{if eq .Type "rm"}}pill-admin{{else if eq .Type "add"}}pill-success{{else if eq .Type "diff"}}pill-review{{end}}'>
58- {{.Header}}
59- </code>
60- </div>
61- {{- end -}}
62- </div>
63- </details>
64- {{else if eq .Event "pr_status_changed"}}
65- <div>
66- {{template "user-pill" .UserData}}
67- <span class="font-bold">changed status to
68- <code class="pill-status-{{.Data.Status}}">{{.Data.Status}}</code>
69- </span>
70- <span>on <date>{{.Date}}</date></span>
71- </div>
72-
73- {{if .Data.Comment}}
74- <div class="status-change-comment">{{.Data.Comment}}</div>
75- {{end}}
76- {{else}}
77- <div>
78- {{template "user-pill" .UserData}}
79- <span class="font-bold">
80- {{if eq .Event "pr_created"}}
81- created pr with <a href="/ps/{{.Patchset.ID}}"><code>{{.FormattedPatchsetID}}</code></a>
82- {{else if eq .Event "pr_patchset_deleted"}}
83- deleted <code>{{.FormattedPatchsetID}}</code>
84- {{else if eq .Event "pr_patchset_replaced"}}
85- replaced <code>{{.FormattedPatchsetID}}</code>
86- {{else if eq .Event "pr_name_changed"}}
87- changed pr name to <code>{{.Data.Name}}</code>
88- {{else}}
89- {{.Event}}
90- {{end}}
91- </span>
92- <span>on <date>{{.Date}}</date></span>
93- </div>
94- {{end}}
95- </div>
96- {{end}}
97- </div>
98- {{else}}
99- {{if .IsRangeDiff}}
100- {{template "range-diff" .}}
101- {{else}}
102- {{template "patchset" .}}
103- {{end}}
104- {{end}}
105-</main>
106-{{end}}
107+
108{{template "base" .}}
109+
110+{{define "title"}}{{.Pr.Title}} - pr summary{{end}}
111+
112+{{define "meta"}}
113+<link rel="alternate" type="application/atom+xml"
114+ title="RSS feed for git collaboration server"
115+ href="/prs/{{.Pr.ID}}/rss" />
116+<meta property="og:title" content="{{.Pr.Title}}" />
117+<meta property="og:url" content="https://{{.MetaData.URL}}/prs/{{.Pr.ID}}" />
118+<meta property="og:type" content="object" />
119+<meta property="og:site_name" content="{{.MetaData.URL}}" />
120+{{end}}
121+
122+{{define "body"}}
123+<main class="group">
124+ <div class="flex justify-center items-center">
125+ <div class="box container w-full">
126+ <h1 class="text-xl">{{.RepoName}}</h1>
127+
128+ <div class="flex items-center gap border-visited flex-wrap">
129+ <a href="/prs/{{.Pr.ID}}">{{.Pr.Title}}</a>
130+ {{if eq .Pr.Status "draft"}}
131+ <code class="pill-info">{{.Pr.Status}}</code>
132+ {{else if eq .Pr.Status "open"}}
133+ <code class="pill-success">{{.Pr.Status}}</code>
134+ {{else}}
135+ <code>{{.Pr.Status}}</code>
136+ {{end}}
137+ <a class="btn-nav flex items-center" href="/prs/{{.Pr.ID}}/rss" style="padding: 0.025rem 0.3rem;">
138+ rss
139+ </a>
140+ </div>
141+
142+ <div class="group timeline text-sm my">
143+ {{range .Logs}}
144+ <div class="timeline-item{{if eq .Event "pr_status_changed"}} timeline-status-{{.Data.Status}}{{end}}">
145+ {{if eq .Event "pr_patchset_added"}}
146+ <details class="mb" style="border: 0; padding: 0;">
147+ <summary>
148+ <span class="font-bold">added <a href="/ps/{{.Patchset.ID}}"><code{{if eq .Patchset.ID $.Patchset.ID}} class="border-visited"{{end}}>{{.FormattedPatchsetID}}</code></a></span>
149+ <span class="event-meta">on <date>{{.Date}}</date> · by {{template "user-pill" .UserData}}</span>
150+ </summary>
151+
152+ <div class="group">
153+ {{- range .RangeDiff -}}
154+ <div>
155+ <code class='{{if eq .Type "rm"}}pill-admin{{else if eq .Type "add"}}pill-success{{else if eq .Type "changed"}}pill-info{{end}}'>
156+ {{.Header}}
157+ </code>
158+ </div>
159+ {{- end -}}
160+ </div>
161+ </details>
162+
163+ {{else if eq .Event "pr_status_changed"}}
164+ <div>
165+ <span class="font-bold">changed status to
166+ <code class="pill-status-{{.Data.Status}}">{{.Data.Status}}</code>
167+ </span>
168+ <span class="event-meta">on <date>{{.Date}}</date> · by {{template "user-pill" .UserData}}</span>
169+ </div>
170+
171+ {{if .Data.Comment}}
172+ <div class="status-change-comment">{{.Data.Comment}}</div>
173+ {{end}}
174+
175+ {{else}}
176+ <div>
177+ <span class="font-bold">
178+ {{if eq .Event "pr_created"}}
179+ created pr with <a href="/ps/{{.Patchset.ID}}"><code{{if eq .Patchset.ID $.Patchset.ID}} class="border-visited"{{end}}>{{.FormattedPatchsetID}}</code></a>
180+ {{else if eq .Event "pr_patchset_deleted"}}
181+ deleted <code>{{.FormattedPatchsetID}}</code>
182+ {{else if eq .Event "pr_patchset_replaced"}}
183+ replaced <code>{{.FormattedPatchsetID}}</code>
184+ {{else if eq .Event "pr_name_changed"}}
185+ changed pr name to <code>{{.Data.Name}}</code>
186+ {{else}}
187+ {{.Event}}
188+ {{end}}
189+ </span>
190+ <span class="event-meta">on <date>{{.Date}}</date> · by {{template "user-pill" .UserData}}</span>
191+ </div>
192+ {{end}}
193+ </div>
194+ {{end}}
195+ </div>
196+
197+ <details class="text-sm">
198+ <summary>cmds</summary>
199+ <div class="group my">
200+ checkout latest patchset:
201+ <pre class="m-0">ssh {{.MetaData.URL}} print pr-{{.Pr.ID}} | git am -3</pre>
202+
203+ checkout any patchset in a patch request:
204+ <pre class="m-0">ssh {{.MetaData.URL}} print ps-X | git am -3</pre>
205+
206+ add changes to patch request:
207+ <pre class="m-0">git format-patch {{.Branch}} --stdout | ssh {{.MetaData.URL}} pr add {{.Pr.ID}}</pre>
208+
209+ set PR to open (enables RSS notifications):
210+ <pre class="m-0">ssh {{.MetaData.URL}} pr open {{.Pr.ID}}</pre>
211+
212+ set PR to draft (stops RSS notifications):
213+ <pre class="m-0">ssh {{.MetaData.URL}} pr draft {{.Pr.ID}}</pre>
214+ </div>
215+ </details>
216+ </div>
217+ </div>
218+
219+ <div class="flex justify-center items-center">
220+ <div class="patchset-split">
221+ <h3 class="text-md mb">
222+ Patchset
223+ <a href="/ps/{{.Patchset.ID}}"><code class="border-visited">{{.FormattedPatchsetID}}</code></a>
224+ <span class="event-meta">on <date>{{.PatchsetDate}}</date> · commit {{sha .Patch.CommitSha}}</span>
225+ </h3>
226+
227+ <div class="flex-collapse flex gap">
228+ <div class="w-full" style="flex: 1 1 50%; min-width: 0;">
229+ {{template "commit-list" .}}
230+ </div>
231+ <div class="w-full" style="flex: 1 1 50%; min-width: 0;">
232+ {{template "patch-detail" .}}
233+ </div>
234+ </div>
235+ </div>
236+ </div>
237+
238+ {{template "patch-files" .}}
239+</main>
240+{{end}}
+32,
-0
1@@ -0,0 +1,32 @@
2+{{template "base" .}}
3+
4+{{define "title"}}PRs - patchbin{{end}}
5+
6+{{define "meta"}}
7+<link rel="alternate" type="application/atom+xml"
8+ title="RSS feed for git collaboration server"
9+ href="/rss" />
10+{{end}}
11+
12+{{define "body"}}
13+<main class="group">
14+ <div class="box-sm">
15+ {{range .PRs}}
16+ <div class="flex items-center justify-between gap py border-b">
17+ <div class="flex items-center gap-2 flex-1" style="min-width: 0;">
18+ <span class="pr-repo-col truncate mono text-sm" title="{{.RepoName}}">[{{.RepoName}}]</span>
19+ <a href="/prs/{{.ID}}" class="flex-1 truncate">{{if .Name}}{{.Name}}{{else}}<em>(no title)</em>{{end}}</a>
20+ {{if gt .NumPatchsets 1}}<code class="text-sm">{{.NumPatchsets}}</code>{{end}}
21+ </div>
22+ <div class="flex items-center gap mono text-sm">
23+ <code class="text-sm">#{{.ID}}</code>
24+ <span>{{.FormattedDate}}</span>
25+ <a href="/prs/{{.ID}}/rss">rss</a>
26+ </div>
27+ </div>
28+ {{else}}
29+ <p>No {{.Tab}} patch requests found.</p>
30+ {{end}}
31+ </div>
32+</main>
33+{{end}}
M
util.go
+9,
-12
1@@ -1,4 +1,4 @@
2-package git
3+package patchbin
4
5 import (
6 "crypto/sha256"
7@@ -6,7 +6,6 @@ import (
8 "encoding/hex"
9 "fmt"
10 "io"
11- "math/rand"
12 "regexp"
13 "strconv"
14 "strings"
15@@ -17,21 +16,11 @@ import (
16
17 var (
18 baseCommitRe = regexp.MustCompile(`base-commit: (.+)\s*`)
19- letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
20 startOfPatch = "From "
21 patchsetPrefix = "ps-"
22 prPrefix = "pr-"
23 )
24
25-// https://stackoverflow.com/a/22892986
26-func randSeq(n int) string {
27- b := make([]rune, n)
28- for i := range b {
29- b[i] = letters[rand.Intn(len(letters))]
30- }
31- return strings.ToLower(string(b))
32-}
33-
34 func truncateSha(sha string) string {
35 if len(sha) < 7 {
36 return sha
37@@ -125,6 +114,14 @@ func ParsePatchset(patchset io.Reader) ([]*Patch, error) {
38 return nil, err
39 }
40
41+ if strings.TrimSpace(buf.String()) == "" {
42+ return nil, fmt.Errorf("patchset is empty")
43+ }
44+
45+ if !strings.HasPrefix(buf.String(), startOfPatch) {
46+ return nil, fmt.Errorf("unrecognized patchset: must start with %q", startOfPatch)
47+ }
48+
49 patchesRaw := splitPatchSet(buf.String())
50 for idx, patchRaw := range patchesRaw {
51 patchStr := patchRaw
+26,
-9
1@@ -7,13 +7,14 @@ import (
2 "io"
3 "os"
4 "path/filepath"
5+ "regexp"
6 "strings"
7
8 "golang.org/x/crypto/ssh"
9 )
10
11 func CreateTmpDir() string {
12- tmp, err := os.MkdirTemp(os.TempDir(), "git-pr*")
13+ tmp, err := os.MkdirTemp(os.TempDir(), "patchbin*")
14 if err != nil {
15 panic(err)
16 }
17@@ -21,7 +22,7 @@ func CreateTmpDir() string {
18 }
19
20 func CreateCfgFile(dataDir, cfgTmpl string, adminKey UserSSH) string {
21- cfgPath := filepath.Join(dataDir, "git-pr.toml")
22+ cfgPath := filepath.Join(dataDir, "patchbin.toml")
23 cfgFi, err := os.Create(cfgPath)
24 if err != nil {
25 panic(err)
26@@ -93,6 +94,11 @@ func (s UserSSH) Cmd(patch []byte, cmd string) (string, error) {
27 return "", err
28 }
29
30+ stderrPipe, err := session.StderrPipe()
31+ if err != nil {
32+ return "", err
33+ }
34+
35 if err := session.Start(cmd); err != nil {
36 return "", err
37 }
38@@ -106,17 +112,28 @@ func (s UserSSH) Cmd(patch []byte, cmd string) (string, error) {
39
40 _ = stdinPipe.Close()
41
42- if err := session.Wait(); err != nil {
43- return "", err
44- }
45+ var stdoutBuf, stderrBuf strings.Builder
46+ go func() { _, _ = io.Copy(&stderrBuf, stderrPipe) }()
47+ _, _ = io.Copy(&stdoutBuf, stdoutPipe)
48
49- buf := new(strings.Builder)
50- _, err = io.Copy(buf, stdoutPipe)
51+ err = session.Wait()
52+ stderr := stderrBuf.String()
53 if err != nil {
54- return "", err
55+ return "", fmt.Errorf("ssh command failed: %w (stderr: %s)", err, stderr)
56 }
57
58- return buf.String(), nil
59+ return stdoutBuf.String(), nil
60+}
61+
62+// ParsePRID extracts the PR ID from the output of `pr create`.
63+// Looks for the URL line: "URL: https://host/prs/123"
64+func ParsePRID(output string) string {
65+ re := regexp.MustCompile(`/prs/(\d+)`)
66+ matches := re.FindStringSubmatch(output)
67+ if len(matches) < 2 {
68+ return "1" // fallback
69+ }
70+ return matches[1]
71 }
72
73 func GenerateKeys() (UserSSH, UserSSH) {
+23,
-1
1@@ -1,9 +1,10 @@
2-package git
3+package patchbin
4
5 import (
6 "fmt"
7 "io"
8 "os"
9+ "strings"
10 "testing"
11 )
12
13@@ -35,6 +36,27 @@ func TestParsePatchsetWithCover(t *testing.T) {
14 }
15 }
16
17+func TestParsePatchsetEmptyInput(t *testing.T) {
18+ _, err := ParsePatchset(strings.NewReader(""))
19+ if err == nil {
20+ t.Fatal("expected error for empty patchset input, got nil")
21+ }
22+}
23+
24+func TestParsePatchsetWhitespaceOnlyInput(t *testing.T) {
25+ _, err := ParsePatchset(strings.NewReader(" \n\n\t\n"))
26+ if err == nil {
27+ t.Fatal("expected error for whitespace-only patchset input, got nil")
28+ }
29+}
30+
31+func TestParsePatchsetGarbageInput(t *testing.T) {
32+ _, err := ParsePatchset(strings.NewReader("this is not a patch\njust some random text\n"))
33+ if err == nil {
34+ t.Fatal("expected error for garbage patchset input, got nil")
35+ }
36+}
37+
38 func TestPatchToDiff(t *testing.T) {
39 file, err := os.Open("fixtures/single.patch")
40 defer func() {
+1,
-1
1@@ -1,3 +1,3 @@
2-package git
3+package patchbin
4
5 var GITPR_VERSION = "2026.02.25"
M
web.go
+100,
-886
1@@ -1,4 +1,4 @@
2-package git
3+package patchbin
4
5 import (
6 "bytes"
7@@ -11,32 +11,62 @@ import (
8 "log/slog"
9 "mime"
10 "net/http"
11- "net/url"
12 "os"
13 "path/filepath"
14- "slices"
15- "strconv"
16- "strings"
17 "time"
18
19 "github.com/alecthomas/chroma/v2"
20 formatterHtml "github.com/alecthomas/chroma/v2/formatters/html"
21 "github.com/alecthomas/chroma/v2/lexers"
22 "github.com/alecthomas/chroma/v2/styles"
23- "github.com/bluekeyes/go-gitdiff/gitdiff"
24 "github.com/gorilla/feeds"
25 )
26
27+//go:embed static/*
28+var embedStaticFS embed.FS
29+
30 var (
31 //go:embed tmpl/*
32- tmplFS embed.FS
33- indexTmpl = getTemplate("index.html")
34- prTmpl = getTemplate("pr.html")
35- userTmpl = getTemplate("user.html")
36- repoTmpl = getTemplate("repo.html")
37- toolTmpl = getTemplate("tool.html")
38+ tmplFS embed.FS
39+ indexTmpl = getTemplate("index.html")
40+ prTmpl = getTemplate("pr.html")
41+ prsListTmpl = getTemplate("prs.html")
42 )
43
44+type BasicData struct {
45+ MetaData
46+}
47+
48+type MetaData struct {
49+ URL string
50+ Desc template.HTML
51+ Tab TabStatus
52+}
53+
54+type PrListItem struct {
55+ ID int64
56+ Name string
57+ RepoName string
58+ Status Status
59+ FormattedDate string
60+ NumPatchsets int
61+}
62+
63+type PrListData struct {
64+ PRs []PrListItem
65+ MetaData
66+}
67+
68+type WebCtx struct {
69+ Pr *PrCmd
70+ Backend *Backend
71+ Formatter *formatterHtml.Formatter
72+ Logger *slog.Logger
73+ Theme *chroma.Style
74+}
75+
76+type ctxWeb struct{}
77+
78 func getTemplate(page string) *template.Template {
79 tmpl, err := template.New("").Funcs(template.FuncMap{
80 "sha": shaFn,
81@@ -52,19 +82,6 @@ func getTemplate(page string) *template.Template {
82 return tmpl.Lookup(page)
83 }
84
85-//go:embed static/*
86-var embedStaticFS embed.FS
87-
88-type WebCtx struct {
89- Pr *PrCmd
90- Backend *Backend
91- Formatter *formatterHtml.Formatter
92- Logger *slog.Logger
93- Theme *chroma.Style
94-}
95-
96-type ctxWeb struct{}
97-
98 func getWebCtx(r *http.Request) (*WebCtx, error) {
99 data, ok := r.Context().Value(ctxWeb{}).(*WebCtx)
100 if data == nil || !ok {
101@@ -98,196 +115,6 @@ func ctxMdw(ctx context.Context, handler http.HandlerFunc) http.HandlerFunc {
102 }
103 }
104
105-func shaFn(sha string) string {
106- if sha == "" {
107- return "(none)"
108- }
109- return truncateSha(sha)
110-}
111-
112-type LinkData struct {
113- Url template.URL
114- Text string
115-}
116-
117-type BasicData struct {
118- MetaData
119-}
120-
121-type PrTableData struct {
122- Prs []*PrListData
123- NumOpen int
124- NumAccepted int
125- NumClosed int
126- MetaData
127-}
128-
129-type UserDetailData struct {
130- Prs []*PrListData
131- UserData UserData
132- NumOpen int
133- NumAccepted int
134- NumClosed int
135- MetaData
136-}
137-
138-type RepoDetailData struct {
139- Name string
140- UserID int64
141- Username string
142- Branch string
143- Prs []*PrListData
144- NumOpen int
145- NumAccepted int
146- NumClosed int
147- MetaData
148-}
149-
150-func createPrDataSorter(sort, sortDir string) func(a, b *PrListData) int {
151- return func(a *PrListData, b *PrListData) int {
152- if sort == "status" {
153- statusA := strings.ToLower(string(a.Status))
154- statusB := strings.ToLower(string(b.Status))
155- if sortDir == "asc" {
156- return strings.Compare(statusA, statusB)
157- } else {
158- return strings.Compare(statusB, statusA)
159- }
160- }
161-
162- if sort == "title" {
163- titleA := strings.ToLower(a.Title)
164- titleB := strings.ToLower(b.Title)
165- if sortDir == "asc" {
166- return strings.Compare(titleA, titleB)
167- } else {
168- return strings.Compare(titleB, titleA)
169- }
170- }
171-
172- if sort == "repo" {
173- repoA := strings.ToLower(a.RepoNs)
174- repoB := strings.ToLower(b.RepoNs)
175- if sortDir == "asc" {
176- return strings.Compare(repoA, repoB)
177- } else {
178- return strings.Compare(repoB, repoA)
179- }
180- }
181-
182- if sort == "created_at" {
183- if sortDir == "asc" {
184- return a.DateOrig.Compare(b.DateOrig)
185- } else {
186- return b.DateOrig.Compare(a.DateOrig)
187- }
188- }
189-
190- if sortDir == "desc" {
191- return int(b.ID) - int(a.ID)
192- }
193- return int(a.ID) - int(b.ID)
194- }
195-}
196-
197-func getPrTableData(web *WebCtx, prs []*PatchRequest, query url.Values) ([]*PrListData, error) {
198- prdata := []*PrListData{}
199- status := Status(strings.ToLower(query.Get("status")))
200- if status == "" {
201- status = StatusOpen
202- }
203- username := strings.ToLower(query.Get("user"))
204- title := strings.ToLower(query.Get("title"))
205- sort := strings.ToLower(query.Get("sort"))
206- sortDir := strings.ToLower(query.Get("sort_dir"))
207- hasFilter := status != "" || username != "" || title != ""
208-
209- for _, curpr := range prs {
210- user, err := web.Pr.GetUserByID(curpr.UserID)
211- if err != nil {
212- web.Logger.Error("cannot get user from pr", "err", err)
213- continue
214- }
215- pk, err := web.Backend.PubkeyToPublicKey(user.Pubkey)
216- if err != nil {
217- web.Logger.Error("cannot get pubkey from user public key", "err", err)
218- continue
219- }
220-
221- repo, err := web.Pr.GetRepoByID(curpr.RepoID)
222- if err != nil {
223- web.Logger.Error("cannot get repo", "err", err)
224- continue
225- }
226-
227- repoUser, err := web.Pr.GetUserByID(repo.UserID)
228- if err != nil {
229- web.Logger.Error("cannot get repo user", "err", err)
230- continue
231- }
232-
233- ps, err := web.Pr.GetPatchsetsByPrID(curpr.ID)
234- if err != nil {
235- web.Logger.Error("cannot get patchsets for pr", "err", err)
236- continue
237- }
238-
239- if hasFilter {
240- if status != "" {
241- if status != curpr.Status {
242- continue
243- }
244- }
245-
246- if username != "" {
247- if username != strings.ToLower(user.Name) {
248- continue
249- }
250- }
251-
252- if title != "" {
253- if !strings.Contains(strings.ToLower(curpr.Name), title) {
254- continue
255- }
256- }
257- }
258-
259- isAdmin := web.Backend.IsAdmin(pk)
260- repoNs := web.Backend.CreateRepoNs(repoUser.Name, repo.Name)
261- prls := &PrListData{
262- RepoNs: repoNs,
263- ID: curpr.ID,
264- UserData: UserData{
265- Name: user.Name,
266- IsAdmin: isAdmin,
267- Pubkey: user.Pubkey,
268- },
269- RepoLink: LinkData{
270- Url: template.URL(fmt.Sprintf("/r/%s/%s", repoUser.Name, repo.Name)),
271- Text: repoNs,
272- },
273- PrLink: LinkData{
274- Url: template.URL(fmt.Sprintf("/prs/%d", curpr.ID)),
275- Text: curpr.Name,
276- },
277- NumPatchsets: len(ps),
278- DateOrig: curpr.CreatedAt,
279- Date: curpr.CreatedAt.Format(web.Backend.Cfg.TimeFormat),
280- Status: curpr.Status,
281- }
282- prdata = append(prdata, prls)
283- }
284-
285- if sort != "" {
286- if sortDir == "" {
287- sortDir = "asc"
288- }
289- slices.SortFunc(prdata, createPrDataSorter(sort, sortDir))
290- }
291-
292- return prdata, nil
293-}
294-
295 func indexHandler(w http.ResponseWriter, r *http.Request) {
296 web, err := getWebCtx(r)
297 if err != nil {
298@@ -295,40 +122,8 @@ func indexHandler(w http.ResponseWriter, r *http.Request) {
299 return
300 }
301
302- prs, err := web.Pr.GetPatchRequests()
303- if err != nil {
304- web.Logger.Error("could not get prs", "err", err)
305- w.WriteHeader(http.StatusInternalServerError)
306- return
307- }
308-
309- prdata, err := getPrTableData(web, prs, r.URL.Query())
310- if err != nil {
311- web.Logger.Error("could not get pr table data", "err", err)
312- w.WriteHeader(http.StatusInternalServerError)
313- return
314- }
315-
316- numOpen := 0
317- numAccepted := 0
318- numClosed := 0
319- for _, pr := range prs {
320- switch pr.Status {
321- case "open":
322- numOpen += 1
323- case "accepted":
324- numAccepted += 1
325- case "closed":
326- numClosed += 1
327- }
328- }
329-
330 w.Header().Set("content-type", "text/html")
331- err = indexTmpl.Execute(w, PrTableData{
332- NumOpen: numOpen,
333- NumAccepted: numAccepted,
334- NumClosed: numClosed,
335- Prs: prdata,
336+ err = indexTmpl.Execute(w, BasicData{
337 MetaData: MetaData{
338 URL: web.Backend.Cfg.Url,
339 Desc: template.HTML(web.Backend.Cfg.Desc),
340@@ -339,556 +134,62 @@ func indexHandler(w http.ResponseWriter, r *http.Request) {
341 }
342 }
343
344-type UserData struct {
345- UserID int64
346- Name string
347- IsAdmin bool
348- Pubkey string
349- CreatedAt string
350-}
351-
352-type MetaData struct {
353- URL string
354- Desc template.HTML
355-}
356-
357-type PrListData struct {
358- UserData
359- RepoNs string
360- RepoLink LinkData
361- PrLink LinkData
362- Title string
363- NumPatchsets int
364- ID int64
365- DateOrig time.Time
366- Date string
367- Status Status
368-}
369-
370-func userDetailHandler(w http.ResponseWriter, r *http.Request) {
371- userName := r.PathValue("user")
372-
373- web, err := getWebCtx(r)
374- if err != nil {
375- web.Logger.Error("fetch web", "err", err)
376- w.WriteHeader(http.StatusInternalServerError)
377- return
378- }
379-
380- user, err := web.Pr.GetUserByName(userName)
381- if err != nil {
382- web.Logger.Error("cannot find user by name", "err", err, "name", userName)
383- w.WriteHeader(http.StatusNotFound)
384- return
385- }
386-
387- pk, err := web.Backend.PubkeyToPublicKey(user.Pubkey)
388- if err != nil {
389- web.Logger.Error("cannot parse pubkey for pr user", "err", err)
390- w.WriteHeader(http.StatusUnprocessableEntity)
391- return
392- }
393- isAdmin := web.Backend.IsAdmin(pk)
394-
395- prs, err := web.Pr.GetPatchRequestsByPubkey(user.Pubkey)
396- if err != nil {
397- web.Logger.Error("cannot get prs", "err", err)
398- w.WriteHeader(http.StatusInternalServerError)
399- return
400- }
401-
402- query := r.URL.Query()
403- query.Set("user", userName)
404-
405- prdata, err := getPrTableData(web, prs, query)
406- if err != nil {
407- web.Logger.Error("cannot get pr table data", "err", err)
408- w.WriteHeader(http.StatusInternalServerError)
409- return
410- }
411-
412- numOpen := 0
413- numAccepted := 0
414- numClosed := 0
415- for _, pr := range prs {
416- switch pr.Status {
417- case "open":
418- numOpen += 1
419- case "accepted":
420- numAccepted += 1
421- case "closed":
422- numClosed += 1
423- }
424- }
425-
426- w.Header().Set("content-type", "text/html")
427- err = userTmpl.Execute(w, UserDetailData{
428- Prs: prdata,
429- NumOpen: numOpen,
430- NumAccepted: numAccepted,
431- NumClosed: numClosed,
432- UserData: UserData{
433- UserID: user.ID,
434- Name: user.Name,
435- Pubkey: user.Pubkey,
436- CreatedAt: user.CreatedAt.Format(time.RFC3339),
437- IsAdmin: isAdmin,
438- },
439- MetaData: MetaData{
440- URL: web.Backend.Cfg.Url,
441- },
442- })
443- if err != nil {
444- web.Backend.Logger.Error("cannot execute template", "err", err)
445- }
446-}
447-
448-func repoDetailHandler(w http.ResponseWriter, r *http.Request) {
449- userName := r.PathValue("user")
450- repoName := r.PathValue("repo")
451-
452- web, err := getWebCtx(r)
453- if err != nil {
454- web.Logger.Error("fetch web", "err", err)
455- w.WriteHeader(http.StatusInternalServerError)
456- return
457- }
458+type TabStatus string
459
460- user, err := web.Pr.GetUserByName(userName)
461- if err != nil {
462- web.Logger.Error("cannot find user", "user", user, "err", err)
463- w.WriteHeader(http.StatusNotFound)
464- return
465- }
466-
467- repo, err := web.Pr.GetRepoByName(user, repoName)
468- if err != nil {
469- web.Logger.Error("cannot find repo", "user", user, "err", err)
470- w.WriteHeader(http.StatusNotFound)
471- return
472- }
473-
474- prs, err := web.Pr.GetPatchRequestsByRepoID(repo.ID)
475- if err != nil {
476- web.Logger.Error("cannot get prs", "err", err)
477- w.WriteHeader(http.StatusInternalServerError)
478- return
479- }
480-
481- prdata, err := getPrTableData(web, prs, r.URL.Query())
482- if err != nil {
483- web.Logger.Error("cannot get pr table data", "err", err)
484- w.WriteHeader(http.StatusInternalServerError)
485- return
486- }
487-
488- numOpen := 0
489- numAccepted := 0
490- numClosed := 0
491- for _, pr := range prs {
492- switch pr.Status {
493- case "open":
494- numOpen += 1
495- case "accepted":
496- numAccepted += 1
497- case "closed":
498- numClosed += 1
499- }
500- }
501-
502- w.Header().Set("content-type", "text/html")
503- err = repoTmpl.Execute(w, RepoDetailData{
504- Name: repo.Name,
505- UserID: user.ID,
506- Username: userName,
507- Branch: "main",
508- Prs: prdata,
509- NumOpen: numOpen,
510- NumAccepted: numAccepted,
511- NumClosed: numClosed,
512- MetaData: MetaData{
513- URL: web.Backend.Cfg.Url,
514- },
515- })
516- if err != nil {
517- web.Backend.Logger.Error("cannot execute template", "err", err)
518- }
519-}
520-
521-type PrData struct {
522- UserData
523- ID int64
524- Title string
525- Date string
526- Status Status
527-}
528-
529-type PatchFile struct {
530- *gitdiff.File
531- Adds int64
532- Dels int64
533- DiffText template.HTML
534-}
535-
536-type PatchData struct {
537- *Patch
538- PatchFiles []*PatchFile
539- PatchHeader *gitdiff.PatchHeader
540- Url template.URL
541- Review bool
542- FormattedAuthorDate string
543-}
544-
545-type EventLogData struct {
546- *EventLog
547- UserData
548- *Patchset
549- FormattedPatchsetID string
550- Date string
551- RangeDiff []*RangeDiffOutput
552-}
553-
554-type PatchsetData struct {
555- *Patchset
556- UserData
557- FormattedID string
558- Date string
559- RangeDiff []*RangeDiffOutput
560-}
561-
562-type PrDetailData struct {
563- Page string
564- Tab string
565- Repo LinkData
566- Pr PrData
567- Patchset *Patchset
568- PatchsetData *PatchsetData
569- Patches []PatchData
570- Branch string
571- Logs []EventLogData
572- Patchsets []PatchsetData
573- IsRangeDiff bool
574- MetaData
575-}
576-
577-type ToolData struct {
578- Patchset *Patchset
579- PatchsetData *PatchsetData
580- MetaData
581-}
582+const (
583+ TabStatusDraft TabStatus = "draft"
584+ TabStatusActive TabStatus = "active"
585+ TabStatusInactive TabStatus = "inactive"
586+)
587
588-func createPrDetail(page string) http.HandlerFunc {
589+func createPrListHandler(tab TabStatus) http.HandlerFunc {
590 return func(w http.ResponseWriter, r *http.Request) {
591- id := r.PathValue("id")
592- prID, err := strconv.Atoi(id)
593- if err != nil {
594- w.WriteHeader(http.StatusUnprocessableEntity)
595- return
596- }
597-
598 web, err := getWebCtx(r)
599 if err != nil {
600 w.WriteHeader(http.StatusInternalServerError)
601 return
602 }
603
604- var pr *PatchRequest
605- var ps *Patchset
606- switch page {
607- case "pr":
608- {
609- pr, err = web.Pr.GetPatchRequestByID(int64(prID))
610- if err != nil {
611- web.Pr.Backend.Logger.Error("cannot get prs", "err", err)
612- w.WriteHeader(http.StatusInternalServerError)
613- return
614- }
615- }
616- case "ps":
617+ var prs []*PatchRequest
618+ switch TabStatus(tab) {
619+ case TabStatusDraft:
620+ prs, err = web.Pr.GetPatchRequestsByStatus(StatusDraft)
621+ case TabStatusInactive:
622+ prs, err = web.Pr.GetPatchRequestsInactive()
623+ case TabStatusActive:
624 fallthrough
625- case "rd":
626- {
627- ps, err = web.Pr.GetPatchsetByID(int64(prID))
628- if err != nil {
629- web.Pr.Backend.Logger.Error("cannot get patchset", "err", err)
630- w.WriteHeader(http.StatusInternalServerError)
631- return
632- }
633-
634- pr, err = web.Pr.GetPatchRequestByID(int64(ps.PatchRequestID))
635- if err != nil {
636- web.Pr.Backend.Logger.Error("cannot get pr", "err", err)
637- w.WriteHeader(http.StatusInternalServerError)
638- return
639- }
640- }
641+ default:
642+ prs, err = web.Pr.GetPatchRequestsActive()
643 }
644-
645- patchsets, err := web.Pr.GetPatchsetsByPrID(pr.ID)
646 if err != nil {
647- web.Logger.Error("cannot get latest patchset", "err", err)
648+ web.Backend.Logger.Error("cannot get patch requests", "err", err)
649 w.WriteHeader(http.StatusInternalServerError)
650 return
651 }
652
653- // get patchsets and diff from previous patchset
654- patchsetsData := []PatchsetData{}
655- var selectedPatchsetData *PatchsetData
656- for idx, patchset := range patchsets {
657- user, err := web.Pr.GetUserByID(patchset.UserID)
658- if err != nil {
659- web.Logger.Error("could not get user for patch", "err", err)
660- continue
661- }
662-
663- var prevPatchset *Patchset
664- if idx > 0 {
665- prevPatchset = patchsets[idx-1]
666- }
667-
668- var rangeDiff []*RangeDiffOutput
669- if idx > 0 {
670- rangeDiff, err = web.Pr.DiffPatchsets(prevPatchset, patchset)
671- if err != nil {
672- web.Logger.Error("could not diff patchset", "err", err)
673- continue
674- }
675- }
676-
677- pk, err := web.Backend.PubkeyToPublicKey(user.Pubkey)
678- if err != nil {
679- web.Logger.Error("cannot parse pubkey for pr user", "err", err)
680- w.WriteHeader(http.StatusUnprocessableEntity)
681- return
682- }
683-
684- // set selected patchset to latest when no ps already set
685- if ps == nil && idx == len(patchsets)-1 {
686- ps = patchset
687- }
688-
689- data := PatchsetData{
690- Patchset: patchset,
691- FormattedID: getFormattedPatchsetID(patchset.ID),
692- UserData: UserData{
693- UserID: user.ID,
694- Name: user.Name,
695- IsAdmin: web.Backend.IsAdmin(pk),
696- Pubkey: user.Pubkey,
697- CreatedAt: user.CreatedAt.Format(time.RFC3339),
698- },
699- Date: patchset.CreatedAt.Format(time.RFC3339),
700- RangeDiff: rangeDiff,
701- }
702- patchsetsData = append(patchsetsData, data)
703- if ps != nil && ps.ID == patchset.ID {
704- selectedPatchsetData = &data
705- }
706- }
707-
708- patchesData := []PatchData{}
709- if len(patchsetsData) >= 1 {
710- psID := ps.ID
711- patches, err := web.Pr.GetPatchesByPatchsetID(psID)
712- if err != nil {
713- web.Logger.Error("cannot get patches", "err", err)
714- w.WriteHeader(http.StatusInternalServerError)
715- return
716- }
717-
718- // TODO: a little hacky
719- reviewIDs := []int64{}
720- for _, data := range patchsetsData {
721- if psID != data.ID {
722- continue
723- }
724- if !data.Review {
725- continue
726- }
727-
728- for _, rdiff := range data.RangeDiff {
729- if rdiff.Type == "add" {
730- for _, patch := range patches {
731- commSha := truncateSha(patch.CommitSha)
732- if strings.Contains(rdiff.Header.String(), commSha) {
733- reviewIDs = append(reviewIDs, patch.ID)
734- break
735- }
736- }
737- }
738- }
739- break
740- }
741-
742- for _, patch := range patches {
743- diffFiles, preamble, err := ParsePatch(patch.RawText)
744- if err != nil {
745- web.Logger.Error("cannot parse patch", "err", err)
746- w.WriteHeader(http.StatusUnprocessableEntity)
747- return
748- }
749- header, err := gitdiff.ParsePatchHeader(preamble)
750- if err != nil {
751- web.Logger.Error("cannot parse patch", "err", err)
752- w.WriteHeader(http.StatusUnprocessableEntity)
753- return
754- }
755-
756- // highlight review
757- isReview := slices.Contains(reviewIDs, patch.ID)
758-
759- patchFiles := []*PatchFile{}
760- for _, file := range diffFiles {
761- var adds int64 = 0
762- var dels int64 = 0
763- for _, frag := range file.TextFragments {
764- adds += frag.LinesAdded
765- dels += frag.LinesDeleted
766- }
767-
768- diffStr, err := parseText(web.Formatter, web.Theme, file.String())
769- if err != nil {
770- web.Logger.Error("cannot parse patch", "err", err)
771- w.WriteHeader(http.StatusUnprocessableEntity)
772- return
773- }
774-
775- patchFiles = append(patchFiles, &PatchFile{
776- File: file,
777- Adds: adds,
778- Dels: dels,
779- DiffText: template.HTML(diffStr),
780- })
781- }
782-
783- timestamp := patch.AuthorDate.Format(web.Backend.Cfg.TimeFormat)
784- patchesData = append(patchesData, PatchData{
785- Patch: patch,
786- Url: template.URL(fmt.Sprintf("patch-%d", patch.ID)),
787- Review: isReview,
788- FormattedAuthorDate: timestamp,
789- PatchFiles: patchFiles,
790- PatchHeader: header,
791- })
792- }
793- }
794-
795- user, err := web.Pr.GetUserByID(pr.UserID)
796- if err != nil {
797- w.WriteHeader(http.StatusNotFound)
798- return
799- }
800-
801- w.Header().Set("content-type", "text/html")
802- pk, err := web.Backend.PubkeyToPublicKey(user.Pubkey)
803- if err != nil {
804- web.Logger.Error("cannot parse pubkey for pr user", "err", err)
805- w.WriteHeader(http.StatusUnprocessableEntity)
806- return
807- }
808- isAdmin := web.Backend.IsAdmin(pk)
809- logs, err := web.Pr.GetEventLogsByPrID(pr.ID)
810- if err != nil {
811- web.Logger.Error("cannot get logs for pr", "err", err)
812- w.WriteHeader(http.StatusUnprocessableEntity)
813- return
814- }
815- slices.SortFunc(logs, func(a *EventLog, b *EventLog) int {
816- return a.CreatedAt.Compare(b.CreatedAt)
817- })
818-
819- logData := []EventLogData{}
820- for _, eventlog := range logs {
821- user, _ := web.Pr.GetUserByID(eventlog.UserID)
822- pk, err := web.Backend.PubkeyToPublicKey(user.Pubkey)
823+ prItems := []PrListItem{}
824+ for _, pr := range prs {
825+ patchsets, err := web.Pr.GetPatchsetsByPrID(pr.ID)
826 if err != nil {
827- web.Logger.Error("cannot parse pubkey for pr user", "err", err)
828- w.WriteHeader(http.StatusUnprocessableEntity)
829- return
830- }
831- var logps *Patchset
832- var rangeDiff []*RangeDiffOutput
833- if eventlog.PatchsetID.Int64 > 0 {
834- logps, err = web.Pr.GetPatchsetByID(eventlog.PatchsetID.Int64)
835- if err != nil {
836- web.Logger.Error("cannot get patchset", "err", err, "ps", eventlog.PatchsetID)
837- w.WriteHeader(http.StatusUnprocessableEntity)
838- return
839- }
840- for _, psData := range patchsetsData {
841- if psData.ID == eventlog.PatchsetID.Int64 {
842- rangeDiff = psData.RangeDiff
843- break
844- }
845- }
846+ patchsets = nil
847 }
848-
849- logData = append(logData, EventLogData{
850- EventLog: eventlog,
851- FormattedPatchsetID: getFormattedPatchsetID(eventlog.PatchsetID.Int64),
852- Patchset: logps,
853- RangeDiff: rangeDiff,
854- UserData: UserData{
855- UserID: user.ID,
856- Name: user.Name,
857- IsAdmin: web.Backend.IsAdmin(pk),
858- Pubkey: user.Pubkey,
859- CreatedAt: user.CreatedAt.Format(time.RFC3339),
860- },
861- Date: eventlog.CreatedAt.Format(web.Backend.Cfg.TimeFormat),
862+ prItems = append(prItems, PrListItem{
863+ ID: pr.ID,
864+ Name: pr.Name,
865+ RepoName: pr.RepoName,
866+ Status: pr.Status,
867+ FormattedDate: pr.CreatedAt.Format(web.Backend.Cfg.TimeFormat),
868+ NumPatchsets: len(patchsets),
869 })
870 }
871
872- repo, err := web.Pr.GetRepoByID(pr.RepoID)
873- if err != nil {
874- web.Logger.Error("cannot get repo for pr", "err", err)
875- w.WriteHeader(http.StatusUnprocessableEntity)
876- return
877- }
878-
879- repoOwner, err := web.Pr.GetUserByID(repo.UserID)
880- if err != nil {
881- web.Logger.Error("cannot get repo for pr", "err", err)
882- w.WriteHeader(http.StatusUnprocessableEntity)
883- return
884- }
885-
886- repoNs := web.Backend.CreateRepoNs(repoOwner.Name, repo.Name)
887- url := fmt.Sprintf("/r/%s/%s", repoOwner.Name, repo.Name)
888- tab := "timeline"
889- if page == "ps" || page == "rd" {
890- tab = "patchsets"
891- }
892-
893- err = prTmpl.Execute(w, PrDetailData{
894- Page: page,
895- Tab: tab,
896- Repo: LinkData{
897- Url: template.URL(url),
898- Text: repoNs,
899- },
900- Branch: "main",
901- Patchset: ps,
902- PatchsetData: selectedPatchsetData,
903- IsRangeDiff: page == "rd",
904- Patches: patchesData,
905- Patchsets: patchsetsData,
906- Logs: logData,
907- Pr: PrData{
908- ID: pr.ID,
909- UserData: UserData{
910- UserID: user.ID,
911- Name: user.Name,
912- IsAdmin: isAdmin,
913- Pubkey: user.Pubkey,
914- CreatedAt: user.CreatedAt.Format(time.RFC3339),
915- },
916- Title: pr.Name,
917- Date: pr.CreatedAt.Format(web.Backend.Cfg.TimeFormat),
918- Status: pr.Status,
919- },
920+ w.Header().Set("content-type", "text/html")
921+ err = prsListTmpl.Execute(w, PrListData{
922+ PRs: prItems,
923 MetaData: MetaData{
924- URL: web.Backend.Cfg.Url,
925+ URL: web.Backend.Cfg.Url,
926+ Desc: template.HTML(web.Backend.Cfg.Desc),
927+ Tab: TabStatus(tab),
928 },
929 })
930 if err != nil {
931@@ -897,76 +198,11 @@ func createPrDetail(page string) http.HandlerFunc {
932 }
933 }
934
935-func toolHandlerGet(w http.ResponseWriter, r *http.Request) {
936- web, err := getWebCtx(r)
937- if err != nil {
938- w.WriteHeader(http.StatusUnprocessableEntity)
939- return
940- }
941-
942- err = toolTmpl.Execute(w, ToolData{
943- MetaData: MetaData{
944- URL: web.Backend.Cfg.Url,
945- },
946- Patchset: &Patchset{
947- ID: 0,
948- },
949- PatchsetData: &PatchsetData{
950- RangeDiff: []*RangeDiffOutput{},
951- },
952- })
953- if err != nil {
954- web.Backend.Logger.Error("cannot execute template", "err", err)
955- }
956-}
957-
958-func toolHandlerPost(w http.ResponseWriter, r *http.Request) {
959- web, err := getWebCtx(r)
960- if err != nil {
961- web.Backend.Logger.Error("web ctx not found", "err", err)
962- w.WriteHeader(http.StatusUnprocessableEntity)
963- return
964- }
965-
966- if err := r.ParseForm(); err != nil {
967- web.Backend.Logger.Error("parse form", "err", err)
968- http.Error(w, "Failed to parse form", http.StatusBadRequest)
969- return
970- }
971-
972- prevPs := r.PostFormValue("prev_patchset")
973- prevPs = strings.ReplaceAll(prevPs, "\r", "")
974- nextPs := r.PostFormValue("next_patchset")
975- nextPs = strings.ReplaceAll(nextPs, "\r", "")
976-
977- prevPatchset, err := ParsePatchset(strings.NewReader(prevPs))
978- if err != nil {
979- web.Backend.Logger.Error("parse prev patchset", "err", err)
980- w.WriteHeader(http.StatusUnprocessableEntity)
981- return
982- }
983- nextPatchset, err := ParsePatchset(strings.NewReader(nextPs))
984- if err != nil {
985- web.Backend.Logger.Error("parse next patchset", "err", err)
986- w.WriteHeader(http.StatusUnprocessableEntity)
987- return
988- }
989- rangeDiff := RangeDiff(prevPatchset, nextPatchset)
990-
991- err = toolTmpl.Execute(w, ToolData{
992- MetaData: MetaData{
993- URL: web.Backend.Cfg.Url,
994- },
995- Patchset: &Patchset{
996- ID: 0,
997- },
998- PatchsetData: &PatchsetData{
999- RangeDiff: rangeDiff,
1000- },
1001- })
1002- if err != nil {
1003- web.Backend.Logger.Error("cannot execute template", "err", err)
1004+func shaFn(sha string) string {
1005+ if sha == "" {
1006+ return "(none)"
1007 }
1008+ return truncateSha(sha)
1009 }
1010
1011 func rssHandler(w http.ResponseWriter, r *http.Request) {
1012@@ -991,8 +227,6 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
1013 var eventLogs []*EventLog
1014 id := r.PathValue("id")
1015 pubkey := r.URL.Query().Get("pubkey")
1016- username := r.PathValue("user")
1017- repoName := r.PathValue("repo")
1018
1019 if id != "" {
1020 var prID int64
1021@@ -1009,20 +243,6 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
1022 return
1023 }
1024 eventLogs, err = web.Pr.GetEventLogsByUserID(user.ID)
1025- } else if username != "" {
1026- user, perr := web.Pr.GetUserByName(username)
1027- if perr != nil {
1028- w.WriteHeader(http.StatusNotFound)
1029- return
1030- }
1031- eventLogs, err = web.Pr.GetEventLogsByUserID(user.ID)
1032- } else if repoName != "" {
1033- user, perr := web.Pr.GetUserByName(username)
1034- if perr != nil {
1035- w.WriteHeader(http.StatusNotFound)
1036- return
1037- }
1038- eventLogs, err = web.Pr.GetEventLogsByRepoName(user, repoName)
1039 } else {
1040 eventLogs, err = web.Pr.GetEventLogs()
1041 }
1042@@ -1041,33 +261,31 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
1043 continue
1044 }
1045
1046- repo := &Repo{Name: "unknown"}
1047- if eventLog.RepoID.Valid {
1048- repo, err = web.Pr.GetRepoByID(eventLog.RepoID.Int64)
1049- if err != nil {
1050- web.Logger.Error("repo not found for event log", "id", eventLog.ID, "err", err)
1051- continue
1052- }
1053+ pr, err := web.Pr.GetPatchRequestByID(eventLog.PatchRequestID.Int64)
1054+ if err != nil {
1055+ continue
1056+ }
1057+
1058+ // Don't send RSS notifications for draft PRs
1059+ if pr.Status == StatusDraft {
1060+ continue
1061 }
1062
1063+ displayName := web.Backend.ComputeUserName(user.Pubkey)
1064 realUrl := fmt.Sprintf("%s/prs/%d", web.Backend.Cfg.Url, eventLog.PatchRequestID.Int64)
1065 content := fmt.Sprintf(
1066- "<div><div>RepoID: %s</div><div>PatchRequestID: %d</div><div>Event: %s</div><div>Created: %s</div><div>Data: %s</div></div>",
1067- web.Backend.CreateRepoNs(user.Name, repo.Name),
1068+ "<div><div>Repo: %s</div><div>PatchRequestID: %d</div><div>Event: %s</div><div>Created: %s</div><div>Data: %s</div></div>",
1069+ pr.RepoName,
1070 eventLog.PatchRequestID.Int64,
1071 eventLog.Event,
1072 eventLog.CreatedAt.Format(time.RFC3339Nano),
1073 eventLog.Data,
1074 )
1075- pr, err := web.Pr.GetPatchRequestByID(eventLog.PatchRequestID.Int64)
1076- if err != nil {
1077- continue
1078- }
1079
1080 title := fmt.Sprintf(
1081 `%s in %s for PR "%s" (#%d)`,
1082 eventLog.Event,
1083- web.Backend.CreateRepoNs(user.Name, repo.Name),
1084+ pr.RepoName,
1085 pr.Name,
1086 eventLog.PatchRequestID.Int64,
1087 )
1088@@ -1078,7 +296,7 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
1089 Content: content,
1090 Created: eventLog.CreatedAt,
1091 Description: title,
1092- Author: &feeds.Author{Name: user.Name},
1093+ Author: &feeds.Author{Name: displayName},
1094 }
1095
1096 feedItems = append(feedItems, item)
1097@@ -1195,10 +413,7 @@ func GitWebServer(cfg *GitCfg) http.Handler {
1098 Backend: be,
1099 }
1100 formatter := formatterHtml.New(
1101- formatterHtml.WithLineNumbers(true),
1102- formatterHtml.LineNumbersInTable(true),
1103 formatterHtml.WithClasses(true),
1104- formatterHtml.WithLinkableLineNumbers(true, "gitpr"),
1105 )
1106 web := &WebCtx{
1107 Pr: prCmd,
1108@@ -1214,17 +429,16 @@ func GitWebServer(cfg *GitCfg) http.Handler {
1109 // ensure legacy router is disabled
1110 // GODEBUG=httpmuxgo121=0
1111 mux := http.NewServeMux()
1112+ mux.HandleFunc("GET /prs/active", ctxMdw(ctx, createPrListHandler("active")))
1113+ mux.HandleFunc("GET /prs/draft", ctxMdw(ctx, createPrListHandler("draft")))
1114+ mux.HandleFunc("GET /prs/inactive", ctxMdw(ctx, createPrListHandler("inactive")))
1115 mux.HandleFunc("GET /prs/{id}", ctxMdw(ctx, createPrDetail("pr")))
1116+ mux.HandleFunc("GET /prs/{id}/patches/{patchID}", ctxMdw(ctx, createPrDetail("pr")))
1117 mux.HandleFunc("GET /prs/{id}/rss", ctxMdw(ctx, rssHandler))
1118 mux.HandleFunc("GET /ps/{id}", ctxMdw(ctx, createPrDetail("ps")))
1119- mux.HandleFunc("GET /rd/{id}", ctxMdw(ctx, createPrDetail("rd")))
1120- mux.HandleFunc("GET /r/{user}/{repo}/rss", ctxMdw(ctx, rssHandler))
1121- mux.HandleFunc("GET /r/{user}/{repo}", ctxMdw(ctx, repoDetailHandler))
1122- mux.HandleFunc("GET /r/{user}", ctxMdw(ctx, userDetailHandler))
1123- mux.HandleFunc("GET /rss/{user}", ctxMdw(ctx, rssHandler))
1124+ mux.HandleFunc("GET /ps/{id}/patches/{patchID}", ctxMdw(ctx, createPrDetail("ps")))
1125 mux.HandleFunc("GET /rss", ctxMdw(ctx, rssHandler))
1126- mux.HandleFunc("GET /tool", ctxMdw(ctx, toolHandlerGet))
1127- mux.HandleFunc("POST /tool", ctxMdw(ctx, toolHandlerPost))
1128+
1129 mux.HandleFunc("GET /", ctxMdw(ctx, indexHandler))
1130 mux.HandleFunc("GET /syntax.css", ctxMdw(ctx, chromaStyleHandler))
1131 embedFS, err := getEmbedFS(embedStaticFS, "static")
+454,
-0
1@@ -0,0 +1,454 @@
2+package patchbin
3+
4+import (
5+ "fmt"
6+ "html/template"
7+ "net/http"
8+ "slices"
9+ "strconv"
10+ "time"
11+
12+ "github.com/bluekeyes/go-gitdiff/gitdiff"
13+)
14+
15+type UserData struct {
16+ UserID int64
17+ Name string
18+ IsAdmin bool
19+ Pubkey string
20+ CreatedAt string
21+}
22+
23+type PatchsetData struct {
24+ *Patchset
25+ UserData
26+ FormattedID string
27+ Date string
28+ RangeDiff []*RangeDiffOutput
29+}
30+
31+type PrData struct {
32+ UserData
33+ ID int64
34+ Title string
35+ Date string
36+ Status Status
37+}
38+
39+type EventLogData struct {
40+ *EventLog
41+ UserData
42+ *Patchset
43+ FormattedPatchsetID string
44+ Date string
45+ RangeDiff []*RangeDiffOutput
46+}
47+
48+type PatchHunk struct {
49+ Anchor string
50+ DiffText template.HTML
51+}
52+
53+type PatchFile struct {
54+ *gitdiff.File
55+ DisplayName string
56+ FileAnchor string
57+ Adds int64
58+ Dels int64
59+ Hunks []PatchHunk
60+ SemanticChanges []SemanticChange
61+}
62+
63+// PatchSummary is a lightweight view of a patch used for the commit list,
64+// without the expensive diff parsing/rendering that a full PatchData needs.
65+type PatchSummary struct {
66+ *Patch
67+ Url template.URL
68+ FormattedAuthorDate string
69+}
70+
71+type PatchData struct {
72+ *Patch
73+ PatchFiles []*PatchFile
74+ PatchHeader *gitdiff.PatchHeader
75+ Url template.URL
76+ FormattedAuthorDate string
77+ SemanticSummary SemanticSummary
78+}
79+
80+type PrDetailData struct {
81+ Page string
82+ RepoName string
83+ Branch string
84+ Pr PrData
85+ Patchset *Patchset
86+ FormattedPatchsetID string
87+ PatchsetDate string
88+ Patches []PatchSummary
89+ Patch *PatchData
90+ PrevUrl string
91+ NextUrl string
92+ Logs []EventLogData
93+ MetaData
94+}
95+
96+type AllPatchData struct {
97+ Patches []PatchSummary
98+ Patchsets []*PatchsetData
99+}
100+
101+func getAllPatchData(web *WebCtx, pr *PatchRequest, ps *Patchset) (*AllPatchData, error) {
102+ patchsets, err := web.Pr.GetPatchsetsByPrID(pr.ID)
103+ if err != nil {
104+ return nil, err
105+ }
106+
107+ // get patchsets and diff from previous patchset
108+ patchsetsData := []*PatchsetData{}
109+ for idx, patchset := range patchsets {
110+ user, err := web.Pr.GetUserByID(patchset.UserID)
111+ if err != nil {
112+ web.Logger.Error("could not get user for patch", "err", err)
113+ continue
114+ }
115+
116+ var prevPatchset *Patchset
117+ if idx > 0 {
118+ prevPatchset = patchsets[idx-1]
119+ }
120+
121+ var rangeDiff []*RangeDiffOutput
122+ if idx > 0 {
123+ rangeDiff, err = web.Pr.DiffPatchsets(prevPatchset, patchset)
124+ if err != nil {
125+ web.Logger.Error("could not diff patchset", "err", err)
126+ continue
127+ }
128+ }
129+
130+ pk, err := web.Backend.PubkeyToPublicKey(user.Pubkey)
131+ if err != nil {
132+ return nil, err
133+ }
134+
135+ displayName := web.Backend.ComputeUserName(user.Pubkey)
136+ data := PatchsetData{
137+ Patchset: patchset,
138+ FormattedID: getFormattedPatchsetID(patchset.ID),
139+ UserData: UserData{
140+ UserID: user.ID,
141+ Name: displayName,
142+ IsAdmin: web.Backend.IsAdmin(pk),
143+ Pubkey: user.Pubkey,
144+ CreatedAt: user.CreatedAt.Format(time.RFC3339),
145+ },
146+ Date: patchset.CreatedAt.Format(time.RFC3339),
147+ RangeDiff: rangeDiff,
148+ }
149+ patchsetsData = append(patchsetsData, &data)
150+ }
151+
152+ patchesData := []PatchSummary{}
153+ if len(patchsetsData) >= 1 {
154+ psID := ps.ID
155+ patches, err := web.Pr.GetPatchesByPatchsetID(psID)
156+ if err != nil {
157+ return nil, err
158+ }
159+
160+ for _, patch := range patches {
161+ timestamp := patch.AuthorDate.Format(web.Backend.Cfg.TimeFormat)
162+ patchesData = append(patchesData, PatchSummary{
163+ Patch: patch,
164+ Url: template.URL(fmt.Sprintf("patch-%d", patch.ID)),
165+ FormattedAuthorDate: timestamp,
166+ })
167+ }
168+ }
169+
170+ return &AllPatchData{
171+ Patches: patchesData,
172+ Patchsets: patchsetsData,
173+ }, nil
174+}
175+
176+func hunkAnchor(patchID int64, fileName string, hunkIdx int) string {
177+ return fmt.Sprintf("patch-%d-%s-hunk-%d", patchID, fileName, hunkIdx)
178+}
179+
180+func getPatchData(web *WebCtx, patch *Patch) (*PatchData, error) {
181+ diffFiles, preamble, err := ParsePatch(patch.RawText)
182+ if err != nil {
183+ return nil, err
184+ }
185+ header, err := gitdiff.ParsePatchHeader(preamble)
186+ if err != nil {
187+ return nil, err
188+ }
189+
190+ patchFiles := []*PatchFile{}
191+ var semanticSummary SemanticSummary
192+ for _, file := range diffFiles {
193+ var adds int64 = 0
194+ var dels int64 = 0
195+
196+ fileName := file.NewName
197+ if fileName == "" {
198+ fileName = file.OldName
199+ }
200+
201+ hunks := make([]PatchHunk, 0, len(file.TextFragments))
202+ for hunkIdx, frag := range file.TextFragments {
203+ adds += frag.LinesAdded
204+ dels += frag.LinesDeleted
205+
206+ diffStr, err := parseText(web.Formatter, web.Theme, frag.String())
207+ if err != nil {
208+ return nil, err
209+ }
210+
211+ hunks = append(hunks, PatchHunk{
212+ Anchor: hunkAnchor(patch.ID, fileName, hunkIdx),
213+ DiffText: template.HTML(diffStr),
214+ })
215+ }
216+
217+ semanticChanges := AnalyzeSemanticChanges(file)
218+ for i := range semanticChanges {
219+ semanticChanges[i].HunkAnchor = hunkAnchor(patch.ID, fileName, semanticChanges[i].HunkIndex)
220+ }
221+ semanticSummary = SummarizeSemanticChanges(semanticSummary, fileName, SupportsSemanticDiff(fileName) && !file.IsBinary, semanticChanges)
222+
223+ patchFiles = append(patchFiles, &PatchFile{
224+ File: file,
225+ DisplayName: fileName,
226+ FileAnchor: fmt.Sprintf("patch-%d-%s", patch.ID, fileName),
227+ Adds: adds,
228+ Dels: dels,
229+ Hunks: hunks,
230+ SemanticChanges: semanticChanges,
231+ })
232+ }
233+
234+ timestamp := patch.AuthorDate.Format(web.Backend.Cfg.TimeFormat)
235+ return &PatchData{
236+ Patch: patch,
237+ Url: template.URL(fmt.Sprintf("patch-%d", patch.ID)),
238+ FormattedAuthorDate: timestamp,
239+ PatchFiles: patchFiles,
240+ PatchHeader: header,
241+ SemanticSummary: semanticSummary,
242+ }, nil
243+}
244+
245+func getLogData(web *WebCtx, prID int64, patchsetsData []*PatchsetData) ([]EventLogData, error) {
246+ logData := []EventLogData{}
247+ logs, err := web.Pr.GetEventLogsByPrID(prID)
248+ if err != nil {
249+ return logData, err
250+ }
251+
252+ slices.SortFunc(logs, func(a *EventLog, b *EventLog) int {
253+ return a.CreatedAt.Compare(b.CreatedAt)
254+ })
255+
256+ for _, eventlog := range logs {
257+ logUser, _ := web.Pr.GetUserByID(eventlog.UserID)
258+ pk, err := web.Backend.PubkeyToPublicKey(logUser.Pubkey)
259+ if err != nil {
260+ return logData, err
261+ }
262+ var logps *Patchset
263+ var rangeDiff []*RangeDiffOutput
264+ if eventlog.PatchsetID.Int64 > 0 {
265+ logps, err = web.Pr.GetPatchsetByID(eventlog.PatchsetID.Int64)
266+ if err != nil {
267+ web.Logger.Error("cannot get patchset", "err", err, "ps", eventlog.PatchsetID)
268+ return logData, err
269+ }
270+ for _, psData := range patchsetsData {
271+ if psData.ID == eventlog.PatchsetID.Int64 {
272+ rangeDiff = psData.RangeDiff
273+ break
274+ }
275+ }
276+ }
277+
278+ logDisplayName := web.Backend.ComputeUserName(logUser.Pubkey)
279+ logData = append(logData, EventLogData{
280+ EventLog: eventlog,
281+ FormattedPatchsetID: getFormattedPatchsetID(eventlog.PatchsetID.Int64),
282+ Patchset: logps,
283+ RangeDiff: rangeDiff,
284+ UserData: UserData{
285+ UserID: logUser.ID,
286+ Name: logDisplayName,
287+ IsAdmin: web.Backend.IsAdmin(pk),
288+ Pubkey: logUser.Pubkey,
289+ CreatedAt: logUser.CreatedAt.Format(time.RFC3339),
290+ },
291+ Date: eventlog.CreatedAt.Format(web.Backend.Cfg.TimeFormat),
292+ })
293+ }
294+
295+ return logData, nil
296+}
297+
298+func createPrDetail(page string) http.HandlerFunc {
299+ return func(w http.ResponseWriter, r *http.Request) {
300+ id := r.PathValue("id")
301+ prID, err := strconv.Atoi(id)
302+ if err != nil {
303+ w.WriteHeader(http.StatusUnprocessableEntity)
304+ return
305+ }
306+
307+ web, err := getWebCtx(r)
308+ if err != nil {
309+ w.WriteHeader(http.StatusInternalServerError)
310+ return
311+ }
312+
313+ var pr *PatchRequest
314+ var ps *Patchset
315+ switch page {
316+ case "pr":
317+ {
318+ pr, err = web.Pr.GetPatchRequestByID(int64(prID))
319+ if err != nil {
320+ web.Pr.Backend.Logger.Error("cannot get prs", "err", err)
321+ w.WriteHeader(http.StatusInternalServerError)
322+ return
323+ }
324+
325+ ps, err = web.Pr.GetLatestPatchsetByPrID(int64(prID))
326+ if err != nil {
327+ web.Pr.Backend.Logger.Error("cannot get patchset", "err", err)
328+ w.WriteHeader(http.StatusInternalServerError)
329+ return
330+ }
331+ }
332+ case "ps":
333+ {
334+ ps, err = web.Pr.GetPatchsetByID(int64(prID))
335+ if err != nil {
336+ web.Pr.Backend.Logger.Error("cannot get patchset", "err", err)
337+ w.WriteHeader(http.StatusInternalServerError)
338+ return
339+ }
340+
341+ pr, err = web.Pr.GetPatchRequestByID(int64(ps.PatchRequestID))
342+ if err != nil {
343+ web.Pr.Backend.Logger.Error("cannot get pr", "err", err)
344+ w.WriteHeader(http.StatusInternalServerError)
345+ return
346+ }
347+ }
348+ }
349+
350+ user, err := web.Pr.GetUserByID(pr.UserID)
351+ if err != nil {
352+ w.WriteHeader(http.StatusNotFound)
353+ return
354+ }
355+
356+ pk, err := web.Backend.PubkeyToPublicKey(user.Pubkey)
357+ if err != nil {
358+ web.Logger.Error("cannot parse pubkey for pr user", "err", err)
359+ w.WriteHeader(http.StatusUnprocessableEntity)
360+ return
361+ }
362+ isAdmin := web.Backend.IsAdmin(pk)
363+ displayName := web.Backend.ComputeUserName(user.Pubkey)
364+
365+ aps, err := getAllPatchData(web, pr, ps)
366+ if err != nil {
367+ web.Logger.Error("cannot compute all patch data", "err", err)
368+ w.WriteHeader(http.StatusUnprocessableEntity)
369+ return
370+ }
371+
372+ if len(aps.Patches) == 0 {
373+ web.Logger.Error("no patches found for patchset", "ps", ps.ID)
374+ w.WriteHeader(http.StatusNotFound)
375+ return
376+ }
377+
378+ selectedIdx := 0
379+ if patchIDStr := r.PathValue("patchID"); patchIDStr != "" {
380+ patchID, err := strconv.ParseInt(patchIDStr, 10, 64)
381+ if err != nil {
382+ w.WriteHeader(http.StatusUnprocessableEntity)
383+ return
384+ }
385+ found := false
386+ for idx, summary := range aps.Patches {
387+ if summary.ID == patchID {
388+ selectedIdx = idx
389+ found = true
390+ break
391+ }
392+ }
393+ if !found {
394+ w.WriteHeader(http.StatusNotFound)
395+ return
396+ }
397+ }
398+
399+ selectedPatch, err := getPatchData(web, aps.Patches[selectedIdx].Patch)
400+ if err != nil {
401+ web.Logger.Error("cannot compute selected patch data", "err", err)
402+ w.WriteHeader(http.StatusUnprocessableEntity)
403+ return
404+ }
405+
406+ var prevUrl, nextUrl string
407+ if selectedIdx > 0 {
408+ prevUrl = fmt.Sprintf("/ps/%d/patches/%d", ps.ID, aps.Patches[selectedIdx-1].ID)
409+ }
410+ if selectedIdx < len(aps.Patches)-1 {
411+ nextUrl = fmt.Sprintf("/ps/%d/patches/%d", ps.ID, aps.Patches[selectedIdx+1].ID)
412+ }
413+
414+ logData, err := getLogData(web, pr.ID, aps.Patchsets)
415+ if err != nil {
416+ web.Logger.Error("cannot fetch log data", "err", err)
417+ w.WriteHeader(http.StatusUnprocessableEntity)
418+ return
419+ }
420+
421+ w.Header().Set("content-type", "text/html")
422+ err = prTmpl.Execute(w, PrDetailData{
423+ Page: page,
424+ RepoName: pr.RepoName,
425+ Branch: "main",
426+ Patchset: ps,
427+ FormattedPatchsetID: getFormattedPatchsetID(ps.ID),
428+ PatchsetDate: ps.CreatedAt.Format(web.Backend.Cfg.TimeFormat),
429+ Patches: aps.Patches,
430+ Patch: selectedPatch,
431+ PrevUrl: prevUrl,
432+ NextUrl: nextUrl,
433+ Logs: logData,
434+ Pr: PrData{
435+ ID: pr.ID,
436+ UserData: UserData{
437+ UserID: user.ID,
438+ Name: displayName,
439+ IsAdmin: isAdmin,
440+ Pubkey: user.Pubkey,
441+ CreatedAt: user.CreatedAt.Format(time.RFC3339),
442+ },
443+ Title: pr.Name,
444+ Date: pr.CreatedAt.Format(web.Backend.Cfg.TimeFormat),
445+ Status: pr.Status,
446+ },
447+ MetaData: MetaData{
448+ URL: web.Backend.Cfg.Url,
449+ },
450+ })
451+ if err != nil {
452+ web.Backend.Logger.Error("cannot execute template", "err", err)
453+ }
454+ }
455+}