repos / git-pr

a self-hosted git collaboration server
git clone https://github.com/picosh/git-pr.git

commit
8085b4f
parent
8460ee0
author
Eric Bower
date
2024-07-14 15:39:41 -0400 EDT
refactor: patch requests -> patchsets -> patches

Previously a patch request contained a series of patches.  This worked
great as an MVP but we are starting to see some issues with this impl.

Previously the contrib and reviewer would push patches to git-pr similar
to github pull requests.  They all get merged into a single patchset.
This mostly works until you want to start editing previous commits to
keep the commit history tidy and relevant.  For many workflows, going
back to a previous commit and amending it to address feedback is
desirable.

This creates a new model, patchset, which is a mostly immutable
container for patches.  1-to-many patch request to patchsets and
1-to-many patchset to patches.  Think of these patchsets as revisions.

This allows us to better organize collaboration and enable features like
`git range-diff` to see changes between revisions.

BREAKING CHANGE: sqlite dbs will have to be recreated as the new models
are fundamentally different. Sorry for the inconvenience!
12 files changed,  +763, -336
M cli.go
M db.go
M mdw.go
M pr.go
M web.go
M cli.go
+202, -124
  1@@ -17,11 +17,46 @@ func NewTabWriter(out io.Writer) *tabwriter.Writer {
  2 	return tabwriter.NewWriter(out, 0, 0, 1, ' ', tabwriter.TabIndent)
  3 }
  4 
  5-func getPrID(str string) (int64, error) {
  6+func strToInt(str string) (int64, error) {
  7 	prID, err := strconv.ParseInt(str, 10, 64)
  8 	return prID, err
  9 }
 10 
 11+func getPatchsetFromOpt(patchsets []*Patchset, optPatchsetID string) (*Patchset, error) {
 12+	if optPatchsetID == "" {
 13+		return patchsets[len(patchsets)-1], nil
 14+	}
 15+
 16+	id, err := getPatchsetID(optPatchsetID)
 17+	if err != nil {
 18+		return nil, err
 19+	}
 20+
 21+	for _, ps := range patchsets {
 22+		if ps.ID == id {
 23+			return ps, nil
 24+		}
 25+	}
 26+
 27+	return nil, fmt.Errorf("cannot find patchset: %s", optPatchsetID)
 28+}
 29+
 30+func printPatches(sesh ssh.Session, patches []*Patch) {
 31+	wish.Println(sesh, "/* vim: set filetype=diff : */")
 32+	if len(patches) == 1 {
 33+		wish.Println(sesh, patches[0].RawText)
 34+		return
 35+	}
 36+
 37+	opatches := patches
 38+	for idx, patch := range opatches {
 39+		wish.Println(sesh, patch.RawText)
 40+		if idx < len(patches)-1 {
 41+			wish.Printf(sesh, "\n\n\n")
 42+		}
 43+	}
 44+}
 45+
 46 func NewCli(sesh ssh.Session, be *Backend, pr GitPatchRequest) *cli.App {
 47 	desc := `Patch requests (PR) are the simplest way to submit, review, and accept changes to your git repository.
 48 Here's how it works:
 49@@ -50,12 +85,12 @@ Here's how it works:
 50 		ErrWriter:   sesh,
 51 		ExitErrHandler: func(cCtx *cli.Context, err error) {
 52 			if err != nil {
 53-				wish.Fatalln(sesh, err)
 54+				wish.Fatalln(sesh, fmt.Errorf("err: %w", err))
 55 			}
 56 		},
 57 		OnUsageError: func(cCtx *cli.Context, err error, isSubcommand bool) error {
 58 			if err != nil {
 59-				wish.Fatalln(sesh, err)
 60+				wish.Fatalln(sesh, fmt.Errorf("err: %w", err))
 61 			}
 62 			return nil
 63 		},
 64@@ -143,13 +178,14 @@ Here's how it works:
 65 						return err
 66 					}
 67 					writer := NewTabWriter(sesh)
 68-					fmt.Fprintln(writer, "RepoID\tPrID\tEvent\tCreated\tData")
 69+					fmt.Fprintln(writer, "RepoID\tPrID\tPatchsetID\tEvent\tCreated\tData")
 70 					for _, eventLog := range eventLogs {
 71 						fmt.Fprintf(
 72 							writer,
 73-							"%s\t%d\t%s\t%s\t%s\n",
 74+							"%s\t%d\t%s\t%s\t%s\t%s\n",
 75 							eventLog.RepoID,
 76-							eventLog.PatchRequestID,
 77+							eventLog.PatchRequestID.Int64,
 78+							getFormattedPatchsetID(eventLog.PatchsetID.Int64),
 79 							eventLog.Event,
 80 							eventLog.CreatedAt.Format(be.Cfg.TimeFormat),
 81 							eventLog.Data,
 82@@ -159,6 +195,30 @@ Here's how it works:
 83 					return nil
 84 				},
 85 			},
 86+			{
 87+				Name:  "ps",
 88+				Usage: "Mange patchsets",
 89+				Subcommands: []*cli.Command{
 90+					{
 91+						Name:      "rm",
 92+						Usage:     "Remove a patchset with its patches",
 93+						Args:      true,
 94+						ArgsUsage: "[patchsetID]",
 95+						Action: func(cCtx *cli.Context) error {
 96+							args := cCtx.Args()
 97+							if !args.Present() {
 98+								return fmt.Errorf("must provide a patchset ID")
 99+							}
100+
101+							patchsetID, err := getPatchsetID(args.First())
102+							if err != nil {
103+								return err
104+							}
105+							return pr.DeletePatchsetByID(patchsetID)
106+						},
107+					},
108+				},
109+			},
110 			{
111 				Name:  "pr",
112 				Usage: "Manage Patch Requests (PR)",
113@@ -191,9 +251,10 @@ Here's how it works:
114 							},
115 						},
116 						Action: func(cCtx *cli.Context) error {
117-							repoID := cCtx.Args().First()
118-							var prs []*PatchRequest
119+							args := cCtx.Args()
120+							repoID := args.First()
121 							var err error
122+							var prs []*PatchRequest
123 							if repoID == "" {
124 								prs, err = pr.GetPatchRequests()
125 							} else {
126@@ -210,7 +271,7 @@ Here's how it works:
127 							onlyMine := cCtx.Bool("mine")
128 
129 							writer := NewTabWriter(sesh)
130-							fmt.Fprintln(writer, "ID\tRepoID\tName\tStatus\tUser\tDate")
131+							fmt.Fprintln(writer, "ID\tRepoID\tName\tStatus\tPatchsets\tUser\tDate")
132 							for _, req := range prs {
133 								if onlyAccepted && req.Status != "accepted" {
134 									continue
135@@ -238,13 +299,20 @@ Here's how it works:
136 									continue
137 								}
138 
139+								patchsets, err := pr.GetPatchsetsByPrID(req.ID)
140+								if err != nil {
141+									be.Logger.Error("could not get patchsets for pr", "err", err)
142+									continue
143+								}
144+
145 								fmt.Fprintf(
146 									writer,
147-									"%d\t%s\t%s\t[%s]\t%s\t%s\n",
148+									"%d\t%s\t%s\t[%s]\t%d\t%s\t%s\n",
149 									req.ID,
150 									req.RepoID,
151 									req.Name,
152 									req.Status,
153+									len(patchsets),
154 									user.Name,
155 									req.CreatedAt.Format(be.Cfg.TimeFormat),
156 								)
157@@ -264,7 +332,12 @@ Here's how it works:
158 								return err
159 							}
160 
161-							repoID := cCtx.Args().First()
162+							args := cCtx.Args()
163+							if !args.Present() {
164+								return fmt.Errorf("must provide a repo ID")
165+							}
166+
167+							repoID := args.First()
168 							prq, err := pr.SubmitPatchRequest(repoID, user.ID, sesh)
169 							if err != nil {
170 								return err
171@@ -289,138 +362,101 @@ Here's how it works:
172 						},
173 					},
174 					{
175-						Name:      "print",
176-						Usage:     "Print the patches for a PR",
177+						Name:      "diff",
178+						Usage:     "Print a diff between the last two patchsets in a PR",
179 						Args:      true,
180 						ArgsUsage: "[prID]",
181-						Flags: []cli.Flag{
182-							&cli.StringFlag{
183-								Name:    "filter",
184-								Usage:   "Only print patches in sequence range (x:y) (x:) (:y)",
185-								Aliases: []string{"f"},
186-							},
187-						},
188 						Action: func(cCtx *cli.Context) error {
189-							prID, err := getPrID(cCtx.Args().First())
190+							args := cCtx.Args()
191+							if !args.Present() {
192+								return fmt.Errorf("must provide a patch request ID")
193+							}
194+
195+							prID, err := strToInt(args.First())
196 							if err != nil {
197 								return err
198 							}
199 
200-							patches, err := pr.GetPatchesByPrID(prID)
201+							patchsets, err := pr.GetPatchsetsByPrID(prID)
202 							if err != nil {
203+								be.Logger.Error("cannot get latest patchset", "err", err)
204 								return err
205 							}
206 
207-							if len(patches) == 1 {
208-								wish.Println(sesh, patches[0].RawText)
209-								return nil
210+							if len(patchsets) == 0 {
211+								return fmt.Errorf("no patchsets found for pr: %d", prID)
212 							}
213 
214-							rnge := cCtx.String("filter")
215-							opatches := patches
216-							if rnge != "" {
217-								ranger, err := parseRange(rnge, len(patches))
218-								if err != nil {
219-									return err
220-								}
221-								opatches = filterPatches(ranger, patches)
222+							latest := patchsets[len(patchsets)-1]
223+							var prev *Patchset
224+							if len(patchsets) > 1 {
225+								prev = patchsets[len(patchsets)-2]
226 							}
227 
228-							for idx, patch := range opatches {
229-								wish.Println(sesh, patch.RawText)
230-								if idx < len(patches)-1 {
231-									wish.Printf(sesh, "\n\n\n")
232-								}
233+							patches, err := pr.DiffPatchsets(prev, latest)
234+							if err != nil {
235+								be.Logger.Error("could not diff patchset", "err", err)
236+								return err
237 							}
238 
239+							printPatches(sesh, patches)
240 							return nil
241 						},
242 					},
243 					{
244-						Name:      "stats",
245-						Usage:     "Print PR with diff stats",
246+						Name:      "print",
247+						Usage:     "Print the patches for a PR",
248 						Args:      true,
249 						ArgsUsage: "[prID]",
250 						Flags: []cli.Flag{
251 							&cli.StringFlag{
252-								Name:    "filter",
253-								Usage:   "Only print patches in sequence range (x:y) (x:) (:y)",
254-								Aliases: []string{"f"},
255+								Name:    "patchset",
256+								Usage:   "Provide patchset ID to print a specific patchset (`patchset-xxx`, default is latest)",
257+								Aliases: []string{"ps"},
258 							},
259 						},
260 						Action: func(cCtx *cli.Context) error {
261-							prID, err := getPrID(cCtx.Args().First())
262-							if err != nil {
263-								return err
264+							args := cCtx.Args()
265+							if !args.Present() {
266+								return fmt.Errorf("must provide a patch request ID")
267 							}
268 
269-							request, err := pr.GetPatchRequestByID(prID)
270+							prID, err := strToInt(args.First())
271 							if err != nil {
272 								return err
273 							}
274 
275-							writer := NewTabWriter(sesh)
276-							fmt.Fprintln(writer, "ID\tName\tStatus\tDate")
277-							fmt.Fprintf(
278-								writer,
279-								"%d\t%s\t[%s]\t%s\n%s\n\n",
280-								request.ID, request.Name, request.Status, request.CreatedAt.Format(be.Cfg.TimeFormat),
281-								request.Text,
282-							)
283-							writer.Flush()
284-
285-							patches, err := pr.GetPatchesByPrID(prID)
286+							patchsets, err := pr.GetPatchsetsByPrID(prID)
287 							if err != nil {
288 								return err
289 							}
290 
291-							rnge := cCtx.String("filter")
292-							opatches := patches
293-							if rnge != "" {
294-								ranger, err := parseRange(rnge, len(patches))
295-								if err != nil {
296-									return err
297-								}
298-								opatches = filterPatches(ranger, patches)
299+							patchset, err := getPatchsetFromOpt(patchsets, cCtx.String("patchset"))
300+							if err != nil {
301+								return err
302 							}
303 
304-							for _, patch := range opatches {
305-								reviewTxt := ""
306-								if patch.Review {
307-									reviewTxt = "[review]"
308-								}
309-								timestamp := AuthorDateToTime(patch.AuthorDate, be.Logger).Format(be.Cfg.TimeFormat)
310-								wish.Printf(
311-									sesh,
312-									"%s %s %s\n%s <%s>\n%s\n\n---\n%s\n%s\n\n\n",
313-									patch.Title,
314-									reviewTxt,
315-									truncateSha(patch.CommitSha),
316-									patch.AuthorName,
317-									patch.AuthorEmail,
318-									timestamp,
319-									patch.BodyAppendix,
320-									patch.Body,
321-								)
322+							patches, err := pr.GetPatchesByPatchsetID(patchset.ID)
323+							if err != nil {
324+								return err
325 							}
326 
327+							printPatches(sesh, patches)
328 							return nil
329 						},
330 					},
331 					{
332 						Name:      "summary",
333-						Usage:     "List patches in PRs",
334+						Usage:     "Display metadata related to a PR",
335 						Args:      true,
336 						ArgsUsage: "[prID]",
337-						Flags: []cli.Flag{
338-							&cli.StringFlag{
339-								Name:    "filter",
340-								Usage:   "Only print patches in sequence range (x:y) (x:) (:y)",
341-								Aliases: []string{"f"},
342-							},
343-						},
344 						Action: func(cCtx *cli.Context) error {
345-							prID, err := getPrID(cCtx.Args().First())
346+							args := cCtx.Args()
347+							if !args.Present() {
348+								return fmt.Errorf("must provide a patch request ID")
349+							}
350+
351+							prID, err := strToInt(args.First())
352 							if err != nil {
353 								return err
354 							}
355@@ -429,45 +465,70 @@ Here's how it works:
356 								return err
357 							}
358 
359+							wish.Printf(sesh, "Info\n====\n")
360+
361 							writer := NewTabWriter(sesh)
362 							fmt.Fprintln(writer, "ID\tName\tStatus\tDate")
363 							fmt.Fprintf(
364 								writer,
365-								"%d\t%s\t[%s]\t%s\n%s\n",
366+								"%d\t%s\t[%s]\t%s\n",
367 								request.ID, request.Name, request.Status, request.CreatedAt.Format(be.Cfg.TimeFormat),
368-								request.Text,
369 							)
370 							writer.Flush()
371 
372-							patches, err := pr.GetPatchesByPrID(prID)
373+							patchsets, err := pr.GetPatchsetsByPrID(prID)
374 							if err != nil {
375 								return err
376 							}
377 
378-							rnge := cCtx.String("filter")
379-							opatches := patches
380-							if rnge != "" {
381-								ranger, err := parseRange(rnge, len(patches))
382+							wish.Printf(sesh, "\nPatchsets\n====\n")
383+
384+							writerSet := NewTabWriter(sesh)
385+							fmt.Fprintln(writerSet, "ID\tType\tUser\tDate")
386+							for _, patchset := range patchsets {
387+								user, err := pr.GetUserByID(patchset.UserID)
388 								if err != nil {
389-									return err
390+									be.Logger.Error("cannot find user for patchset", "err", err)
391+									continue
392+								}
393+								isReview := ""
394+								if patchset.Review {
395+									isReview = "[review]"
396 								}
397-								opatches = filterPatches(ranger, patches)
398+
399+								fmt.Fprintf(
400+									writerSet,
401+									"%s\t%s\t%s\t%s\n",
402+									getFormattedPatchsetID(patchset.ID),
403+									isReview,
404+									user.Name,
405+									patchset.CreatedAt.Format(be.Cfg.TimeFormat),
406+								)
407 							}
408+							writerSet.Flush()
409+
410+							latest, err := getPatchsetFromOpt(patchsets, "")
411+							if err != nil {
412+								return err
413+							}
414+
415+							patches, err := pr.GetPatchesByPatchsetID(latest.ID)
416+							if err != nil {
417+								return err
418+							}
419+
420+							wish.Printf(sesh, "\nPatches from latest patchset\n====\n")
421 
422+							opatches := patches
423 							w := NewTabWriter(sesh)
424-							fmt.Fprintln(w, "Idx\tTitle\tStatus\tCommit\tAuthor\tDate")
425+							fmt.Fprintln(w, "Idx\tTitle\tCommit\tAuthor\tDate")
426 							for idx, patch := range opatches {
427-								reviewTxt := ""
428-								if patch.Review {
429-									reviewTxt = "[review]"
430-								}
431 								timestamp := AuthorDateToTime(patch.AuthorDate, be.Logger).Format(be.Cfg.TimeFormat)
432 								fmt.Fprintf(
433 									w,
434-									"%d\t%s\t%s\t%s\t%s <%s>\t%s\n",
435+									"%d\t%s\t%s\t%s <%s>\t%s\n",
436 									idx,
437 									patch.Title,
438-									reviewTxt,
439 									truncateSha(patch.CommitSha),
440 									patch.AuthorName,
441 									patch.AuthorEmail,
442@@ -485,7 +546,12 @@ Here's how it works:
443 						Args:      true,
444 						ArgsUsage: "[prID]",
445 						Action: func(cCtx *cli.Context) error {
446-							prID, err := getPrID(cCtx.Args().First())
447+							args := cCtx.Args()
448+							if !args.Present() {
449+								return fmt.Errorf("must provide a patch request ID")
450+							}
451+
452+							prID, err := strToInt(args.First())
453 							if err != nil {
454 								return err
455 							}
456@@ -522,7 +588,12 @@ Here's how it works:
457 						Args:      true,
458 						ArgsUsage: "[prID]",
459 						Action: func(cCtx *cli.Context) error {
460-							prID, err := getPrID(cCtx.Args().First())
461+							args := cCtx.Args()
462+							if !args.Present() {
463+								return fmt.Errorf("must provide a patch request ID")
464+							}
465+
466+							prID, err := strToInt(args.First())
467 							if err != nil {
468 								return err
469 							}
470@@ -560,7 +631,12 @@ Here's how it works:
471 						Args:      true,
472 						ArgsUsage: "[prID]",
473 						Action: func(cCtx *cli.Context) error {
474-							prID, err := getPrID(cCtx.Args().First())
475+							args := cCtx.Args()
476+							if !args.Present() {
477+								return fmt.Errorf("must provide a patch request ID")
478+							}
479+
480+							prID, err := strToInt(args.First())
481 							if err != nil {
482 								return err
483 							}
484@@ -599,7 +675,12 @@ Here's how it works:
485 						Args:      true,
486 						ArgsUsage: "[prID] [title]",
487 						Action: func(cCtx *cli.Context) error {
488-							prID, err := getPrID(cCtx.Args().First())
489+							args := cCtx.Args()
490+							if !args.Present() {
491+								return fmt.Errorf("must provide a patch request ID")
492+							}
493+
494+							prID, err := strToInt(args.First())
495 							if err != nil {
496 								return err
497 							}
498@@ -639,7 +720,7 @@ Here's how it works:
499 					},
500 					{
501 						Name:      "add",
502-						Usage:     "Append a patch to a PR",
503+						Usage:     "Add a new patchset to a PR",
504 						Args:      true,
505 						ArgsUsage: "[prID]",
506 						Flags: []cli.Flag{
507@@ -647,13 +728,14 @@ Here's how it works:
508 								Name:  "review",
509 								Usage: "mark patch as a review",
510 							},
511-							&cli.BoolFlag{
512-								Name:  "force",
513-								Usage: "replace patchset with new patchset -- including reviews",
514-							},
515 						},
516 						Action: func(cCtx *cli.Context) error {
517-							prID, err := getPrID(cCtx.Args().First())
518+							args := cCtx.Args()
519+							if !args.Present() {
520+								return fmt.Errorf("must provide a patch request ID")
521+							}
522+
523+							prID, err := strToInt(args.First())
524 							if err != nil {
525 								return err
526 							}
527@@ -669,7 +751,6 @@ Here's how it works:
528 
529 							isAdmin := be.IsAdmin(sesh.PublicKey())
530 							isReview := cCtx.Bool("review")
531-							isReplace := cCtx.Bool("force")
532 							isPrOwner := be.IsPrOwner(prq.UserID, user.ID)
533 							if !isAdmin && !isPrOwner {
534 								return fmt.Errorf("unauthorized, you are not the owner of this PR")
535@@ -679,12 +760,9 @@ Here's how it works:
536 							if isReview {
537 								wish.Println(sesh, "Marking new patchset as a review")
538 								op = OpReview
539-							} else if isReplace {
540-								wish.Println(sesh, "Replacing current patchset with new one")
541-								op = OpReplace
542 							}
543 
544-							patches, err := pr.SubmitPatchSet(prID, user.ID, op, sesh)
545+							patches, err := pr.SubmitPatchset(prID, user.ID, op, sesh)
546 							if err != nil {
547 								return err
548 							}
M db.go
+83, -53
  1@@ -42,35 +42,43 @@ type PatchRequest struct {
  2 	LastUpdated string `db:"last_updated"`
  3 }
  4 
  5+type Patchset struct {
  6+	ID             int64     `db:"id"`
  7+	UserID         int64     `db:"user_id"`
  8+	PatchRequestID int64     `db:"patch_request_id"`
  9+	Review         bool      `db:"review"`
 10+	CreatedAt      time.Time `db:"created_at"`
 11+}
 12+
 13 // Patch is a database model for a single entry in a patchset.
 14 // This usually corresponds to a git commit.
 15 type Patch struct {
 16-	ID             int64          `db:"id"`
 17-	UserID         int64          `db:"user_id"`
 18-	PatchRequestID int64          `db:"patch_request_id"`
 19-	AuthorName     string         `db:"author_name"`
 20-	AuthorEmail    string         `db:"author_email"`
 21-	AuthorDate     string         `db:"author_date"`
 22-	Title          string         `db:"title"`
 23-	Body           string         `db:"body"`
 24-	BodyAppendix   string         `db:"body_appendix"`
 25-	CommitSha      string         `db:"commit_sha"`
 26-	ContentSha     string         `db:"content_sha"`
 27-	BaseCommitSha  sql.NullString `db:"base_commit_sha"`
 28-	Review         bool           `db:"review"`
 29-	RawText        string         `db:"raw_text"`
 30-	CreatedAt      time.Time      `db:"created_at"`
 31+	ID            int64          `db:"id"`
 32+	UserID        int64          `db:"user_id"`
 33+	PatchsetID    int64          `db:"patchset_id"`
 34+	AuthorName    string         `db:"author_name"`
 35+	AuthorEmail   string         `db:"author_email"`
 36+	AuthorDate    string         `db:"author_date"`
 37+	Title         string         `db:"title"`
 38+	Body          string         `db:"body"`
 39+	BodyAppendix  string         `db:"body_appendix"`
 40+	CommitSha     string         `db:"commit_sha"`
 41+	ContentSha    string         `db:"content_sha"`
 42+	BaseCommitSha sql.NullString `db:"base_commit_sha"`
 43+	RawText       string         `db:"raw_text"`
 44+	CreatedAt     time.Time      `db:"created_at"`
 45 }
 46 
 47 // EventLog is a event log for RSS or other notification systems.
 48 type EventLog struct {
 49-	ID             int64     `db:"id"`
 50-	UserID         int64     `db:"user_id"`
 51-	RepoID         string    `db:"repo_id"`
 52-	PatchRequestID int64     `db:"patch_request_id"`
 53-	Event          string    `db:"event"`
 54-	Data           string    `db:"data"`
 55-	CreatedAt      time.Time `db:"created_at"`
 56+	ID             int64         `db:"id"`
 57+	UserID         int64         `db:"user_id"`
 58+	RepoID         string        `db:"repo_id"`
 59+	PatchRequestID sql.NullInt64 `db:"patch_request_id"`
 60+	PatchsetID     sql.NullInt64 `db:"patchset_id"`
 61+	Event          string        `db:"event"`
 62+	Data           string        `db:"data"`
 63+	CreatedAt      time.Time     `db:"created_at"`
 64 }
 65 
 66 // DB is the interface for a pico/git database.
 67@@ -111,54 +119,76 @@ CREATE TABLE IF NOT EXISTS patch_requests (
 68     ON UPDATE CASCADE
 69 );
 70 
 71-CREATE TABLE IF NOT EXISTS patches (
 72+CREATE TABLE IF NOT EXISTS patchsets (
 73   id INTEGER PRIMARY KEY AUTOINCREMENT,
 74   user_id INTEGER NOT NULL,
 75   patch_request_id INTEGER NOT NULL,
 76-  author_name TEXT NOT NULL,
 77-  author_email TEXT NOT NULL,
 78-  author_date DATETIME NOT NULL,
 79-  title TEXT NOT NULL,
 80-  body TEXT NOT NULL,
 81-  body_appendix TEXT NOT NULL,
 82-  commit_sha TEXT NOT NULL,
 83-  content_sha TEXT NOT NULL,
 84   review BOOLEAN NOT NULL DEFAULT false,
 85-  raw_text TEXT NOT NULL,
 86-  base_commit_sha TEXT,
 87   created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
 88-  CONSTRAINT pr_id_fk
 89-    FOREIGN KEY(patch_request_id) REFERENCES patch_requests(id)
 90+  CONSTRAINT patchset_user_id_fk
 91+    FOREIGN KEY(user_id) REFERENCES app_users(id)
 92     ON DELETE CASCADE
 93     ON UPDATE CASCADE,
 94-  CONSTRAINT patches_user_id_fk
 95-    FOREIGN KEY(user_id) REFERENCES app_users(id)
 96+  CONSTRAINT patchset_patch_request_id_fk
 97+    FOREIGN KEY(patch_request_id) REFERENCES patch_requests(id)
 98     ON DELETE CASCADE
 99     ON UPDATE CASCADE
100 );
101 
102+CREATE TABLE IF NOT EXISTS patches (
103+	id INTEGER PRIMARY KEY AUTOINCREMENT,
104+	user_id INTEGER NOT NULL,
105+	patchset_id INTEGER NOT NULL,
106+	author_name TEXT NOT NULL,
107+	author_email TEXT NOT NULL,
108+	author_date DATETIME NOT NULL,
109+	title TEXT NOT NULL,
110+	body TEXT NOT NULL,
111+	body_appendix TEXT NOT NULL,
112+	commit_sha TEXT NOT NULL,
113+	content_sha TEXT NOT NULL,
114+	raw_text TEXT NOT NULL,
115+	base_commit_sha TEXT,
116+	created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
117+	CONSTRAINT patches_user_id_fk
118+		FOREIGN KEY(user_id) REFERENCES app_users(id)
119+		ON DELETE CASCADE
120+		ON UPDATE CASCADE,
121+	CONSTRAINT patches_patchset_id_fk
122+		FOREIGN KEY(patchset_id) REFERENCES patchsets(id)
123+		ON DELETE CASCADE
124+		ON UPDATE CASCADE
125+);
126+
127 CREATE TABLE IF NOT EXISTS event_logs (
128-  id INTEGER PRIMARY KEY AUTOINCREMENT,
129-  user_id INTEGER NOT NULL,
130-  repo_id TEXT,
131-  patch_request_id INTEGER,
132-  event TEXT NOT NULL,
133-  data TEXT,
134-  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
135-  CONSTRAINT event_logs_pr_id_fk
136-  FOREIGN KEY(patch_request_id) REFERENCES patch_requests(id)
137-  ON DELETE CASCADE
138-  ON UPDATE CASCADE,
139-  CONSTRAINT event_logs_user_id_fk
140-    FOREIGN KEY(user_id) REFERENCES app_users(id)
141-    ON DELETE CASCADE
142-    ON UPDATE CASCADE
143+	id INTEGER PRIMARY KEY AUTOINCREMENT,
144+	user_id INTEGER NOT NULL,
145+	repo_id TEXT,
146+	patch_request_id INTEGER,
147+	patchset_id INTEGER,
148+	event TEXT NOT NULL,
149+	data TEXT,
150+	created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
151+	CONSTRAINT event_logs_pr_id_fk
152+		FOREIGN KEY(patch_request_id) REFERENCES patch_requests(id)
153+		ON DELETE CASCADE
154+		ON UPDATE CASCADE,
155+	CONSTRAINT event_logs_patchset_id_fk
156+		FOREIGN KEY(patchset_id) REFERENCES patchsets(id)
157+		ON DELETE CASCADE
158+		ON UPDATE CASCADE,
159+	CONSTRAINT event_logs_user_id_fk
160+		FOREIGN KEY(user_id) REFERENCES app_users(id)
161+		ON DELETE CASCADE
162+		ON UPDATE CASCADE
163 );
164 `
165 
166 var sqliteMigrations = []string{
167 	"", // migration #0 is reserved for schema initialization
168 	"ALTER TABLE patches ADD COLUMN base_commit_sha TEXT",
169+	`
170+	`,
171 }
172 
173 // Open opens a database connection.
174@@ -199,7 +229,7 @@ func (db *DB) upgrade() error {
175 		return fmt.Errorf("git-pr (version %d) older than schema (version %d)", len(sqliteMigrations), version)
176 	}
177 
178-	tx, err := db.Begin()
179+	tx, err := db.Beginx()
180 	if err != nil {
181 		return err
182 	}
A fixtures/single.diff
+18, -0
 1@@ -0,0 +1,18 @@
 2+diff --git a/README.md b/README.md
 3+index 586bc0d..8f3a780 100644
 4+--- a/README.md
 5++++ b/README.md
 6+@@ -1,3 +1,3 @@
 7+-# test
 8++# Let's build an RNN
 9+ 
10+-testing git pr
11++This repo demonstrates building an RNN using `pytorch`
12+diff --git a/train.py b/train.py
13+new file mode 100644
14+index 0000000..5c027f4
15+--- /dev/null
16++++ b/train.py
17+@@ -0,0 +1,2 @@
18++if __name__ == "__main__":
19++    print("train!")
A fixtures/single.patch
+31, -0
 1@@ -0,0 +1,31 @@
 2+From 59456574a0bfee9f71c91c13046173c820152346 Mon Sep 17 00:00:00 2001
 3+From: Eric Bower <me@erock.io>
 4+Date: Wed, 3 Jul 2024 15:18:47 -0400
 5+Subject: [PATCH] feat: lets build an rnn
 6+
 7+---
 8+ README.md | 4 ++--
 9+ train.py  | 2 ++
10+ 2 files changed, 4 insertions(+), 2 deletions(-)
11+ create mode 100644 train.py
12+
13+diff --git a/README.md b/README.md
14+index 586bc0d..8f3a780 100644
15+--- a/README.md
16++++ b/README.md
17+@@ -1,3 +1,3 @@
18+-# test
19++# Let's build an RNN
20+ 
21+-testing git pr
22++This repo demonstrates building an RNN using `pytorch`
23+diff --git a/train.py b/train.py
24+new file mode 100644
25+index 0000000..5c027f4
26+--- /dev/null
27++++ b/train.py
28+@@ -0,0 +1,2 @@
29++if __name__ == "__main__":
30++    print("train!")
31+-- 
32+2.45.2
M mdw.go
+3, -1
 1@@ -1,6 +1,8 @@
 2 package git
 3 
 4 import (
 5+	"fmt"
 6+
 7 	"github.com/charmbracelet/ssh"
 8 	"github.com/charmbracelet/wish"
 9 )
10@@ -14,7 +16,7 @@ func GitPatchRequestMiddleware(be *Backend, pr GitPatchRequest) wish.Middleware
11 			err := cli.Run(margs)
12 			if err != nil {
13 				be.Logger.Error("error when running cli", "err", err)
14-				wish.Fatalln(sesh, err)
15+				wish.Fatalln(sesh, fmt.Errorf("err: %w", err))
16 				next(sesh)
17 				return
18 			}
M pr.go
+118, -40
  1@@ -1,6 +1,7 @@
  2 package git
  3 
  4 import (
  5+	"database/sql"
  6 	"errors"
  7 	"fmt"
  8 	"io"
  9@@ -16,7 +17,6 @@ type PatchsetOp int
 10 const (
 11 	OpNormal PatchsetOp = iota
 12 	OpReview
 13-	OpReplace
 14 )
 15 
 16 type GitPatchRequest interface {
 17@@ -29,19 +29,22 @@ type GitPatchRequest interface {
 18 	GetReposWithLatestPr() ([]RepoWithLatestPr, error)
 19 	GetRepoByID(repoID string) (*Repo, error)
 20 	SubmitPatchRequest(repoID string, userID int64, patchset io.Reader) (*PatchRequest, error)
 21-	SubmitPatchSet(prID, userID int64, op PatchsetOp, patchset io.Reader) ([]*Patch, error)
 22+	SubmitPatchset(prID, userID int64, op PatchsetOp, patchset io.Reader) ([]*Patch, error)
 23 	GetPatchRequestByID(prID int64) (*PatchRequest, error)
 24 	GetPatchRequests() ([]*PatchRequest, error)
 25 	GetPatchRequestsByRepoID(repoID string) ([]*PatchRequest, error)
 26-	GetPatchesByPrID(prID int64) ([]*Patch, error)
 27+	GetPatchsetsByPrID(prID int64) ([]*Patchset, error)
 28+	GetLatestPatchsetByPrID(prID int64) (*Patchset, error)
 29+	GetPatchesByPatchsetID(prID int64) ([]*Patch, error)
 30 	UpdatePatchRequestStatus(prID, userID int64, status string) error
 31 	UpdatePatchRequestName(prID, userID int64, name string) error
 32-	DeletePatchesByPrID(prID int64) error
 33+	DeletePatchsetByID(patchsetID int64) error
 34 	CreateEventLog(eventLog EventLog) error
 35 	GetEventLogs() ([]*EventLog, error)
 36 	GetEventLogsByRepoID(repoID string) ([]*EventLog, error)
 37 	GetEventLogsByPrID(prID int64) ([]*EventLog, error)
 38 	GetEventLogsByUserID(userID int64) ([]*EventLog, error)
 39+	DiffPatchsets(aset *Patchset, bset *Patchset) ([]*Patch, error)
 40 }
 41 
 42 type PrCmd struct {
 43@@ -215,20 +218,41 @@ func (pr PrCmd) GetRepoByID(repoID string) (*Repo, error) {
 44 	return nil, fmt.Errorf("repo not found: %s", repoID)
 45 }
 46 
 47-func (pr PrCmd) GetPatchesByPrID(prID int64) ([]*Patch, error) {
 48-	patches := []*Patch{}
 49+func (pr PrCmd) GetPatchsetsByPrID(prID int64) ([]*Patchset, error) {
 50+	patchsets := []*Patchset{}
 51 	err := pr.Backend.DB.Select(
 52-		&patches,
 53-		"SELECT * FROM patches WHERE patch_request_id=? ORDER BY created_at ASC, id ASC",
 54+		&patchsets,
 55+		"SELECT * FROM patchsets WHERE patch_request_id=? ORDER BY created_at ASC",
 56 		prID,
 57 	)
 58 	if err != nil {
 59-		return patches, err
 60+		return patchsets, err
 61 	}
 62-	if len(patches) == 0 {
 63-		return patches, fmt.Errorf("no patches found for Patch Request ID: %d", prID)
 64+	if len(patchsets) == 0 {
 65+		return patchsets, fmt.Errorf("no patchsets found for patch request: %d", prID)
 66 	}
 67-	return patches, nil
 68+	return patchsets, nil
 69+}
 70+
 71+func (pr PrCmd) GetLatestPatchsetByPrID(prID int64) (*Patchset, error) {
 72+	patchsets, err := pr.GetPatchsetsByPrID(prID)
 73+	if err != nil {
 74+		return nil, err
 75+	}
 76+	if len(patchsets) == 0 {
 77+		return nil, fmt.Errorf("not patchsets found for patch request: %d", prID)
 78+	}
 79+	return patchsets[len(patchsets)-1], nil
 80+}
 81+
 82+func (pr PrCmd) GetPatchesByPatchsetID(patchsetID int64) ([]*Patch, error) {
 83+	patches := []*Patch{}
 84+	err := pr.Backend.DB.Select(
 85+		&patches,
 86+		"SELECT * FROM patches WHERE patchset_id=? ORDER BY created_at ASC, id ASC",
 87+		patchsetID,
 88+	)
 89+	return patches, err
 90 }
 91 
 92 func (cmd PrCmd) GetPatchRequests() ([]*PatchRequest, error) {
 93@@ -269,7 +293,7 @@ func (cmd PrCmd) UpdatePatchRequestStatus(prID int64, userID int64, status strin
 94 	)
 95 	_ = cmd.CreateEventLog(EventLog{
 96 		UserID:         userID,
 97-		PatchRequestID: prID,
 98+		PatchRequestID: sql.NullInt64{Int64: prID},
 99 		Event:          "pr_status_changed",
100 		Data:           fmt.Sprintf(`{"status":"%s"}`, status),
101 	})
102@@ -288,7 +312,7 @@ func (cmd PrCmd) UpdatePatchRequestName(prID int64, userID int64, name string) e
103 	)
104 	_ = cmd.CreateEventLog(EventLog{
105 		UserID:         userID,
106-		PatchRequestID: prID,
107+		PatchRequestID: sql.NullInt64{Int64: prID},
108 		Event:          "pr_name_changed",
109 		Data:           fmt.Sprintf(`{"name":"%s"}`, name),
110 	})
111@@ -296,7 +320,7 @@ func (cmd PrCmd) UpdatePatchRequestName(prID int64, userID int64, name string) e
112 }
113 
114 func (cmd PrCmd) CreateEventLog(eventLog EventLog) error {
115-	if eventLog.RepoID == "" && eventLog.PatchRequestID != 0 {
116+	if eventLog.RepoID == "" && eventLog.PatchRequestID.Valid {
117 		var pr PatchRequest
118 		err := cmd.Backend.DB.Get(
119 			&pr,
120@@ -314,10 +338,11 @@ func (cmd PrCmd) CreateEventLog(eventLog EventLog) error {
121 	}
122 
123 	_, err := cmd.Backend.DB.Exec(
124-		"INSERT INTO event_logs (user_id, repo_id, patch_request_id, event, data) VALUES (?, ?, ?, ?, ?)",
125+		"INSERT INTO event_logs (user_id, repo_id, patch_request_id, patchset_id, event, data) VALUES (?, ?, ?, ?, ?, ?)",
126 		eventLog.UserID,
127 		eventLog.RepoID,
128-		eventLog.PatchRequestID,
129+		eventLog.PatchRequestID.Int64,
130+		eventLog.PatchsetID.Int64,
131 		eventLog.Event,
132 		eventLog.Data,
133 	)
134@@ -330,18 +355,18 @@ func (cmd PrCmd) CreateEventLog(eventLog EventLog) error {
135 	return err
136 }
137 
138-func (cmd PrCmd) createPatch(tx *sqlx.Tx, review bool, patch *Patch) (int64, error) {
139+func (cmd PrCmd) createPatch(tx *sqlx.Tx, patch *Patch) (int64, error) {
140 	patchExists := []Patch{}
141-	_ = cmd.Backend.DB.Select(&patchExists, "SELECT * FROM patches WHERE patch_request_id=? AND content_sha=?", patch.PatchRequestID, patch.ContentSha)
142+	_ = cmd.Backend.DB.Select(&patchExists, "SELECT * FROM patches WHERE patchset_id=? AND content_sha=?", patch.PatchsetID, patch.ContentSha)
143 	if len(patchExists) > 0 {
144 		return 0, ErrPatchExists
145 	}
146 
147 	var patchID int64
148 	row := tx.QueryRow(
149-		"INSERT INTO patches (user_id, patch_request_id, author_name, author_email, author_date, title, body, body_appendix, commit_sha, content_sha, base_commit_sha, review, raw_text) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id",
150+		"INSERT INTO patches (user_id, patchset_id, author_name, author_email, author_date, title, body, body_appendix, commit_sha, content_sha, base_commit_sha, raw_text) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id",
151 		patch.UserID,
152-		patch.PatchRequestID,
153+		patch.PatchsetID,
154 		patch.AuthorName,
155 		patch.AuthorEmail,
156 		patch.AuthorDate,
157@@ -351,7 +376,6 @@ func (cmd PrCmd) createPatch(tx *sqlx.Tx, review bool, patch *Patch) (int64, err
158 		patch.CommitSha,
159 		patch.ContentSha,
160 		patch.BaseCommitSha,
161-		review,
162 		patch.RawText,
163 	)
164 	err := row.Scan(&patchID)
165@@ -374,7 +398,7 @@ func (cmd PrCmd) SubmitPatchRequest(repoID string, userID int64, patchset io.Rea
166 		_ = tx.Rollback()
167 	}()
168 
169-	patches, err := parsePatchSet(patchset)
170+	patches, err := parsePatchset(patchset)
171 	if err != nil {
172 		return nil, err
173 	}
174@@ -413,10 +437,24 @@ func (cmd PrCmd) SubmitPatchRequest(repoID string, userID int64, patchset io.Rea
175 		return nil, fmt.Errorf("could not create patch request")
176 	}
177 
178+	var patchsetID int64
179+	row = tx.QueryRow(
180+		"INSERT INTO patchsets (user_id, patch_request_id) VALUES(?, ?) RETURNING id",
181+		userID,
182+		prID,
183+	)
184+	err = row.Scan(&patchsetID)
185+	if err != nil {
186+		return nil, err
187+	}
188+	if patchsetID == 0 {
189+		return nil, fmt.Errorf("could not create patchset")
190+	}
191+
192 	for _, patch := range patches {
193 		patch.UserID = userID
194-		patch.PatchRequestID = prID
195-		_, err = cmd.createPatch(tx, false, patch)
196+		patch.PatchsetID = prID
197+		_, err = cmd.createPatch(tx, patch)
198 		if err != nil {
199 			return nil, err
200 		}
201@@ -429,7 +467,8 @@ func (cmd PrCmd) SubmitPatchRequest(repoID string, userID int64, patchset io.Rea
202 
203 	_ = cmd.CreateEventLog(EventLog{
204 		UserID:         userID,
205-		PatchRequestID: prID,
206+		PatchRequestID: sql.NullInt64{Int64: prID},
207+		PatchsetID:     sql.NullInt64{Int64: patchsetID},
208 		Event:          "pr_created",
209 	})
210 
211@@ -438,7 +477,7 @@ func (cmd PrCmd) SubmitPatchRequest(repoID string, userID int64, patchset io.Rea
212 	return &pr, err
213 }
214 
215-func (cmd PrCmd) SubmitPatchSet(prID int64, userID int64, op PatchsetOp, patchset io.Reader) ([]*Patch, error) {
216+func (cmd PrCmd) SubmitPatchset(prID int64, userID int64, op PatchsetOp, patchset io.Reader) ([]*Patch, error) {
217 	fin := []*Patch{}
218 	tx, err := cmd.Backend.DB.Beginx()
219 	if err != nil {
220@@ -449,22 +488,30 @@ func (cmd PrCmd) SubmitPatchSet(prID int64, userID int64, op PatchsetOp, patchse
221 		_ = tx.Rollback()
222 	}()
223 
224-	patches, err := parsePatchSet(patchset)
225+	patches, err := parsePatchset(patchset)
226 	if err != nil {
227 		return fin, err
228 	}
229 
230-	if op == OpReplace {
231-		err = cmd.DeletePatchesByPrID(prID)
232-		if err != nil {
233-			return fin, err
234-		}
235+	var patchsetID int64
236+	row := tx.QueryRow(
237+		"INSERT INTO patchsets (user_id, patch_request_id, review) VALUES(?, ?, ?) RETURNING id",
238+		userID,
239+		prID,
240+		op == OpReview,
241+	)
242+	err = row.Scan(&patchsetID)
243+	if err != nil {
244+		return nil, err
245+	}
246+	if patchsetID == 0 {
247+		return nil, fmt.Errorf("could not create patchset")
248 	}
249 
250 	for _, patch := range patches {
251 		patch.UserID = userID
252-		patch.PatchRequestID = prID
253-		patchID, err := cmd.createPatch(tx, op == OpReview, patch)
254+		patch.PatchsetID = patchsetID
255+		patchID, err := cmd.createPatch(tx, patch)
256 		if err == nil {
257 			patch.ID = patchID
258 			fin = append(fin, patch)
259@@ -484,13 +531,12 @@ func (cmd PrCmd) SubmitPatchSet(prID int64, userID int64, op PatchsetOp, patchse
260 		event := "pr_patchset_added"
261 		if op == OpReview {
262 			event = "pr_reviewed"
263-		} else if op == OpReplace {
264-			event = "pr_patchset_replaced"
265 		}
266 
267 		_ = cmd.CreateEventLog(EventLog{
268 			UserID:         userID,
269-			PatchRequestID: prID,
270+			PatchRequestID: sql.NullInt64{Int64: prID},
271+			PatchsetID:     sql.NullInt64{Int64: patchsetID},
272 			Event:          event,
273 		})
274 	}
275@@ -498,9 +544,9 @@ func (cmd PrCmd) SubmitPatchSet(prID int64, userID int64, op PatchsetOp, patchse
276 	return fin, err
277 }
278 
279-func (cmd PrCmd) DeletePatchesByPrID(prID int64) error {
280+func (cmd PrCmd) DeletePatchsetByID(patchsetID int64) error {
281 	_, err := cmd.Backend.DB.Exec(
282-		"DELETE FROM patches WHERE patch_request_id=?", prID,
283+		"DELETE FROM patchsets WHERE id=?", patchsetID,
284 	)
285 	return err
286 }
287@@ -550,3 +596,35 @@ func (cmd PrCmd) GetEventLogsByUserID(userID int64) ([]*EventLog, error) {
288 	)
289 	return eventLogs, err
290 }
291+
292+func (cmd PrCmd) DiffPatchsets(prev *Patchset, next *Patchset) ([]*Patch, error) {
293+	patches, err := cmd.GetPatchesByPatchsetID(next.ID)
294+	if err != nil {
295+		return nil, err
296+	}
297+
298+	if prev == nil {
299+		return patches, nil
300+	}
301+
302+	prevPatches, err := cmd.GetPatchesByPatchsetID(prev.ID)
303+	if err != nil {
304+		return nil, fmt.Errorf("cannot get previous patchset patches: %w", err)
305+	}
306+
307+	diffPatches := []*Patch{}
308+	for _, patch := range patches {
309+		foundPatch := false
310+		for _, prev := range prevPatches {
311+			if prev.ContentSha == patch.ContentSha {
312+				foundPatch = true
313+			}
314+		}
315+
316+		if !foundPatch {
317+			diffPatches = append(diffPatches, patch)
318+		}
319+	}
320+
321+	return diffPatches, nil
322+}
A tmpl/patch.html
+22, -0
 1@@ -0,0 +1,22 @@
 2+{{define "patch"}}
 3+<div>
 4+  <h3 class="text-lg m-0 p-0 mb">
 5+    {{if .Review}}<code class="pill-alert">REVIEW</code>{{end}}
 6+    <a href="#{{.Url}}">{{.Title}}</a>
 7+  </h3>
 8+
 9+  <div class="group-h text-sm">
10+    <code class="pill{{if .Review}}-alert{{end}}">{{.AuthorName}} &lt;{{.AuthorEmail}}&gt;</code>
11+    <date>{{.FormattedAuthorDate}}</date>
12+  </div>
13+</div>
14+
15+{{if .Body}}<pre class="m-0">{{.Body}}</pre>{{end}}
16+
17+<pre class="m-0">{{.BodyAppendix}}</pre>
18+
19+<details>
20+  <summary>Patch</summary>
21+  <div>{{.DiffStr}}</div>
22+</details>
23+{{end}}
M tmpl/pr-detail.html
+35, -25
 1@@ -19,18 +19,20 @@
 2 
 3 <main class="group">
 4   <div class="box group text-sm">
 5+    <h3 class="text-lg">Logs</h3>
 6+
 7     {{range .Logs}}
 8     <div>
 9       <code class='pill{{if eq .Event "pr_reviewed"}}-alert{{end}}'>{{.UserName}}</code>
10       <span class="font-bold">
11         {{if eq .Event "pr_created"}}
12-          created pr
13+          created pr with <code>{{.FormattedPatchsetID}}</code>
14         {{else if eq .Event "pr_patchset_added"}}
15-          added patches
16+          added <code>{{.FormattedPatchsetID}}</code>
17         {{else if eq .Event "pr_reviewed"}}
18-          reviewed pr
19+          reviewed pr with <code class="pill-alert">{{.FormattedPatchsetID}}</code>
20         {{else if eq .Event "pr_patchset_replaced"}}
21-          replaced all patches
22+          replaced <code>{{.FormattedPatchsetID}}</code>
23         {{else if eq .Event "pr_status_changed"}}
24           changed status
25         {{else if eq .Event "pr_name_changed"}}
26@@ -40,36 +42,44 @@
27         {{end}}
28       </span>
29       <span>on <date>{{.Date}}</date></span>
30-      <span>{{.Data}}</span>
31+      {{if .Data}}<code>{{.Data}}</code>{{end}}
32     </div>
33     {{end}}
34   </div>
35 
36-  <div class="group">
37-    {{range $idx, $val := .Patches}}
38-    <div class="box{{if $val.Review}}-alert{{end}} group" id="{{$val.Url}}">
39-      <div>
40-        <h2 class="text-lg m-0 p-0 mb">
41-          <code>{{$idx}}</code>
42-          {{if $val.Review}}<code class="pill-alert">REVIEW</code>{{end}}
43-          <a href="#{{$val.Url}}">{{$val.Title}}</a>
44-        </h2>
45+  <div class="box group text-sm">
46+    <h3 class="text-lg">Patchsets</h3>
47 
48-        <div class="group-h text-sm">
49-          <code class="pill{{if $val.Review}}-alert{{end}}">{{$val.AuthorName}} &lt;{{$val.AuthorEmail}}&gt;</code>
50-          <date>{{$val.FormattedAuthorDate}}</date>
51+    {{range .Patchsets}}
52+      <details>
53+        <summary class="text-sm">Diff ↕</summary>
54+        <div class="group">
55+          {{range .DiffPatches}}
56+            <div class="box-sm{{if .Review}}-alert{{end}} group" id="{{.Url}}">
57+              {{template "patch" .}}
58+            </div>
59+          {{else}}
60+            No patches found, that doesn't seem right.
61+          {{end}}
62         </div>
63-      </div>
64+      </details>
65 
66-      {{if $val.Body}}<pre class="m-0">{{$val.Body}}</pre>{{end}}
67+      <div>
68+        <code class="{{if .Review}}pill-alert{{end}}">{{.FormattedID}}</code>
69+        <span> by </span>
70+        <code class="pill{{if .Review}}-alert{{end}}">{{.UserName}}</code>
71+        <span>on <date>{{.Date}}</date></span>
72+      </div>
73+    {{end}}
74+  </div>
75 
76-      <pre class="m-0">{{$val.BodyAppendix}}</pre>
77+  <hr class="w-full" />
78 
79-      <details>
80-        <summary>Patch</summary>
81-        <div>{{$val.DiffStr}}</div>
82-      </details>
83-    </div>
84+  <div class="group">
85+    {{range $idx, $val := .Patches}}
86+      <div class="box{{if .Review}}-alert{{end}} group" id="{{.Url}}">
87+        {{template "patch" .}}
88+      </div>
89     {{else}}
90     <div class="box">
91       No patches found for patch request.
M tmpl/pr-header.html
+2, -6
 1@@ -18,14 +18,10 @@
 2 git format-patch {{.Branch}} --stdout | ssh {{.MetaData.URL}} pr add {{.Pr.ID}}</pre>
 3       <pre class="m-0"># add review to patch request
 4 git format-patch {{.Branch}} --stdout | ssh {{.MetaData.URL}} pr add --review {{.Pr.ID}}</pre>
 5-      <pre class="m-0"># overwrite patches
 6-git format-patch {{.Branch}} --stdout | ssh {{.MetaData.URL}} pr add --force {{.Pr.ID}}</pre>
 7+      <pre class="m-0"># remove patchset
 8+ssh {{.MetaData.URL}} ps rm ps-x</pre>
 9       <pre class="m-0"># checkout all patches
10 ssh {{.MetaData.URL}} pr print {{.Pr.ID}} | git am -3</pre>
11-      <pre class="m-0"># checkout specific patch
12-ssh {{.MetaData.URL}} pr print {{.Pr.ID}} --filter n | git am -3</pre>
13-      <pre class="m-0"># checkout patch range
14-ssh {{.MetaData.URL}} pr print {{.Pr.ID}} --filter n:y | git am -3</pre>
15       <pre class="m-0"># accept PR
16 ssh {{.MetaData.URL}} pr accept {{.Pr.ID}}</pre>
17       <pre class="m-0"># close PR
M util.go
+96, -54
  1@@ -20,6 +20,8 @@ import (
  2 var baseCommitRe = regexp.MustCompile(`base-commit: (.+)\s*`)
  3 var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
  4 var startOfPatch = "From "
  5+var patchsetPrefix = "ps-"
  6+var prPrefix = "pr-"
  7 
  8 // https://stackoverflow.com/a/22892986
  9 func randSeq(n int) string {
 10@@ -53,65 +55,27 @@ func getAuthorizedKeys(pubkeys []string) ([]ssh.PublicKey, error) {
 11 	return keys, nil
 12 }
 13 
 14-type Ranger struct {
 15-	Left  int
 16-	Right int
 17-}
 18-
 19-func parseRange(rnge string, sliceLen int) (*Ranger, error) {
 20-	items := strings.Split(rnge, ":")
 21-	left := 0
 22-	var err error
 23-	if items[0] != "" {
 24-		left, err = strconv.Atoi(items[0])
 25-		if err != nil {
 26-			return nil, fmt.Errorf("first value before `:` must provide number")
 27-		}
 28-	}
 29-
 30-	if left < 0 {
 31-		return nil, fmt.Errorf("first value must be >= 0")
 32-	}
 33-
 34-	if left >= sliceLen {
 35-		return nil, fmt.Errorf("first value must be less than number of patches")
 36-	}
 37-
 38-	if len(items) == 1 {
 39-		return &Ranger{
 40-			Left:  left,
 41-			Right: left,
 42-		}, nil
 43-	}
 44-
 45-	if items[1] == "" {
 46-		return &Ranger{Left: left, Right: sliceLen - 1}, nil
 47+func getFormattedPatchsetID(id int64) string {
 48+	if id == 0 {
 49+		return ""
 50 	}
 51+	return fmt.Sprintf("%s%d", patchsetPrefix, id)
 52+}
 53 
 54-	right, err := strconv.Atoi(items[1])
 55+func getPrID(prID string) (int64, error) {
 56+	recID, err := strconv.Atoi(strings.Replace(prID, prPrefix, "", 1))
 57 	if err != nil {
 58-		return nil, fmt.Errorf("second value after `:` must provide number")
 59+		return 0, err
 60 	}
 61-
 62-	if left > right {
 63-		return nil, fmt.Errorf("second value must be greater than first value")
 64-	}
 65-
 66-	if right >= sliceLen {
 67-		return nil, fmt.Errorf("second value must be less than number of patches")
 68-	}
 69-
 70-	return &Ranger{
 71-		Left:  left,
 72-		Right: right,
 73-	}, nil
 74+	return int64(recID), nil
 75 }
 76 
 77-func filterPatches(ranger *Ranger, patches []*Patch) []*Patch {
 78-	if ranger.Left == ranger.Right {
 79-		return []*Patch{patches[ranger.Left]}
 80+func getPatchsetID(patchsetID string) (int64, error) {
 81+	psID, err := strconv.Atoi(strings.Replace(patchsetID, patchsetPrefix, "", 1))
 82+	if err != nil {
 83+		return 0, err
 84 	}
 85-	return patches[ranger.Left:ranger.Right]
 86+	return int64(psID), nil
 87 }
 88 
 89 func splitPatchSet(patchset string) []string {
 90@@ -127,7 +91,24 @@ func findBaseCommit(patch string) string {
 91 	return baseCommit
 92 }
 93 
 94-func parsePatchSet(patchset io.Reader) ([]*Patch, error) {
 95+func patchToDiff(patch io.Reader) (string, error) {
 96+	by, err := io.ReadAll(patch)
 97+	if err != nil {
 98+		return "", err
 99+	}
100+	str := string(by)
101+	idx := strings.Index(str, "diff --git")
102+	if idx == -1 {
103+		return "", fmt.Errorf("no diff found in patch")
104+	}
105+	trailIdx := strings.LastIndex(str, "-- \n")
106+	if trailIdx >= 0 {
107+		return str[idx:trailIdx], nil
108+	}
109+	return str[idx:], nil
110+}
111+
112+func parsePatchset(patchset io.Reader) ([]*Patch, error) {
113 	patches := []*Patch{}
114 	buf := new(strings.Builder)
115 	_, err := io.Copy(buf, patchset)
116@@ -174,7 +155,7 @@ func parsePatchSet(patchset io.Reader) ([]*Patch, error) {
117 			BodyAppendix:  header.BodyAppendix,
118 			CommitSha:     header.SHA,
119 			ContentSha:    contentSha,
120-			RawText:       patchRaw,
121+			RawText:       patchStr,
122 			BaseCommitSha: sql.NullString{String: baseCommit},
123 		})
124 	}
125@@ -245,6 +226,67 @@ func AuthorDateToTime(date string, logger *slog.Logger) time.Time {
126 	return ds
127 }
128 
129+/* func filterPatches(ranger *Ranger, patches []*Patch) []*Patch {
130+	if ranger.Left == ranger.Right {
131+		return []*Patch{patches[ranger.Left]}
132+	}
133+	return patches[ranger.Left:ranger.Right]
134+}
135+
136+type Ranger struct {
137+	Left  int
138+	Right int
139+}
140+
141+func parseRange(rnge string, sliceLen int) (*Ranger, error) {
142+	items := strings.Split(rnge, ":")
143+	left := 0
144+	var err error
145+	if items[0] != "" {
146+		left, err = strconv.Atoi(items[0])
147+		if err != nil {
148+			return nil, fmt.Errorf("first value before `:` must provide number")
149+		}
150+	}
151+
152+	if left < 0 {
153+		return nil, fmt.Errorf("first value must be >= 0")
154+	}
155+
156+	if left >= sliceLen {
157+		return nil, fmt.Errorf("first value must be less than number of patches")
158+	}
159+
160+	if len(items) == 1 {
161+		return &Ranger{
162+			Left:  left,
163+			Right: left,
164+		}, nil
165+	}
166+
167+	if items[1] == "" {
168+		return &Ranger{Left: left, Right: sliceLen - 1}, nil
169+	}
170+
171+	right, err := strconv.Atoi(items[1])
172+	if err != nil {
173+		return nil, fmt.Errorf("second value after `:` must provide number")
174+	}
175+
176+	if left > right {
177+		return nil, fmt.Errorf("second value must be greater than first value")
178+	}
179+
180+	if right >= sliceLen {
181+		return nil, fmt.Errorf("second value must be less than number of patches")
182+	}
183+
184+	return &Ranger{
185+		Left:  left,
186+		Right: right,
187+	}, nil
188+} */
189+
190 /* func gitServiceCommands(sesh ssh.Session, be *Backend, cmd, repoName string) error {
191 	name := utils.SanitizeRepo(repoName)
192 	// git bare repositories should end in ".git"
M util_test.go
+36, -1
 1@@ -1,6 +1,8 @@
 2 package git
 3 
 4 import (
 5+	"fmt"
 6+	"io"
 7 	"os"
 8 	"testing"
 9 )
10@@ -13,7 +15,7 @@ func TestParsePatchsetWithCover(t *testing.T) {
11 	if err != nil {
12 		t.Fatalf(err.Error())
13 	}
14-	actual, err := parsePatchSet(file)
15+	actual, err := parsePatchset(file)
16 	if err != nil {
17 		t.Fatalf(err.Error())
18 	}
19@@ -32,3 +34,36 @@ func TestParsePatchsetWithCover(t *testing.T) {
20 		}
21 	}
22 }
23+
24+func TestPatchToDiff(t *testing.T) {
25+	file, err := os.Open("fixtures/single.patch")
26+	defer func() {
27+		_ = file.Close()
28+	}()
29+	if err != nil {
30+		t.Fatalf(err.Error())
31+	}
32+
33+	fileExp, err := os.Open("fixtures/single.diff")
34+	defer func() {
35+		_ = file.Close()
36+	}()
37+	if err != nil {
38+		t.Fatalf(err.Error())
39+	}
40+
41+	actual, err := patchToDiff(file)
42+	if err != nil {
43+		t.Fatalf(err.Error())
44+	}
45+
46+	by, err := io.ReadAll(fileExp)
47+	if err != nil {
48+		t.Fatalf("cannot read expected file")
49+	}
50+
51+	if actual != string(by) {
52+		fmt.Println(actual)
53+		t.Fatalf("diff does not match expected")
54+	}
55+}
M web.go
+117, -32
  1@@ -71,6 +71,7 @@ func getTemplate(file string) *template.Template {
  2 		template.ParseFS(
  3 			tmplFS,
  4 			filepath.Join("tmpl", file),
  5+			filepath.Join("tmpl", "patch.html"),
  6 			filepath.Join("tmpl", "pr-header.html"),
  7 			filepath.Join("tmpl", "pr-list-item.html"),
  8 			filepath.Join("tmpl", "pr-status.html"),
  9@@ -279,23 +280,35 @@ type PatchData struct {
 10 	*Patch
 11 	Url                 template.URL
 12 	DiffStr             template.HTML
 13+	Review              bool
 14 	FormattedAuthorDate string
 15 }
 16 
 17 type EventLogData struct {
 18 	*EventLog
 19-	UserName string
 20-	Pubkey   string
 21-	Date     string
 22+	FormattedPatchsetID string
 23+	UserName            string
 24+	Pubkey              string
 25+	Date                string
 26+}
 27+
 28+type PatchsetData struct {
 29+	*Patchset
 30+	FormattedID string
 31+	UserName    string
 32+	Pubkey      string
 33+	Date        string
 34+	DiffPatches []PatchData
 35 }
 36 
 37 type PrDetailData struct {
 38-	Page    string
 39-	Repo    LinkData
 40-	Pr      PrData
 41-	Patches []PatchData
 42-	Branch  string
 43-	Logs    []EventLogData
 44+	Page      string
 45+	Repo      LinkData
 46+	Pr        PrData
 47+	Patches   []PatchData
 48+	Branch    string
 49+	Logs      []EventLogData
 50+	Patchsets []PatchsetData
 51 	MetaData
 52 }
 53 
 54@@ -327,28 +340,96 @@ func prDetailHandler(w http.ResponseWriter, r *http.Request) {
 55 		return
 56 	}
 57 
 58-	patches, err := web.Pr.GetPatchesByPrID(int64(prID))
 59+	patchsets, err := web.Pr.GetPatchsetsByPrID(int64(prID))
 60 	if err != nil {
 61-		web.Logger.Error("cannot get patches", "err", err)
 62+		web.Logger.Error("cannot get latest patchset", "err", err)
 63 		w.WriteHeader(http.StatusInternalServerError)
 64 		return
 65 	}
 66 
 67+	// get patchsets and diff from previous patchset
 68+	patchsetsData := []PatchsetData{}
 69+	for idx, patchset := range patchsets {
 70+		user, err := web.Pr.GetUserByID(patchset.UserID)
 71+		if err != nil {
 72+			web.Logger.Error("could not get user for patch", "err", err)
 73+			continue
 74+		}
 75+
 76+		var prevPatchset *Patchset
 77+		if idx > 0 {
 78+			prevPatchset = patchsets[idx-1]
 79+		}
 80+		patches, err := web.Pr.DiffPatchsets(prevPatchset, patchset)
 81+		if err != nil {
 82+			web.Logger.Error("could not diff patchset", "err", err)
 83+			continue
 84+		}
 85+
 86+		patchesData := []PatchData{}
 87+		for _, patch := range patches {
 88+			diffStr, err := parseText(web.Formatter, web.Theme, patch.RawText)
 89+			if err != nil {
 90+				web.Logger.Error("cannot parse patch", "err", err)
 91+				w.WriteHeader(http.StatusUnprocessableEntity)
 92+				return
 93+			}
 94+
 95+			patchesData = append(patchesData, PatchData{
 96+				Patch:   patch,
 97+				Url:     template.URL(fmt.Sprintf("patch-%d", patch.ID)),
 98+				DiffStr: template.HTML(diffStr),
 99+				Review:  patchset.Review,
100+			})
101+		}
102+
103+		patchsetsData = append(patchsetsData, PatchsetData{
104+			Patchset:    patchset,
105+			FormattedID: getFormattedPatchsetID(patchset.ID),
106+			UserName:    user.Name,
107+			Pubkey:      user.Pubkey,
108+			Date:        patchset.CreatedAt.Format(time.RFC3339),
109+			DiffPatches: patchesData,
110+		})
111+	}
112+
113 	patchesData := []PatchData{}
114-	for _, patch := range patches {
115-		diffStr, err := parseText(web.Formatter, web.Theme, patch.RawText)
116+	if len(patchsetsData) >= 1 {
117+		latest := patchsetsData[len(patchsets)-1]
118+		patches, err := web.Pr.GetPatchesByPatchsetID(latest.ID)
119 		if err != nil {
120-			w.WriteHeader(http.StatusUnprocessableEntity)
121+			web.Logger.Error("cannot get patches", "err", err)
122+			w.WriteHeader(http.StatusInternalServerError)
123 			return
124 		}
125 
126-		timestamp := AuthorDateToTime(patch.AuthorDate, web.Logger).Format(web.Backend.Cfg.TimeFormat)
127-		patchesData = append(patchesData, PatchData{
128-			Patch:               patch,
129-			Url:                 template.URL(fmt.Sprintf("patch-%d", patch.ID)),
130-			DiffStr:             template.HTML(diffStr),
131-			FormattedAuthorDate: timestamp,
132-		})
133+		for _, patch := range patches {
134+			timestamp := AuthorDateToTime(patch.AuthorDate, web.Logger).Format(web.Backend.Cfg.TimeFormat)
135+			diffStr, err := parseText(web.Formatter, web.Theme, patch.RawText)
136+			if err != nil {
137+				web.Logger.Error("cannot parse patch", "err", err)
138+				w.WriteHeader(http.StatusUnprocessableEntity)
139+				return
140+			}
141+
142+			// highlight review
143+			isReview := false
144+			if latest.Review {
145+				for _, diffPatch := range latest.DiffPatches {
146+					if diffPatch.ID == patch.ID {
147+						isReview = true
148+					}
149+				}
150+			}
151+
152+			patchesData = append(patchesData, PatchData{
153+				Patch:               patch,
154+				Url:                 template.URL(fmt.Sprintf("patch-%d", patch.ID)),
155+				DiffStr:             template.HTML(diffStr),
156+				Review:              isReview,
157+				FormattedAuthorDate: timestamp,
158+			})
159+		}
160 	}
161 
162 	user, err := web.Pr.GetUserByID(pr.UserID)
163@@ -361,12 +442,14 @@ func prDetailHandler(w http.ResponseWriter, r *http.Request) {
164 	tmpl := getTemplate("pr-detail.html")
165 	pk, err := web.Backend.PubkeyToPublicKey(user.Pubkey)
166 	if err != nil {
167+		web.Logger.Error("cannot parse pubkey for pr user", "err", err)
168 		w.WriteHeader(http.StatusUnprocessableEntity)
169 		return
170 	}
171 	isAdmin := web.Backend.IsAdmin(pk)
172 	logs, err := web.Pr.GetEventLogsByPrID(int64(prID))
173 	if err != nil {
174+		web.Logger.Error("cannot get logs for pr", "err", err)
175 		w.WriteHeader(http.StatusUnprocessableEntity)
176 		return
177 	}
178@@ -378,10 +461,11 @@ func prDetailHandler(w http.ResponseWriter, r *http.Request) {
179 	for _, eventlog := range logs {
180 		user, _ := web.Pr.GetUserByID(eventlog.UserID)
181 		logData = append(logData, EventLogData{
182-			EventLog: eventlog,
183-			UserName: user.Name,
184-			Pubkey:   user.Pubkey,
185-			Date:     pr.CreatedAt.Format(web.Backend.Cfg.TimeFormat),
186+			EventLog:            eventlog,
187+			FormattedPatchsetID: getFormattedPatchsetID(eventlog.PatchsetID.Int64),
188+			UserName:            user.Name,
189+			Pubkey:              user.Pubkey,
190+			Date:                pr.CreatedAt.Format(web.Backend.Cfg.TimeFormat),
191 		})
192 	}
193 
194@@ -391,9 +475,10 @@ func prDetailHandler(w http.ResponseWriter, r *http.Request) {
195 			Url:  template.URL("/repos/" + repo.ID),
196 			Text: repo.ID,
197 		},
198-		Branch:  repo.DefaultBranch,
199-		Patches: patchesData,
200-		Logs:    logData,
201+		Branch:    repo.DefaultBranch,
202+		Patches:   patchesData,
203+		Patchsets: patchsetsData,
204+		Logs:      logData,
205 		Pr: PrData{
206 			ID:       pr.ID,
207 			IsAdmin:  isAdmin,
208@@ -465,16 +550,16 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
209 
210 	var feedItems []*feeds.Item
211 	for _, eventLog := range eventLogs {
212-		realUrl := fmt.Sprintf("%s/prs/%d", web.Backend.Cfg.Url, eventLog.PatchRequestID)
213+		realUrl := fmt.Sprintf("%s/prs/%d", web.Backend.Cfg.Url, eventLog.PatchRequestID.Int64)
214 		content := fmt.Sprintf(
215 			"<div><div>RepoID: %s</div><div>PatchRequestID: %d</div><div>Event: %s</div><div>Created: %s</div><div>Data: %s</div></div>",
216 			eventLog.RepoID,
217-			eventLog.PatchRequestID,
218+			eventLog.PatchRequestID.Int64,
219 			eventLog.Event,
220 			eventLog.CreatedAt.Format(time.RFC3339Nano),
221 			eventLog.Data,
222 		)
223-		pr, err := web.Pr.GetPatchRequestByID(eventLog.PatchRequestID)
224+		pr, err := web.Pr.GetPatchRequestByID(eventLog.PatchRequestID.Int64)
225 		if err != nil {
226 			continue
227 		}
228@@ -489,7 +574,7 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
229 			eventLog.Event,
230 			eventLog.RepoID,
231 			pr.Name,
232-			eventLog.PatchRequestID,
233+			eventLog.PatchRequestID.Int64,
234 		)
235 		item := &feeds.Item{
236 			Id:          fmt.Sprintf("%d", eventLog.ID),