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