1package db
2
3import (
4 "context"
5 "database/sql"
6 "fmt"
7 "log"
8 "sort"
9 "strings"
10 "time"
11
12 "github.com/bluekeyes/go-gitdiff/gitdiff"
13 "github.com/bluesky-social/indigo/atproto/syntax"
14 "go.opentelemetry.io/otel/attribute"
15 "go.opentelemetry.io/otel/trace"
16 "tangled.sh/tangled.sh/core/api/tangled"
17 "tangled.sh/tangled.sh/core/patchutil"
18 "tangled.sh/tangled.sh/core/types"
19)
20
21type PullState int
22
23const (
24 PullClosed PullState = iota
25 PullOpen
26 PullMerged
27)
28
29func (p PullState) String() string {
30 switch p {
31 case PullOpen:
32 return "open"
33 case PullMerged:
34 return "merged"
35 case PullClosed:
36 return "closed"
37 default:
38 return "closed"
39 }
40}
41
42func (p PullState) IsOpen() bool {
43 return p == PullOpen
44}
45func (p PullState) IsMerged() bool {
46 return p == PullMerged
47}
48func (p PullState) IsClosed() bool {
49 return p == PullClosed
50}
51
52type Pull struct {
53 // ids
54 ID int
55 PullId int
56
57 // at ids
58 RepoAt syntax.ATURI
59 OwnerDid string
60 Rkey string
61
62 // content
63 Title string
64 Body string
65 TargetBranch string
66 State PullState
67 Submissions []*PullSubmission
68
69 // meta
70 Created time.Time
71 PullSource *PullSource
72
73 // optionally, populate this when querying for reverse mappings
74 Repo *Repo
75}
76
77type PullSource struct {
78 Branch string
79 RepoAt *syntax.ATURI
80
81 // optionally populate this for reverse mappings
82 Repo *Repo
83}
84
85type PullSubmission struct {
86 // ids
87 ID int
88 PullId int
89
90 // at ids
91 RepoAt syntax.ATURI
92
93 // content
94 RoundNumber int
95 Patch string
96 Comments []PullComment
97 SourceRev string // include the rev that was used to create this submission: only for branch PRs
98
99 // meta
100 Created time.Time
101}
102
103type PullComment struct {
104 // ids
105 ID int
106 PullId int
107 SubmissionId int
108
109 // at ids
110 RepoAt string
111 OwnerDid string
112 CommentAt string
113
114 // content
115 Body string
116
117 // meta
118 Created time.Time
119}
120
121func (p *Pull) LatestPatch() string {
122 latestSubmission := p.Submissions[p.LastRoundNumber()]
123 return latestSubmission.Patch
124}
125
126func (p *Pull) PullAt() syntax.ATURI {
127 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey))
128}
129
130func (p *Pull) LastRoundNumber() int {
131 return len(p.Submissions) - 1
132}
133
134func (p *Pull) IsPatchBased() bool {
135 return p.PullSource == nil
136}
137
138func (p *Pull) IsBranchBased() bool {
139 if p.PullSource != nil {
140 if p.PullSource.RepoAt != nil {
141 return p.PullSource.RepoAt == &p.RepoAt
142 } else {
143 // no repo specified
144 return true
145 }
146 }
147 return false
148}
149
150func (p *Pull) IsForkBased() bool {
151 if p.PullSource != nil {
152 if p.PullSource.RepoAt != nil {
153 // make sure repos are different
154 return p.PullSource.RepoAt != &p.RepoAt
155 }
156 }
157 return false
158}
159
160func (s PullSubmission) AsDiff(targetBranch string) ([]*gitdiff.File, error) {
161 patch := s.Patch
162
163 // if format-patch; then extract each patch
164 var diffs []*gitdiff.File
165 if patchutil.IsFormatPatch(patch) {
166 patches, err := patchutil.ExtractPatches(patch)
167 if err != nil {
168 return nil, err
169 }
170 var ps [][]*gitdiff.File
171 for _, p := range patches {
172 ps = append(ps, p.Files)
173 }
174
175 diffs = patchutil.CombineDiff(ps...)
176 } else {
177 d, _, err := gitdiff.Parse(strings.NewReader(patch))
178 if err != nil {
179 return nil, err
180 }
181 diffs = d
182 }
183
184 return diffs, nil
185}
186
187func (s PullSubmission) AsNiceDiff(targetBranch string) types.NiceDiff {
188 diffs, err := s.AsDiff(targetBranch)
189 if err != nil {
190 log.Println(err)
191 }
192
193 nd := types.NiceDiff{}
194 nd.Commit.Parent = targetBranch
195
196 for _, d := range diffs {
197 ndiff := types.Diff{}
198 ndiff.Name.New = d.NewName
199 ndiff.Name.Old = d.OldName
200 ndiff.IsBinary = d.IsBinary
201 ndiff.IsNew = d.IsNew
202 ndiff.IsDelete = d.IsDelete
203 ndiff.IsCopy = d.IsCopy
204 ndiff.IsRename = d.IsRename
205
206 for _, tf := range d.TextFragments {
207 ndiff.TextFragments = append(ndiff.TextFragments, *tf)
208 for _, l := range tf.Lines {
209 switch l.Op {
210 case gitdiff.OpAdd:
211 nd.Stat.Insertions += 1
212 case gitdiff.OpDelete:
213 nd.Stat.Deletions += 1
214 }
215 }
216 }
217
218 nd.Diff = append(nd.Diff, ndiff)
219 }
220
221 nd.Stat.FilesChanged = len(diffs)
222
223 return nd
224}
225
226func (s PullSubmission) IsFormatPatch() bool {
227 return patchutil.IsFormatPatch(s.Patch)
228}
229
230func (s PullSubmission) AsFormatPatch() []patchutil.FormatPatch {
231 patches, err := patchutil.ExtractPatches(s.Patch)
232 if err != nil {
233 log.Println("error extracting patches from submission:", err)
234 return []patchutil.FormatPatch{}
235 }
236
237 return patches
238}
239
240func NewPull(ctx context.Context, tx *sql.Tx, pull *Pull) error {
241 span := trace.SpanFromContext(ctx)
242 defer span.End()
243
244 span.SetAttributes(
245 attribute.String("repo.at", pull.RepoAt.String()),
246 attribute.String("owner.did", pull.OwnerDid),
247 attribute.String("title", pull.Title),
248 attribute.String("target_branch", pull.TargetBranch),
249 )
250 span.AddEvent("creating new pull request")
251
252 defer tx.Rollback()
253
254 _, err := tx.Exec(`
255 insert or ignore into repo_pull_seqs (repo_at, next_pull_id)
256 values (?, 1)
257 `, pull.RepoAt)
258 if err != nil {
259 span.RecordError(err)
260 return err
261 }
262
263 var nextId int
264 err = tx.QueryRow(`
265 update repo_pull_seqs
266 set next_pull_id = next_pull_id + 1
267 where repo_at = ?
268 returning next_pull_id - 1
269 `, pull.RepoAt).Scan(&nextId)
270 if err != nil {
271 span.RecordError(err)
272 return err
273 }
274
275 pull.PullId = nextId
276 pull.State = PullOpen
277
278 span.SetAttributes(attribute.Int("pull.id", pull.PullId))
279 span.AddEvent("assigned pull ID")
280
281 var sourceBranch, sourceRepoAt *string
282 if pull.PullSource != nil {
283 sourceBranch = &pull.PullSource.Branch
284 if pull.PullSource.RepoAt != nil {
285 x := pull.PullSource.RepoAt.String()
286 sourceRepoAt = &x
287 }
288 }
289
290 _, err = tx.Exec(
291 `
292 insert into pulls (repo_at, owner_did, pull_id, title, target_branch, body, rkey, state, source_branch, source_repo_at)
293 values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
294 pull.RepoAt,
295 pull.OwnerDid,
296 pull.PullId,
297 pull.Title,
298 pull.TargetBranch,
299 pull.Body,
300 pull.Rkey,
301 pull.State,
302 sourceBranch,
303 sourceRepoAt,
304 )
305 if err != nil {
306 span.RecordError(err)
307 return err
308 }
309
310 span.AddEvent("inserted pull record")
311
312 _, err = tx.Exec(`
313 insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
314 values (?, ?, ?, ?, ?)
315 `, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev)
316 if err != nil {
317 span.RecordError(err)
318 return err
319 }
320
321 span.AddEvent("inserted initial pull submission")
322
323 if err := tx.Commit(); err != nil {
324 span.RecordError(err)
325 return err
326 }
327
328 span.AddEvent("transaction committed successfully")
329 return nil
330}
331
332func GetPullAt(ctx context.Context, e Execer, repoAt syntax.ATURI, pullId int) (syntax.ATURI, error) {
333 pull, err := GetPull(ctx, e, repoAt, pullId)
334 if err != nil {
335 return "", err
336 }
337 return pull.PullAt(), err
338}
339
340func NextPullId(e Execer, repoAt syntax.ATURI) (int, error) {
341 var pullId int
342 err := e.QueryRow(`select next_pull_id from repo_pull_seqs where repo_at = ?`, repoAt).Scan(&pullId)
343 return pullId - 1, err
344}
345
346func GetPulls(ctx context.Context, e Execer, repoAt syntax.ATURI, state PullState) ([]*Pull, error) {
347 span := trace.SpanFromContext(ctx)
348 defer span.End()
349
350 span.SetAttributes(
351 attribute.String("repoAt", repoAt.String()),
352 attribute.String("state", state.String()),
353 )
354 span.AddEvent("querying pulls")
355
356 pulls := make(map[int]*Pull)
357
358 rows, err := e.QueryContext(ctx, `
359 select
360 owner_did,
361 pull_id,
362 created,
363 title,
364 state,
365 target_branch,
366 body,
367 rkey,
368 source_branch,
369 source_repo_at
370 from
371 pulls
372 where
373 repo_at = ? and state = ?`, repoAt, state)
374 if err != nil {
375 span.RecordError(err)
376 return nil, err
377 }
378 defer rows.Close()
379
380 for rows.Next() {
381 var pull Pull
382 var createdAt string
383 var sourceBranch, sourceRepoAt sql.NullString
384 err := rows.Scan(
385 &pull.OwnerDid,
386 &pull.PullId,
387 &createdAt,
388 &pull.Title,
389 &pull.State,
390 &pull.TargetBranch,
391 &pull.Body,
392 &pull.Rkey,
393 &sourceBranch,
394 &sourceRepoAt,
395 )
396 if err != nil {
397 span.RecordError(err)
398 return nil, err
399 }
400
401 createdTime, err := time.Parse(time.RFC3339, createdAt)
402 if err != nil {
403 span.RecordError(err)
404 return nil, err
405 }
406 pull.Created = createdTime
407
408 if sourceBranch.Valid {
409 pull.PullSource = &PullSource{
410 Branch: sourceBranch.String,
411 }
412 if sourceRepoAt.Valid {
413 sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String)
414 if err != nil {
415 span.RecordError(err)
416 return nil, err
417 }
418 pull.PullSource.RepoAt = &sourceRepoAtParsed
419 }
420 }
421
422 pulls[pull.PullId] = &pull
423 }
424
425 span.AddEvent("querying pull submissions")
426 span.SetAttributes(attribute.Int("pull_count", len(pulls)))
427
428 // get latest round no. for each pull
429 inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
430 submissionsQuery := fmt.Sprintf(`
431 select
432 id, pull_id, round_number
433 from
434 pull_submissions
435 where
436 repo_at = ? and pull_id in (%s)
437 `, inClause)
438
439 args := make([]any, len(pulls)+1)
440 args[0] = repoAt.String()
441 idx := 1
442 for _, p := range pulls {
443 args[idx] = p.PullId
444 idx += 1
445 }
446 submissionsRows, err := e.QueryContext(ctx, submissionsQuery, args...)
447 if err != nil {
448 span.RecordError(err)
449 return nil, err
450 }
451 defer submissionsRows.Close()
452
453 for submissionsRows.Next() {
454 var s PullSubmission
455 err := submissionsRows.Scan(
456 &s.ID,
457 &s.PullId,
458 &s.RoundNumber,
459 )
460 if err != nil {
461 span.RecordError(err)
462 return nil, err
463 }
464
465 if p, ok := pulls[s.PullId]; ok {
466 p.Submissions = make([]*PullSubmission, s.RoundNumber+1)
467 p.Submissions[s.RoundNumber] = &s
468 }
469 }
470 if err := rows.Err(); err != nil {
471 span.RecordError(err)
472 return nil, err
473 }
474
475 span.AddEvent("querying pull comments")
476
477 // get comment count on latest submission on each pull
478 inClause = strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
479 commentsQuery := fmt.Sprintf(`
480 select
481 count(id), pull_id
482 from
483 pull_comments
484 where
485 submission_id in (%s)
486 group by
487 submission_id
488 `, inClause)
489
490 args = []any{}
491 for _, p := range pulls {
492 args = append(args, p.Submissions[p.LastRoundNumber()].ID)
493 }
494 commentsRows, err := e.QueryContext(ctx, commentsQuery, args...)
495 if err != nil {
496 span.RecordError(err)
497 return nil, err
498 }
499 defer commentsRows.Close()
500
501 for commentsRows.Next() {
502 var commentCount, pullId int
503 err := commentsRows.Scan(
504 &commentCount,
505 &pullId,
506 )
507 if err != nil {
508 span.RecordError(err)
509 return nil, err
510 }
511 if p, ok := pulls[pullId]; ok {
512 p.Submissions[p.LastRoundNumber()].Comments = make([]PullComment, commentCount)
513 }
514 }
515 if err := rows.Err(); err != nil {
516 span.RecordError(err)
517 return nil, err
518 }
519
520 span.AddEvent("sorting pulls by date")
521
522 orderedByDate := []*Pull{}
523 for _, p := range pulls {
524 orderedByDate = append(orderedByDate, p)
525 }
526 sort.Slice(orderedByDate, func(i, j int) bool {
527 return orderedByDate[i].Created.After(orderedByDate[j].Created)
528 })
529
530 span.SetAttributes(attribute.Int("result_count", len(orderedByDate)))
531 return orderedByDate, nil
532}
533
534func GetPull(ctx context.Context, e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) {
535 span := trace.SpanFromContext(ctx)
536 defer span.End()
537
538 span.SetAttributes(attribute.String("repoAt", repoAt.String()), attribute.Int("pull.id", pullId))
539 span.AddEvent("query pull metadata")
540
541 query := `
542 select
543 owner_did,
544 pull_id,
545 created,
546 title,
547 state,
548 target_branch,
549 repo_at,
550 body,
551 rkey,
552 source_branch,
553 source_repo_at
554 from
555 pulls
556 where
557 repo_at = ? and pull_id = ?
558 `
559 row := e.QueryRowContext(ctx, query, repoAt, pullId)
560
561 var pull Pull
562 var createdAt string
563 var sourceBranch, sourceRepoAt sql.NullString
564 err := row.Scan(
565 &pull.OwnerDid,
566 &pull.PullId,
567 &createdAt,
568 &pull.Title,
569 &pull.State,
570 &pull.TargetBranch,
571 &pull.RepoAt,
572 &pull.Body,
573 &pull.Rkey,
574 &sourceBranch,
575 &sourceRepoAt,
576 )
577 if err != nil {
578 span.RecordError(err)
579 return nil, err
580 }
581
582 createdTime, err := time.Parse(time.RFC3339, createdAt)
583 if err != nil {
584 span.RecordError(err)
585 return nil, err
586 }
587 pull.Created = createdTime
588
589 if sourceBranch.Valid {
590 pull.PullSource = &PullSource{
591 Branch: sourceBranch.String,
592 }
593 if sourceRepoAt.Valid {
594 sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String)
595 if err != nil {
596 span.RecordError(err)
597 return nil, err
598 }
599 pull.PullSource.RepoAt = &sourceRepoAtParsed
600 }
601 }
602
603 span.AddEvent("query submissions")
604 submissionsQuery := `
605 select
606 id, pull_id, repo_at, round_number, patch, created, source_rev
607 from
608 pull_submissions
609 where
610 repo_at = ? and pull_id = ?
611 `
612 submissionsRows, err := e.QueryContext(ctx, submissionsQuery, repoAt, pullId)
613 if err != nil {
614 span.RecordError(err)
615 return nil, err
616 }
617 defer submissionsRows.Close()
618
619 submissionsMap := make(map[int]*PullSubmission)
620
621 for submissionsRows.Next() {
622 var submission PullSubmission
623 var submissionCreatedStr string
624 var submissionSourceRev sql.NullString
625 err := submissionsRows.Scan(
626 &submission.ID,
627 &submission.PullId,
628 &submission.RepoAt,
629 &submission.RoundNumber,
630 &submission.Patch,
631 &submissionCreatedStr,
632 &submissionSourceRev,
633 )
634 if err != nil {
635 span.RecordError(err)
636 return nil, err
637 }
638
639 submissionCreatedTime, err := time.Parse(time.RFC3339, submissionCreatedStr)
640 if err != nil {
641 span.RecordError(err)
642 return nil, err
643 }
644 submission.Created = submissionCreatedTime
645
646 if submissionSourceRev.Valid {
647 submission.SourceRev = submissionSourceRev.String
648 }
649
650 submissionsMap[submission.ID] = &submission
651 }
652 if err = submissionsRows.Close(); err != nil {
653 span.RecordError(err)
654 return nil, err
655 }
656 if len(submissionsMap) == 0 {
657 return &pull, nil
658 }
659
660 var args []any
661 for k := range submissionsMap {
662 args = append(args, k)
663 }
664 inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ")
665
666 span.AddEvent("query comments")
667 commentsQuery := fmt.Sprintf(`
668 select
669 id,
670 pull_id,
671 submission_id,
672 repo_at,
673 owner_did,
674 comment_at,
675 body,
676 created
677 from
678 pull_comments
679 where
680 submission_id IN (%s)
681 order by
682 created asc
683 `, inClause)
684 commentsRows, err := e.QueryContext(ctx, commentsQuery, args...)
685 if err != nil {
686 span.RecordError(err)
687 return nil, err
688 }
689 defer commentsRows.Close()
690
691 for commentsRows.Next() {
692 var comment PullComment
693 var commentCreatedStr string
694 err := commentsRows.Scan(
695 &comment.ID,
696 &comment.PullId,
697 &comment.SubmissionId,
698 &comment.RepoAt,
699 &comment.OwnerDid,
700 &comment.CommentAt,
701 &comment.Body,
702 &commentCreatedStr,
703 )
704 if err != nil {
705 span.RecordError(err)
706 return nil, err
707 }
708
709 commentCreatedTime, err := time.Parse(time.RFC3339, commentCreatedStr)
710 if err != nil {
711 span.RecordError(err)
712 return nil, err
713 }
714 comment.Created = commentCreatedTime
715
716 if submission, ok := submissionsMap[comment.SubmissionId]; ok {
717 submission.Comments = append(submission.Comments, comment)
718 }
719 }
720 if err = commentsRows.Err(); err != nil {
721 span.RecordError(err)
722 return nil, err
723 }
724
725 if pull.PullSource != nil && pull.PullSource.RepoAt != nil {
726 span.AddEvent("query pull source repo")
727 pullSourceRepo, err := GetRepoByAtUri(ctx, e, pull.PullSource.RepoAt.String())
728 if err != nil {
729 span.RecordError(err)
730 log.Printf("failed to get repo by at uri: %v", err)
731 } else {
732 pull.PullSource.Repo = pullSourceRepo
733 }
734 }
735
736 pull.Submissions = make([]*PullSubmission, len(submissionsMap))
737 for _, submission := range submissionsMap {
738 pull.Submissions[submission.RoundNumber] = submission
739 }
740
741 return &pull, nil
742}
743
744// timeframe here is directly passed into the sql query filter, and any
745// timeframe in the past should be negative; e.g.: "-3 months"
746func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]Pull, error) {
747 var pulls []Pull
748
749 rows, err := e.Query(`
750 select
751 p.owner_did,
752 p.repo_at,
753 p.pull_id,
754 p.created,
755 p.title,
756 p.state,
757 r.did,
758 r.name,
759 r.knot,
760 r.rkey,
761 r.created
762 from
763 pulls p
764 join
765 repos r on p.repo_at = r.at_uri
766 where
767 p.owner_did = ? and p.created >= date ('now', ?)
768 order by
769 p.created desc`, did, timeframe)
770 if err != nil {
771 return nil, err
772 }
773 defer rows.Close()
774
775 for rows.Next() {
776 var pull Pull
777 var repo Repo
778 var pullCreatedAt, repoCreatedAt string
779 err := rows.Scan(
780 &pull.OwnerDid,
781 &pull.RepoAt,
782 &pull.PullId,
783 &pullCreatedAt,
784 &pull.Title,
785 &pull.State,
786 &repo.Did,
787 &repo.Name,
788 &repo.Knot,
789 &repo.Rkey,
790 &repoCreatedAt,
791 )
792 if err != nil {
793 return nil, err
794 }
795
796 pullCreatedTime, err := time.Parse(time.RFC3339, pullCreatedAt)
797 if err != nil {
798 return nil, err
799 }
800 pull.Created = pullCreatedTime
801
802 repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt)
803 if err != nil {
804 return nil, err
805 }
806 repo.Created = repoCreatedTime
807
808 pull.Repo = &repo
809
810 pulls = append(pulls, pull)
811 }
812
813 if err := rows.Err(); err != nil {
814 return nil, err
815 }
816
817 return pulls, nil
818}
819
820func NewPullComment(ctx context.Context, e Execer, comment *PullComment) (int64, error) {
821 span := trace.SpanFromContext(ctx)
822 defer span.End()
823
824 span.SetAttributes(
825 attribute.String("repo.at", comment.RepoAt),
826 attribute.Int("pull.id", comment.PullId),
827 attribute.Int("submission.id", comment.SubmissionId),
828 attribute.String("owner.did", comment.OwnerDid),
829 )
830 span.AddEvent("inserting new pull comment")
831
832 query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)`
833 res, err := e.ExecContext(
834 ctx,
835 query,
836 comment.OwnerDid,
837 comment.RepoAt,
838 comment.SubmissionId,
839 comment.CommentAt,
840 comment.PullId,
841 comment.Body,
842 )
843 if err != nil {
844 span.RecordError(err)
845 return 0, err
846 }
847
848 i, err := res.LastInsertId()
849 if err != nil {
850 span.RecordError(err)
851 return 0, err
852 }
853
854 span.SetAttributes(attribute.Int64("comment.id", i))
855 span.AddEvent("pull comment created successfully")
856 return i, nil
857}
858
859func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState PullState) error {
860 _, err := e.Exec(`update pulls set state = ? where repo_at = ? and pull_id = ?`, pullState, repoAt, pullId)
861 return err
862}
863
864func ClosePull(e Execer, repoAt syntax.ATURI, pullId int) error {
865 err := SetPullState(e, repoAt, pullId, PullClosed)
866 return err
867}
868
869func ReopenPull(e Execer, repoAt syntax.ATURI, pullId int) error {
870 err := SetPullState(e, repoAt, pullId, PullOpen)
871 return err
872}
873
874func MergePull(e Execer, repoAt syntax.ATURI, pullId int) error {
875 err := SetPullState(e, repoAt, pullId, PullMerged)
876 return err
877}
878
879func ResubmitPull(e Execer, pull *Pull, newPatch, sourceRev string) error {
880 newRoundNumber := len(pull.Submissions)
881 _, err := e.Exec(`
882 insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
883 values (?, ?, ?, ?, ?)
884 `, pull.PullId, pull.RepoAt, newRoundNumber, newPatch, sourceRev)
885
886 return err
887}
888
889type PullCount struct {
890 Open int
891 Merged int
892 Closed int
893}
894
895func GetPullCount(e Execer, repoAt syntax.ATURI) (PullCount, error) {
896 row := e.QueryRow(`
897 select
898 count(case when state = ? then 1 end) as open_count,
899 count(case when state = ? then 1 end) as merged_count,
900 count(case when state = ? then 1 end) as closed_count
901 from pulls
902 where repo_at = ?`,
903 PullOpen,
904 PullMerged,
905 PullClosed,
906 repoAt,
907 )
908
909 var count PullCount
910 if err := row.Scan(&count.Open, &count.Merged, &count.Closed); err != nil {
911 return PullCount{0, 0, 0}, err
912 }
913
914 return count, nil
915}