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
M cfg.go
M cli.go
M go.mod
M go.sum
M mdw.go
M pr.go
M ssh.go
M web.go
+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"]
+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-![git-pr-logo](logo.png)
  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=
+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);">&rarr;</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}} &lt;{{.Header.OldAuthorEmail}}&gt;</span></div>
 64-                  <div><span style="color: limegreen;">+ {{.Header.NewAuthorName}} &lt;{{.Header.NewAuthorEmail}}&gt;</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}} &lt;{{.Header.OldAuthorEmail}}&gt;</span></div>
220+                  <div><span style="color: limegreen;">+ {{.Header.NewAuthorName}} &lt;{{.Header.NewAuthorEmail}}&gt;</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> &middot;
 17-    <a href="https://github.com/picosh/git-pr">github</a> &middot;
 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 &rarr; 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 &rarr; 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-    &middot;
380-    <a href="/?status=accepted">accepted</a> <code>{{.NumAccepted}}</code>
381-    &middot;
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> &middot; 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> &middot; 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> &middot; 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> &middot; 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}}
+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+}