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}