repos / git-pr

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

Eric Bower  ·  2026-02-25

pr.go

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