1package db
2
3import (
4 "database/sql"
5 "errors"
6 "fmt"
7 "log"
8 "slices"
9 "strings"
10 "time"
11
12 "github.com/bluesky-social/indigo/atproto/syntax"
13 securejoin "github.com/cyphar/filepath-securejoin"
14 "tangled.sh/tangled.sh/core/api/tangled"
15)
16
17type Repo struct {
18 Did string
19 Name string
20 Knot string
21 Rkey string
22 Created time.Time
23 Description string
24 Spindle string
25 Labels []string
26
27 // optionally, populate this when querying for reverse mappings
28 RepoStats *RepoStats
29
30 // optional
31 Source string
32}
33
34func (r *Repo) AsRecord() tangled.Repo {
35 var source, spindle, description *string
36
37 if r.Source != "" {
38 source = &r.Source
39 }
40
41 if r.Spindle != "" {
42 spindle = &r.Spindle
43 }
44
45 if r.Description != "" {
46 description = &r.Description
47 }
48
49 return tangled.Repo{
50 Knot: r.Knot,
51 Name: r.Name,
52 Description: description,
53 CreatedAt: r.Created.Format(time.RFC3339),
54 Source: source,
55 Spindle: spindle,
56 Labels: r.Labels,
57 }
58}
59
60func (r Repo) RepoAt() syntax.ATURI {
61 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey))
62}
63
64func (r Repo) DidSlashRepo() string {
65 p, _ := securejoin.SecureJoin(r.Did, r.Name)
66 return p
67}
68
69func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) {
70 repoMap := make(map[syntax.ATURI]*Repo)
71
72 var conditions []string
73 var args []any
74 for _, filter := range filters {
75 conditions = append(conditions, filter.Condition())
76 args = append(args, filter.Arg()...)
77 }
78
79 whereClause := ""
80 if conditions != nil {
81 whereClause = " where " + strings.Join(conditions, " and ")
82 }
83
84 limitClause := ""
85 if limit != 0 {
86 limitClause = fmt.Sprintf(" limit %d", limit)
87 }
88
89 repoQuery := fmt.Sprintf(
90 `select
91 did,
92 name,
93 knot,
94 rkey,
95 created,
96 description,
97 source,
98 spindle
99 from
100 repos r
101 %s
102 order by created desc
103 %s`,
104 whereClause,
105 limitClause,
106 )
107 rows, err := e.Query(repoQuery, args...)
108
109 if err != nil {
110 return nil, fmt.Errorf("failed to execute repo query: %w ", err)
111 }
112
113 for rows.Next() {
114 var repo Repo
115 var createdAt string
116 var description, source, spindle sql.NullString
117
118 err := rows.Scan(
119 &repo.Did,
120 &repo.Name,
121 &repo.Knot,
122 &repo.Rkey,
123 &createdAt,
124 &description,
125 &source,
126 &spindle,
127 )
128 if err != nil {
129 return nil, fmt.Errorf("failed to execute repo query: %w ", err)
130 }
131
132 if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
133 repo.Created = t
134 }
135 if description.Valid {
136 repo.Description = description.String
137 }
138 if source.Valid {
139 repo.Source = source.String
140 }
141 if spindle.Valid {
142 repo.Spindle = spindle.String
143 }
144
145 repo.RepoStats = &RepoStats{}
146 repoMap[repo.RepoAt()] = &repo
147 }
148
149 if err = rows.Err(); err != nil {
150 return nil, fmt.Errorf("failed to execute repo query: %w ", err)
151 }
152
153 inClause := strings.TrimSuffix(strings.Repeat("?, ", len(repoMap)), ", ")
154 args = make([]any, len(repoMap))
155
156 i := 0
157 for _, r := range repoMap {
158 args[i] = r.RepoAt()
159 i++
160 }
161
162 // Get labels for all repos
163 labelsQuery := fmt.Sprintf(
164 `select repo_at, label_at from repo_labels where repo_at in (%s)`,
165 inClause,
166 )
167 rows, err = e.Query(labelsQuery, args...)
168 if err != nil {
169 return nil, fmt.Errorf("failed to execute labels query: %w ", err)
170 }
171 for rows.Next() {
172 var repoat, labelat string
173 if err := rows.Scan(&repoat, &labelat); err != nil {
174 log.Println("err", "err", err)
175 continue
176 }
177 if r, ok := repoMap[syntax.ATURI(repoat)]; ok {
178 r.Labels = append(r.Labels, labelat)
179 }
180 }
181 if err = rows.Err(); err != nil {
182 return nil, fmt.Errorf("failed to execute labels query: %w ", err)
183 }
184
185 languageQuery := fmt.Sprintf(
186 `
187 select
188 repo_at, language
189 from
190 repo_languages r1
191 where
192 repo_at IN (%s)
193 and is_default_ref = 1
194 and id = (
195 select id
196 from repo_languages r2
197 where r2.repo_at = r1.repo_at
198 and r2.is_default_ref = 1
199 order by bytes desc
200 limit 1
201 );
202 `,
203 inClause,
204 )
205 rows, err = e.Query(languageQuery, args...)
206 if err != nil {
207 return nil, fmt.Errorf("failed to execute lang query: %w ", err)
208 }
209 for rows.Next() {
210 var repoat, lang string
211 if err := rows.Scan(&repoat, &lang); err != nil {
212 log.Println("err", "err", err)
213 continue
214 }
215 if r, ok := repoMap[syntax.ATURI(repoat)]; ok {
216 r.RepoStats.Language = lang
217 }
218 }
219 if err = rows.Err(); err != nil {
220 return nil, fmt.Errorf("failed to execute lang query: %w ", err)
221 }
222
223 starCountQuery := fmt.Sprintf(
224 `select
225 repo_at, count(1)
226 from stars
227 where repo_at in (%s)
228 group by repo_at`,
229 inClause,
230 )
231 rows, err = e.Query(starCountQuery, args...)
232 if err != nil {
233 return nil, fmt.Errorf("failed to execute star-count query: %w ", err)
234 }
235 for rows.Next() {
236 var repoat string
237 var count int
238 if err := rows.Scan(&repoat, &count); err != nil {
239 log.Println("err", "err", err)
240 continue
241 }
242 if r, ok := repoMap[syntax.ATURI(repoat)]; ok {
243 r.RepoStats.StarCount = count
244 }
245 }
246 if err = rows.Err(); err != nil {
247 return nil, fmt.Errorf("failed to execute star-count query: %w ", err)
248 }
249
250 issueCountQuery := fmt.Sprintf(
251 `select
252 repo_at,
253 count(case when open = 1 then 1 end) as open_count,
254 count(case when open = 0 then 1 end) as closed_count
255 from issues
256 where repo_at in (%s)
257 group by repo_at`,
258 inClause,
259 )
260 rows, err = e.Query(issueCountQuery, args...)
261 if err != nil {
262 return nil, fmt.Errorf("failed to execute issue-count query: %w ", err)
263 }
264 for rows.Next() {
265 var repoat string
266 var open, closed int
267 if err := rows.Scan(&repoat, &open, &closed); err != nil {
268 log.Println("err", "err", err)
269 continue
270 }
271 if r, ok := repoMap[syntax.ATURI(repoat)]; ok {
272 r.RepoStats.IssueCount.Open = open
273 r.RepoStats.IssueCount.Closed = closed
274 }
275 }
276 if err = rows.Err(); err != nil {
277 return nil, fmt.Errorf("failed to execute issue-count query: %w ", err)
278 }
279
280 pullCountQuery := fmt.Sprintf(
281 `select
282 repo_at,
283 count(case when state = ? then 1 end) as open_count,
284 count(case when state = ? then 1 end) as merged_count,
285 count(case when state = ? then 1 end) as closed_count,
286 count(case when state = ? then 1 end) as deleted_count
287 from pulls
288 where repo_at in (%s)
289 group by repo_at`,
290 inClause,
291 )
292 args = append([]any{
293 PullOpen,
294 PullMerged,
295 PullClosed,
296 PullDeleted,
297 }, args...)
298 rows, err = e.Query(
299 pullCountQuery,
300 args...,
301 )
302 if err != nil {
303 return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err)
304 }
305 for rows.Next() {
306 var repoat string
307 var open, merged, closed, deleted int
308 if err := rows.Scan(&repoat, &open, &merged, &closed, &deleted); err != nil {
309 log.Println("err", "err", err)
310 continue
311 }
312 if r, ok := repoMap[syntax.ATURI(repoat)]; ok {
313 r.RepoStats.PullCount.Open = open
314 r.RepoStats.PullCount.Merged = merged
315 r.RepoStats.PullCount.Closed = closed
316 r.RepoStats.PullCount.Deleted = deleted
317 }
318 }
319 if err = rows.Err(); err != nil {
320 return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err)
321 }
322
323 var repos []Repo
324 for _, r := range repoMap {
325 repos = append(repos, *r)
326 }
327
328 slices.SortFunc(repos, func(a, b Repo) int {
329 if a.Created.After(b.Created) {
330 return -1
331 }
332 return 1
333 })
334
335 return repos, nil
336}
337
338// helper to get exactly one repo
339func GetRepo(e Execer, filters ...filter) (*Repo, error) {
340 repos, err := GetRepos(e, 0, filters...)
341 if err != nil {
342 return nil, err
343 }
344
345 if repos == nil {
346 return nil, sql.ErrNoRows
347 }
348
349 if len(repos) != 1 {
350 return nil, fmt.Errorf("too many rows returned")
351 }
352
353 return &repos[0], nil
354}
355
356func CountRepos(e Execer, filters ...filter) (int64, error) {
357 var conditions []string
358 var args []any
359 for _, filter := range filters {
360 conditions = append(conditions, filter.Condition())
361 args = append(args, filter.Arg()...)
362 }
363
364 whereClause := ""
365 if conditions != nil {
366 whereClause = " where " + strings.Join(conditions, " and ")
367 }
368
369 repoQuery := fmt.Sprintf(`select count(1) from repos %s`, whereClause)
370 var count int64
371 err := e.QueryRow(repoQuery, args...).Scan(&count)
372
373 if !errors.Is(err, sql.ErrNoRows) && err != nil {
374 return 0, err
375 }
376
377 return count, nil
378}
379
380func GetRepoByAtUri(e Execer, atUri string) (*Repo, error) {
381 var repo Repo
382 var nullableDescription sql.NullString
383
384 row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
385
386 var createdAt string
387 if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil {
388 return nil, err
389 }
390 createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
391 repo.Created = createdAtTime
392
393 if nullableDescription.Valid {
394 repo.Description = nullableDescription.String
395 } else {
396 repo.Description = ""
397 }
398
399 return &repo, nil
400}
401
402func AddRepo(e Execer, repo *Repo) error {
403 _, err := e.Exec(
404 `insert into repos
405 (did, name, knot, rkey, at_uri, description, source)
406 values (?, ?, ?, ?, ?, ?, ?)`,
407 repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source,
408 )
409 return err
410}
411
412func RemoveRepo(e Execer, did, name string) error {
413 _, err := e.Exec(`delete from repos where did = ? and name = ?`, did, name)
414 return err
415}
416
417func GetRepoSource(e Execer, repoAt syntax.ATURI) (string, error) {
418 var nullableSource sql.NullString
419 err := e.QueryRow(`select source from repos where at_uri = ?`, repoAt).Scan(&nullableSource)
420 if err != nil {
421 return "", err
422 }
423 return nullableSource.String, nil
424}
425
426func GetForksByDid(e Execer, did string) ([]Repo, error) {
427 var repos []Repo
428
429 rows, err := e.Query(
430 `select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source
431 from repos r
432 left join collaborators c on r.at_uri = c.repo_at
433 where (r.did = ? or c.subject_did = ?)
434 and r.source is not null
435 and r.source != ''
436 order by r.created desc`,
437 did, did,
438 )
439 if err != nil {
440 return nil, err
441 }
442 defer rows.Close()
443
444 for rows.Next() {
445 var repo Repo
446 var createdAt string
447 var nullableDescription sql.NullString
448 var nullableSource sql.NullString
449
450 err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
451 if err != nil {
452 return nil, err
453 }
454
455 if nullableDescription.Valid {
456 repo.Description = nullableDescription.String
457 }
458
459 if nullableSource.Valid {
460 repo.Source = nullableSource.String
461 }
462
463 createdAtTime, err := time.Parse(time.RFC3339, createdAt)
464 if err != nil {
465 repo.Created = time.Now()
466 } else {
467 repo.Created = createdAtTime
468 }
469
470 repos = append(repos, repo)
471 }
472
473 if err := rows.Err(); err != nil {
474 return nil, err
475 }
476
477 return repos, nil
478}
479
480func GetForkByDid(e Execer, did string, name string) (*Repo, error) {
481 var repo Repo
482 var createdAt string
483 var nullableDescription sql.NullString
484 var nullableSource sql.NullString
485
486 row := e.QueryRow(
487 `select did, name, knot, rkey, description, created, source
488 from repos
489 where did = ? and name = ? and source is not null and source != ''`,
490 did, name,
491 )
492
493 err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
494 if err != nil {
495 return nil, err
496 }
497
498 if nullableDescription.Valid {
499 repo.Description = nullableDescription.String
500 }
501
502 if nullableSource.Valid {
503 repo.Source = nullableSource.String
504 }
505
506 createdAtTime, err := time.Parse(time.RFC3339, createdAt)
507 if err != nil {
508 repo.Created = time.Now()
509 } else {
510 repo.Created = createdAtTime
511 }
512
513 return &repo, nil
514}
515
516func UpdateDescription(e Execer, repoAt, newDescription string) error {
517 _, err := e.Exec(
518 `update repos set description = ? where at_uri = ?`, newDescription, repoAt)
519 return err
520}
521
522func UpdateSpindle(e Execer, repoAt string, spindle *string) error {
523 _, err := e.Exec(
524 `update repos set spindle = ? where at_uri = ?`, spindle, repoAt)
525 return err
526}
527
528type RepoStats struct {
529 Language string
530 StarCount int
531 IssueCount IssueCount
532 PullCount PullCount
533}
534
535type RepoLabel struct {
536 Id int64
537 RepoAt syntax.ATURI
538 LabelAt syntax.ATURI
539}
540
541func SubscribeLabel(e Execer, rl *RepoLabel) error {
542 query := `insert or ignore into repo_labels (repo_at, label_at) values (?, ?)`
543
544 _, err := e.Exec(query, rl.RepoAt.String(), rl.LabelAt.String())
545 return err
546}
547
548func UnsubscribeLabel(e Execer, filters ...filter) error {
549 var conditions []string
550 var args []any
551 for _, filter := range filters {
552 conditions = append(conditions, filter.Condition())
553 args = append(args, filter.Arg()...)
554 }
555
556 whereClause := ""
557 if conditions != nil {
558 whereClause = " where " + strings.Join(conditions, " and ")
559 }
560
561 query := fmt.Sprintf(`delete from repo_labels %s`, whereClause)
562 _, err := e.Exec(query, args...)
563 return err
564}
565
566func GetRepoLabels(e Execer, filters ...filter) ([]RepoLabel, error) {
567 var conditions []string
568 var args []any
569 for _, filter := range filters {
570 conditions = append(conditions, filter.Condition())
571 args = append(args, filter.Arg()...)
572 }
573
574 whereClause := ""
575 if conditions != nil {
576 whereClause = " where " + strings.Join(conditions, " and ")
577 }
578
579 query := fmt.Sprintf(`select id, repo_at, label_at from repo_labels %s`, whereClause)
580
581 rows, err := e.Query(query, args...)
582 if err != nil {
583 return nil, err
584 }
585 defer rows.Close()
586
587 var labels []RepoLabel
588 for rows.Next() {
589 var label RepoLabel
590
591 err := rows.Scan(&label.Id, &label.RepoAt, &label.LabelAt)
592 if err != nil {
593 return nil, err
594 }
595
596 labels = append(labels, label)
597 }
598
599 if err = rows.Err(); err != nil {
600 return nil, err
601 }
602
603 return labels, nil
604}