repos / git-pr

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

jolheiser  ·  2025-08-21

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