main
pr.go
Eric Bower
·
2026-02-25
1package patchbin
2
3import (
4 "database/sql"
5 "errors"
6 "fmt"
7 "io"
8 "time"
9
10 "github.com/jmoiron/sqlx"
11)
12
13var ErrPatchExists = errors.New("patch already exists for patch request")
14
15type PatchsetOp int
16
17const (
18 OpNormal PatchsetOp = iota
19)
20
21var ErrNotPrOwner = fmt.Errorf("only the PR creator can perform this action")
22
23type GitPatchRequest interface {
24 GetUsers() ([]*User, error)
25 GetUserByID(userID int64) (*User, error)
26 GetUserByPubkey(pubkey string) (*User, error)
27 UpsertUserByPubkey(pubkey string) (*User, error)
28 IsBanned(pubkey, ipAddress string) error
29 SubmitPatchRequest(userID int64, userPubkey string, repoName string, patchset io.Reader) (*PatchRequest, error)
30 SubmitPatchset(prID, userID int64, op PatchsetOp, patchset io.Reader) ([]*Patch, error)
31 GetPatchRequestByID(prID int64) (*PatchRequest, error)
32 GetPatchRequests() ([]*PatchRequest, error)
33 GetPatchRequestsByRepoName(repoName string) ([]*PatchRequest, error)
34 GetPatchRequestsByPubkey(pubkey string) ([]*PatchRequest, error)
35 GetPatchsetsByPrID(prID int64) ([]*Patchset, error)
36 GetPatchsetByID(patchsetID int64) (*Patchset, error)
37 GetLatestPatchsetByPrID(prID int64) (*Patchset, error)
38 GetPatchesByPatchsetID(patchsetID int64) ([]*Patch, error)
39 UpdatePatchRequestStatus(prID int64, userPubkey string, status Status, comment string) error
40 UpdatePatchRequestName(prID int64, userPubkey string, name string) error
41 DeletePatchsetByID(userID, prID int64, patchsetID int64) error
42 SubmitIssue(userID int64, userPubkey string, repoName, title, body string) (*PatchRequest, error)
43 CreateEventLog(tx *sqlx.Tx, eventLog EventLog) error
44 GetEventLogs() ([]*EventLog, error)
45 GetEventLogsByPrID(prID int64) ([]*EventLog, error)
46 GetEventLogsByUserID(userID int64) ([]*EventLog, error)
47 DiffPatchsets(aset *Patchset, bset *Patchset) ([]*RangeDiffOutput, error)
48}
49
50type PrCmd struct {
51 Backend *Backend
52}
53
54var (
55 _ GitPatchRequest = PrCmd{}
56 _ GitPatchRequest = (*PrCmd)(nil)
57)
58
59func (pr PrCmd) IsBanned(pubkey, ipAddress string) error {
60 acl := []*Acl{}
61 err := pr.Backend.DB.Select(
62 &acl,
63 "SELECT * FROM acl WHERE permission='banned' AND (pubkey=? OR ip_address=?)",
64 pubkey,
65 ipAddress,
66 )
67 if len(acl) > 0 {
68 return fmt.Errorf("user has been banned")
69 }
70 return err
71}
72
73func (pr PrCmd) GetUsers() ([]*User, error) {
74 users := []*User{}
75 err := pr.Backend.DB.Select(&users, "SELECT * FROM app_users")
76 return users, err
77}
78
79func (pr PrCmd) GetUserByID(id int64) (*User, error) {
80 var user User
81 err := pr.Backend.DB.Get(&user, "SELECT * FROM app_users WHERE id=?", id)
82 return &user, err
83}
84
85func (pr PrCmd) GetUserByPubkey(pubkey string) (*User, error) {
86 var user User
87 err := pr.Backend.DB.Get(&user, "SELECT * FROM app_users WHERE pubkey=?", pubkey)
88 return &user, err
89}
90
91func (pr PrCmd) UpsertUserByPubkey(pubkey string) (*User, error) {
92 user, err := pr.GetUserByPubkey(pubkey)
93 if err == nil {
94 return user, nil
95 }
96 return pr.createUser(pubkey)
97}
98
99func (pr PrCmd) createUser(pubkey string) (*User, error) {
100 if pubkey == "" {
101 return nil, fmt.Errorf("must provide pubkey when creating user")
102 }
103
104 var userID int64
105 row := pr.Backend.DB.QueryRow(
106 "INSERT INTO app_users (pubkey, name) VALUES (?, ?) RETURNING id",
107 pubkey,
108 pubkey, // Use pubkey as name placeholder (will be computed on read)
109 )
110 err := row.Scan(&userID)
111 if err != nil {
112 return nil, err
113 }
114 if userID == 0 {
115 return nil, fmt.Errorf("could not create user")
116 }
117
118 user, err := pr.GetUserByID(userID)
119 return user, err
120}
121
122func (pr PrCmd) GetPatchsetsByPrID(prID int64) ([]*Patchset, error) {
123 patchsets := []*Patchset{}
124 err := pr.Backend.DB.Select(
125 &patchsets,
126 "SELECT * FROM patchsets WHERE patch_request_id=? ORDER BY created_at ASC",
127 prID,
128 )
129 if err != nil {
130 return patchsets, err
131 }
132 if len(patchsets) == 0 {
133 return patchsets, fmt.Errorf("no patchsets found for patch request: %d", prID)
134 }
135 return patchsets, nil
136}
137
138func (pr PrCmd) GetPatchsetByID(patchsetID int64) (*Patchset, error) {
139 var patchset Patchset
140 err := pr.Backend.DB.Get(
141 &patchset,
142 "SELECT * FROM patchsets WHERE id=?",
143 patchsetID,
144 )
145 return &patchset, err
146}
147
148func (pr PrCmd) GetLatestPatchsetByPrID(prID int64) (*Patchset, error) {
149 patchsets, err := pr.GetPatchsetsByPrID(prID)
150 if err != nil {
151 return nil, err
152 }
153 if len(patchsets) == 0 {
154 return nil, fmt.Errorf("no patchsets found for patch request: %d", prID)
155 }
156 return patchsets[len(patchsets)-1], nil
157}
158
159func (pr PrCmd) GetPatchesByPatchsetID(patchsetID int64) ([]*Patch, error) {
160 patches := []*Patch{}
161 err := pr.Backend.DB.Select(
162 &patches,
163 "SELECT * FROM patches WHERE patchset_id=? ORDER BY created_at ASC, id ASC",
164 patchsetID,
165 )
166 return patches, err
167}
168
169func (cmd PrCmd) GetPatchRequests() ([]*PatchRequest, error) {
170 prs := []*PatchRequest{}
171 err := cmd.Backend.DB.Select(
172 &prs,
173 "SELECT * FROM patch_requests ORDER BY id DESC",
174 )
175 return prs, err
176}
177
178func (cmd PrCmd) GetPatchRequestsByStatus(status Status) ([]*PatchRequest, error) {
179 prs := []*PatchRequest{}
180 err := cmd.Backend.DB.Select(
181 &prs,
182 "SELECT * FROM patch_requests WHERE status=? ORDER BY last_activity DESC",
183 status,
184 )
185 return prs, err
186}
187
188func (cmd PrCmd) GetPatchRequestsActive() ([]*PatchRequest, error) {
189 prs := []*PatchRequest{}
190 err := cmd.Backend.DB.Select(
191 &prs,
192 "SELECT * FROM patch_requests WHERE status='open' AND last_activity >= datetime('now', '-14 days') ORDER BY last_activity DESC",
193 )
194 return prs, err
195}
196
197func (cmd PrCmd) GetPatchRequestsInactive() ([]*PatchRequest, error) {
198 prs := []*PatchRequest{}
199 err := cmd.Backend.DB.Select(
200 &prs,
201 "SELECT * FROM patch_requests WHERE status='open' AND last_activity < datetime('now', '-14 days') ORDER BY last_activity DESC",
202 )
203 return prs, err
204}
205
206func (cmd PrCmd) GetPatchRequestsByRepoName(repoName string) ([]*PatchRequest, error) {
207 prs := []*PatchRequest{}
208 err := cmd.Backend.DB.Select(
209 &prs,
210 "SELECT * FROM patch_requests WHERE repo_name=? ORDER BY id DESC",
211 repoName,
212 )
213 return prs, err
214}
215
216func (cmd PrCmd) GetPatchRequestsByPubkey(pubkey string) ([]*PatchRequest, error) {
217 prs := []*PatchRequest{}
218 err := cmd.Backend.DB.Select(
219 &prs,
220 "SELECT pr.* FROM patch_requests pr, app_users au WHERE pr.user_id=au.id AND au.pubkey=? ORDER BY id DESC",
221 pubkey,
222 )
223 return prs, err
224}
225
226func (cmd PrCmd) GetPatchRequestByID(prID int64) (*PatchRequest, error) {
227 pr := PatchRequest{}
228 err := cmd.Backend.DB.Get(
229 &pr,
230 "SELECT * FROM patch_requests WHERE id=? ORDER BY created_at DESC",
231 prID,
232 )
233 return &pr, err
234}
235
236func (cmd PrCmd) updateLastActivity(prID int64) error {
237 _, err := cmd.Backend.DB.Exec(
238 "UPDATE patch_requests SET last_activity=? WHERE id=?",
239 time.Now(),
240 prID,
241 )
242 return err
243}
244
245// UpdatePatchRequestStatus changes the PR status. Only the PR creator (by pubkey) can do this.
246func (cmd PrCmd) UpdatePatchRequestStatus(prID int64, userPubkey string, status Status, comment string) error {
247 pr, err := cmd.GetPatchRequestByID(prID)
248 if err != nil {
249 return err
250 }
251
252 // Verify the requester is the PR creator
253 owner, err := cmd.GetUserByID(pr.UserID)
254 if err != nil {
255 return err
256 }
257 if owner.Pubkey != userPubkey {
258 return ErrNotPrOwner
259 }
260
261 tx, err := cmd.Backend.DB.Beginx()
262 if err != nil {
263 return err
264 }
265 defer func() {
266 _ = tx.Rollback()
267 }()
268
269 _, err = tx.Exec(
270 "UPDATE patch_requests SET status=? WHERE id=?",
271 status,
272 prID,
273 )
274 if err != nil {
275 return err
276 }
277
278 err = cmd.CreateEventLog(tx, EventLog{
279 UserID: pr.UserID,
280 PatchRequestID: sql.NullInt64{Int64: prID, Valid: true},
281 Event: "pr_status_changed",
282 Data: EventData{
283 Status: status,
284 Comment: comment,
285 },
286 })
287 if err != nil {
288 return err
289 }
290
291 err = tx.Commit()
292 if err != nil {
293 return err
294 }
295
296 return cmd.updateLastActivity(prID)
297}
298
299// UpdatePatchRequestName changes the PR title. Only the PR creator (by pubkey) can do this.
300func (cmd PrCmd) UpdatePatchRequestName(prID int64, userPubkey string, name string) error {
301 if name == "" {
302 return fmt.Errorf("must provide name in order to update patch request")
303 }
304
305 pr, err := cmd.GetPatchRequestByID(prID)
306 if err != nil {
307 return err
308 }
309
310 // Verify the requester is the PR creator
311 owner, err := cmd.GetUserByID(pr.UserID)
312 if err != nil {
313 return err
314 }
315 if owner.Pubkey != userPubkey {
316 return ErrNotPrOwner
317 }
318
319 tx, err := cmd.Backend.DB.Beginx()
320 if err != nil {
321 return err
322 }
323 defer func() {
324 _ = tx.Rollback()
325 }()
326
327 _, err = tx.Exec(
328 "UPDATE patch_requests SET name=? WHERE id=?",
329 name,
330 prID,
331 )
332 if err != nil {
333 return err
334 }
335
336 err = cmd.CreateEventLog(tx, EventLog{
337 UserID: pr.UserID,
338 PatchRequestID: sql.NullInt64{Int64: prID, Valid: true},
339 Event: "pr_name_changed",
340 Data: EventData{
341 Name: name,
342 },
343 })
344 if err != nil {
345 return err
346 }
347
348 err = tx.Commit()
349 if err != nil {
350 return err
351 }
352
353 return cmd.updateLastActivity(prID)
354}
355
356func (cmd PrCmd) CreateEventLog(tx *sqlx.Tx, eventLog EventLog) error {
357 _, err := tx.Exec(
358 "INSERT INTO event_logs (user_id, patch_request_id, patchset_id, event, data) VALUES (?, ?, ?, ?, ?)",
359 eventLog.UserID,
360 eventLog.PatchRequestID.Int64,
361 eventLog.PatchsetID.Int64,
362 eventLog.Event,
363 eventLog.Data,
364 )
365 if err != nil {
366 cmd.Backend.Logger.Error(
367 "could not create eventLog",
368 "err", err,
369 )
370 }
371 return err
372}
373
374func (cmd PrCmd) createPatch(tx *sqlx.Tx, patch *Patch) (int64, error) {
375 patchExists := []Patch{}
376 _ = cmd.Backend.DB.Select(&patchExists, "SELECT * FROM patches WHERE patchset_id=? AND content_sha=?", patch.PatchsetID, patch.ContentSha)
377 if len(patchExists) > 0 {
378 return 0, ErrPatchExists
379 }
380
381 var patchID int64
382 row := tx.QueryRow(
383 "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",
384 patch.UserID,
385 patch.PatchsetID,
386 patch.AuthorName,
387 patch.AuthorEmail,
388 patch.AuthorDate,
389 patch.Title,
390 patch.Body,
391 patch.BodyAppendix,
392 patch.CommitSha,
393 patch.ContentSha,
394 patch.BaseCommitSha,
395 patch.RawText,
396 )
397 err := row.Scan(&patchID)
398 if err != nil {
399 return 0, err
400 }
401 if patchID == 0 {
402 return 0, fmt.Errorf("could not create patch")
403 }
404 return patchID, err
405}
406
407// SubmitPatchRequest creates a new patch request with draft status.
408func (cmd PrCmd) SubmitPatchRequest(userID int64, userPubkey string, repoName string, patchset io.Reader) (*PatchRequest, error) {
409 tx, err := cmd.Backend.DB.Beginx()
410 if err != nil {
411 return nil, err
412 }
413
414 defer func() {
415 _ = tx.Rollback()
416 }()
417
418 patches, err := ParsePatchset(patchset)
419 if err != nil {
420 return nil, err
421 }
422
423 if len(patches) == 0 {
424 return nil, fmt.Errorf("after parsing patchset we didn't find any patches, did you send us an empty patchset?")
425 }
426
427 prName := ""
428 prText := ""
429 if len(patches) > 0 {
430 prName = patches[0].Title
431 prText = patches[0].Body
432 }
433
434 now := time.Now()
435 var prID int64
436 row := tx.QueryRow(
437 "INSERT INTO patch_requests (user_id, repo_name, name, text, status, updated_at, last_activity) VALUES(?, ?, ?, ?, ?, ?, ?) RETURNING id",
438 userID,
439 repoName,
440 prName,
441 prText,
442 StatusDraft,
443 now,
444 now,
445 )
446 err = row.Scan(&prID)
447 if err != nil {
448 return nil, err
449 }
450 if prID == 0 {
451 return nil, fmt.Errorf("could not create patch request")
452 }
453
454 var patchsetID int64
455 row = tx.QueryRow(
456 "INSERT INTO patchsets (user_id, patch_request_id) VALUES(?, ?) RETURNING id",
457 userID,
458 prID,
459 )
460 err = row.Scan(&patchsetID)
461 if err != nil {
462 return nil, err
463 }
464 if patchsetID == 0 {
465 return nil, fmt.Errorf("could not create patchset")
466 }
467
468 for _, patch := range patches {
469 patch.UserID = userID
470 patch.PatchsetID = patchsetID
471 _, err = cmd.createPatch(tx, patch)
472 if err != nil {
473 return nil, err
474 }
475 }
476
477 err = cmd.CreateEventLog(tx, EventLog{
478 UserID: userID,
479 PatchRequestID: sql.NullInt64{Int64: prID, Valid: true},
480 PatchsetID: sql.NullInt64{Int64: patchsetID, Valid: true},
481 Event: "pr_created",
482 })
483 if err != nil {
484 return nil, err
485 }
486
487 err = tx.Commit()
488 if err != nil {
489 return nil, err
490 }
491
492 var pr PatchRequest
493 err = cmd.Backend.DB.Get(&pr, "SELECT * FROM patch_requests WHERE id=?", prID)
494 return &pr, err
495}
496
497// SubmitIssue creates a new patch request as an issue (text-only, no patches, starts open).
498// The title is the issue subject, body is the full description.
499func (cmd PrCmd) SubmitIssue(userID int64, userPubkey string, repoName, title, body string) (*PatchRequest, error) {
500 if title == "" {
501 return nil, fmt.Errorf("must provide a title for the issue")
502 }
503
504 tx, err := cmd.Backend.DB.Beginx()
505 if err != nil {
506 return nil, err
507 }
508
509 defer func() {
510 _ = tx.Rollback()
511 }()
512
513 now := time.Now()
514 var prID int64
515 row := tx.QueryRow(
516 "INSERT INTO patch_requests (user_id, repo_name, name, text, status, updated_at, last_activity) VALUES(?, ?, ?, ?, ?, ?, ?) RETURNING id",
517 userID,
518 repoName,
519 title,
520 body,
521 StatusOpen,
522 now,
523 now,
524 )
525 err = row.Scan(&prID)
526 if err != nil {
527 return nil, err
528 }
529 if prID == 0 {
530 return nil, fmt.Errorf("could not create issue")
531 }
532
533 // Create an empty initial patchset so the PR has a patchset for the timeline.
534 // Patches can be added later with `pr add`.
535 var patchsetID int64
536 row = tx.QueryRow(
537 "INSERT INTO patchsets (user_id, patch_request_id) VALUES(?, ?) RETURNING id",
538 userID,
539 prID,
540 )
541 err = row.Scan(&patchsetID)
542 if err != nil {
543 return nil, err
544 }
545 if patchsetID == 0 {
546 return nil, fmt.Errorf("could not create patchset")
547 }
548
549 err = cmd.CreateEventLog(tx, EventLog{
550 UserID: userID,
551 PatchRequestID: sql.NullInt64{Int64: prID, Valid: true},
552 PatchsetID: sql.NullInt64{Int64: patchsetID, Valid: true},
553 Event: "pr_created",
554 })
555 if err != nil {
556 return nil, err
557 }
558
559 err = tx.Commit()
560 if err != nil {
561 return nil, err
562 }
563
564 var pr PatchRequest
565 err = cmd.Backend.DB.Get(&pr, "SELECT * FROM patch_requests WHERE id=?", prID)
566 return &pr, err
567}
568
569func (cmd PrCmd) SubmitPatchset(prID int64, userID int64, op PatchsetOp, patchset io.Reader) ([]*Patch, error) {
570 fin := []*Patch{}
571 tx, err := cmd.Backend.DB.Beginx()
572 if err != nil {
573 return fin, err
574 }
575
576 defer func() {
577 _ = tx.Rollback()
578 }()
579
580 patches, err := ParsePatchset(patchset)
581 if err != nil {
582 return fin, err
583 }
584
585 var patchsetID int64
586 row := tx.QueryRow(
587 "INSERT INTO patchsets (user_id, patch_request_id) VALUES(?, ?) RETURNING id",
588 userID,
589 prID,
590 )
591 err = row.Scan(&patchsetID)
592 if err != nil {
593 return nil, err
594 }
595 if patchsetID == 0 {
596 return nil, fmt.Errorf("could not create patchset")
597 }
598
599 for _, patch := range patches {
600 patch.UserID = userID
601 patch.PatchsetID = patchsetID
602 patchID, err := cmd.createPatch(tx, patch)
603 if err == nil {
604 patch.ID = patchID
605 fin = append(fin, patch)
606 } else {
607 if !errors.Is(ErrPatchExists, err) {
608 return fin, err
609 }
610 }
611 }
612
613 if len(fin) > 0 {
614 err = cmd.CreateEventLog(tx, EventLog{
615 UserID: userID,
616 PatchRequestID: sql.NullInt64{Int64: prID, Valid: true},
617 PatchsetID: sql.NullInt64{Int64: patchsetID, Valid: true},
618 Event: "pr_patchset_added",
619 })
620 if err != nil {
621 return fin, err
622 }
623 }
624
625 err = tx.Commit()
626 if err != nil {
627 return fin, err
628 }
629
630 // Update last_activity
631 if err := cmd.updateLastActivity(prID); err != nil {
632 cmd.Backend.Logger.Error("failed to update last_activity", "err", err, "prID", prID)
633 }
634
635 return fin, nil
636}
637
638func (cmd PrCmd) DeletePatchsetByID(userID int64, prID int64, patchsetID int64) error {
639 tx, err := cmd.Backend.DB.Beginx()
640 if err != nil {
641 return err
642 }
643
644 defer func() {
645 _ = tx.Rollback()
646 }()
647
648 _, err = tx.Exec(
649 "DELETE FROM patchsets WHERE id=?",
650 patchsetID,
651 )
652 if err != nil {
653 return err
654 }
655
656 err = cmd.CreateEventLog(tx, EventLog{
657 UserID: userID,
658 PatchRequestID: sql.NullInt64{Int64: prID, Valid: true},
659 PatchsetID: sql.NullInt64{Int64: patchsetID, Valid: true},
660 Event: "pr_patchset_deleted",
661 })
662 if err != nil {
663 return err
664 }
665
666 err = tx.Commit()
667 if err != nil {
668 return err
669 }
670
671 return cmd.updateLastActivity(prID)
672}
673
674func (cmd PrCmd) GetEventLogs() ([]*EventLog, error) {
675 eventLogs := []*EventLog{}
676 err := cmd.Backend.DB.Select(
677 &eventLogs,
678 "SELECT * FROM event_logs ORDER BY created_at DESC",
679 )
680 return eventLogs, err
681}
682
683func (cmd PrCmd) GetEventLogsByPrID(prID int64) ([]*EventLog, error) {
684 eventLogs := []*EventLog{}
685 err := cmd.Backend.DB.Select(
686 &eventLogs,
687 "SELECT * FROM event_logs WHERE patch_request_id=? ORDER BY created_at DESC",
688 prID,
689 )
690 return eventLogs, err
691}
692
693func (cmd PrCmd) GetEventLogsByUserID(userID int64) ([]*EventLog, error) {
694 eventLogs := []*EventLog{}
695 query := `SELECT * FROM event_logs
696 WHERE user_id=?
697 OR patch_request_id IN (
698 SELECT id FROM patch_requests WHERE user_id=?
699 )
700 ORDER BY created_at DESC`
701 err := cmd.Backend.DB.Select(
702 &eventLogs,
703 query,
704 userID,
705 userID,
706 )
707 return eventLogs, err
708}
709
710func (cmd PrCmd) DiffPatchsets(prev *Patchset, next *Patchset) ([]*RangeDiffOutput, error) {
711 output := []*RangeDiffOutput{}
712 patches, err := cmd.GetPatchesByPatchsetID(next.ID)
713 if err != nil {
714 return output, err
715 }
716
717 for idx, patch := range patches {
718 patchStr := patch.RawText
719 if idx > 0 {
720 patchStr = startOfPatch + patch.RawText
721 }
722 diffFiles, _, err := ParsePatch(patchStr)
723 if err != nil {
724 continue
725 }
726 patch.Files = diffFiles
727 }
728
729 if prev == nil {
730 return output, nil
731 }
732
733 prevPatches, err := cmd.GetPatchesByPatchsetID(prev.ID)
734 if err != nil {
735 return output, fmt.Errorf("cannot get previous patchset patches: %w", err)
736 }
737
738 for idx, patch := range prevPatches {
739 patchStr := patch.RawText
740 if idx > 0 {
741 patchStr = startOfPatch + patch.RawText
742 }
743 diffFiles, _, err := ParsePatch(patchStr)
744 if err != nil {
745 continue
746 }
747 patch.Files = diffFiles
748 }
749
750 return RangeDiff(prevPatches, patches), nil
751}