- 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
+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 }
+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!")
+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+}
+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}} <{{.AuthorEmail}}></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}}
+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}} <{{$val.AuthorEmail}}></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.
+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"
+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),