1package db
2
3import (
4 "database/sql"
5 "fmt"
6 "log"
7 "net/url"
8 "slices"
9 "strings"
10 "time"
11
12 "github.com/bluesky-social/indigo/atproto/syntax"
13 "tangled.sh/tangled.sh/core/api/tangled"
14)
15
16type RepoEvent struct {
17 Repo *Repo
18 Source *Repo
19}
20
21type ProfileTimeline struct {
22 ByMonth []ByMonth
23}
24
25type ByMonth struct {
26 RepoEvents []RepoEvent
27 IssueEvents IssueEvents
28 PullEvents PullEvents
29}
30
31func (b ByMonth) IsEmpty() bool {
32 return len(b.RepoEvents) == 0 &&
33 len(b.IssueEvents.Items) == 0 &&
34 len(b.PullEvents.Items) == 0
35}
36
37type IssueEvents struct {
38 Items []*Issue
39}
40
41type IssueEventStats struct {
42 Open int
43 Closed int
44}
45
46func (i IssueEvents) Stats() IssueEventStats {
47 var open, closed int
48 for _, issue := range i.Items {
49 if issue.Open {
50 open += 1
51 } else {
52 closed += 1
53 }
54 }
55
56 return IssueEventStats{
57 Open: open,
58 Closed: closed,
59 }
60}
61
62type PullEvents struct {
63 Items []*Pull
64}
65
66func (p PullEvents) Stats() PullEventStats {
67 var open, merged, closed int
68 for _, pull := range p.Items {
69 switch pull.State {
70 case PullOpen:
71 open += 1
72 case PullMerged:
73 merged += 1
74 case PullClosed:
75 closed += 1
76 }
77 }
78
79 return PullEventStats{
80 Open: open,
81 Merged: merged,
82 Closed: closed,
83 }
84}
85
86type PullEventStats struct {
87 Closed int
88 Open int
89 Merged int
90}
91
92const TimeframeMonths = 7
93
94func MakeProfileTimeline(e Execer, forDid string) (*ProfileTimeline, error) {
95 timeline := ProfileTimeline{
96 ByMonth: make([]ByMonth, TimeframeMonths),
97 }
98 currentMonth := time.Now().Month()
99 timeframe := fmt.Sprintf("-%d months", TimeframeMonths)
100
101 pulls, err := GetPullsByOwnerDid(e, forDid, timeframe)
102 if err != nil {
103 return nil, fmt.Errorf("error getting pulls by owner did: %w", err)
104 }
105
106 // group pulls by month
107 for _, pull := range pulls {
108 pullMonth := pull.Created.Month()
109
110 if currentMonth-pullMonth >= TimeframeMonths {
111 // shouldn't happen; but times are weird
112 continue
113 }
114
115 idx := currentMonth - pullMonth
116 items := &timeline.ByMonth[idx].PullEvents.Items
117
118 *items = append(*items, &pull)
119 }
120
121 issues, err := GetIssuesByOwnerDid(e, forDid, timeframe)
122 if err != nil {
123 return nil, fmt.Errorf("error getting issues by owner did: %w", err)
124 }
125
126 for _, issue := range issues {
127 issueMonth := issue.Created.Month()
128
129 if currentMonth-issueMonth >= TimeframeMonths {
130 // shouldn't happen; but times are weird
131 continue
132 }
133
134 idx := currentMonth - issueMonth
135 items := &timeline.ByMonth[idx].IssueEvents.Items
136
137 *items = append(*items, &issue)
138 }
139
140 repos, err := GetAllReposByDid(e, forDid)
141 if err != nil {
142 return nil, fmt.Errorf("error getting all repos by did: %w", err)
143 }
144
145 for _, repo := range repos {
146 // TODO: get this in the original query; requires COALESCE because nullable
147 var sourceRepo *Repo
148 if repo.Source != "" {
149 sourceRepo, err = GetRepoByAtUri(e, repo.Source)
150 if err != nil {
151 return nil, err
152 }
153 }
154
155 repoMonth := repo.Created.Month()
156
157 if currentMonth-repoMonth >= TimeframeMonths {
158 // shouldn't happen; but times are weird
159 continue
160 }
161
162 idx := currentMonth - repoMonth
163
164 items := &timeline.ByMonth[idx].RepoEvents
165 *items = append(*items, RepoEvent{
166 Repo: &repo,
167 Source: sourceRepo,
168 })
169 }
170
171 return &timeline, nil
172}
173
174type Profile struct {
175 // ids
176 ID int
177 Did string
178
179 // data
180 Description string
181 IncludeBluesky bool
182 Location string
183 Links [5]string
184 Stats [2]VanityStat
185 PinnedRepos [6]syntax.ATURI
186}
187
188func (p Profile) IsLinksEmpty() bool {
189 for _, l := range p.Links {
190 if l != "" {
191 return false
192 }
193 }
194 return true
195}
196
197func (p Profile) IsStatsEmpty() bool {
198 for _, s := range p.Stats {
199 if s.Kind != "" {
200 return false
201 }
202 }
203 return true
204}
205
206func (p Profile) IsPinnedReposEmpty() bool {
207 for _, r := range p.PinnedRepos {
208 if r != "" {
209 return false
210 }
211 }
212 return true
213}
214
215type VanityStatKind string
216
217const (
218 VanityStatMergedPRCount VanityStatKind = "merged-pull-request-count"
219 VanityStatClosedPRCount VanityStatKind = "closed-pull-request-count"
220 VanityStatOpenPRCount VanityStatKind = "open-pull-request-count"
221 VanityStatOpenIssueCount VanityStatKind = "open-issue-count"
222 VanityStatClosedIssueCount VanityStatKind = "closed-issue-count"
223 VanityStatRepositoryCount VanityStatKind = "repository-count"
224)
225
226func (v VanityStatKind) String() string {
227 switch v {
228 case VanityStatMergedPRCount:
229 return "Merged PRs"
230 case VanityStatClosedPRCount:
231 return "Closed PRs"
232 case VanityStatOpenPRCount:
233 return "Open PRs"
234 case VanityStatOpenIssueCount:
235 return "Open Issues"
236 case VanityStatClosedIssueCount:
237 return "Closed Issues"
238 case VanityStatRepositoryCount:
239 return "Repositories"
240 }
241 return ""
242}
243
244type VanityStat struct {
245 Kind VanityStatKind
246 Value uint64
247}
248
249func (p *Profile) ProfileAt() syntax.ATURI {
250 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.Did, tangled.ActorProfileNSID, "self"))
251}
252
253func UpsertProfile(tx *sql.Tx, profile *Profile) error {
254 defer tx.Rollback()
255
256 // update links
257 _, err := tx.Exec(`delete from profile_links where did = ?`, profile.Did)
258 if err != nil {
259 return err
260 }
261 // update vanity stats
262 _, err = tx.Exec(`delete from profile_stats where did = ?`, profile.Did)
263 if err != nil {
264 return err
265 }
266
267 // update pinned repos
268 _, err = tx.Exec(`delete from profile_pinned_repositories where did = ?`, profile.Did)
269 if err != nil {
270 return err
271 }
272
273 includeBskyValue := 0
274 if profile.IncludeBluesky {
275 includeBskyValue = 1
276 }
277
278 _, err = tx.Exec(
279 `insert or replace into profile (
280 did,
281 description,
282 include_bluesky,
283 location
284 )
285 values (?, ?, ?, ?)`,
286 profile.Did,
287 profile.Description,
288 includeBskyValue,
289 profile.Location,
290 )
291
292 if err != nil {
293 log.Println("profile", "err", err)
294 return err
295 }
296
297 for _, link := range profile.Links {
298 if link == "" {
299 continue
300 }
301
302 _, err := tx.Exec(
303 `insert into profile_links (did, link) values (?, ?)`,
304 profile.Did,
305 link,
306 )
307
308 if err != nil {
309 log.Println("profile_links", "err", err)
310 return err
311 }
312 }
313
314 for _, v := range profile.Stats {
315 if v.Kind == "" {
316 continue
317 }
318
319 _, err := tx.Exec(
320 `insert into profile_stats (did, kind) values (?, ?)`,
321 profile.Did,
322 v.Kind,
323 )
324
325 if err != nil {
326 log.Println("profile_stats", "err", err)
327 return err
328 }
329 }
330
331 for _, pin := range profile.PinnedRepos {
332 if pin == "" {
333 continue
334 }
335
336 _, err := tx.Exec(
337 `insert into profile_pinned_repositories (did, at_uri) values (?, ?)`,
338 profile.Did,
339 pin,
340 )
341
342 if err != nil {
343 log.Println("profile_pinned_repositories", "err", err)
344 return err
345 }
346 }
347
348 return tx.Commit()
349}
350
351func GetProfiles(e Execer, filters ...filter) (map[string]*Profile, error) {
352 var conditions []string
353 var args []any
354 for _, filter := range filters {
355 conditions = append(conditions, filter.Condition())
356 args = append(args, filter.Arg()...)
357 }
358
359 whereClause := ""
360 if conditions != nil {
361 whereClause = " where " + strings.Join(conditions, " and ")
362 }
363
364 profilesQuery := fmt.Sprintf(
365 `select
366 id,
367 did,
368 description,
369 include_bluesky,
370 location
371 from
372 profile
373 %s`,
374 whereClause,
375 )
376 rows, err := e.Query(profilesQuery, args...)
377 if err != nil {
378 return nil, err
379 }
380
381 profileMap := make(map[string]*Profile)
382 for rows.Next() {
383 var profile Profile
384 var includeBluesky int
385
386 err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location)
387 if err != nil {
388 return nil, err
389 }
390
391 if includeBluesky != 0 {
392 profile.IncludeBluesky = true
393 }
394
395 profileMap[profile.Did] = &profile
396 }
397 if err = rows.Err(); err != nil {
398 return nil, err
399 }
400
401 // populate profile links
402 inClause := strings.TrimSuffix(strings.Repeat("?, ", len(profileMap)), ", ")
403 args = make([]any, len(profileMap))
404 i := 0
405 for did := range profileMap {
406 args[i] = did
407 i++
408 }
409
410 linksQuery := fmt.Sprintf("select link, did from profile_links where did in (%s)", inClause)
411 rows, err = e.Query(linksQuery, args...)
412 if err != nil {
413 return nil, err
414 }
415 idxs := make(map[string]int)
416 for did := range profileMap {
417 idxs[did] = 0
418 }
419 for rows.Next() {
420 var link, did string
421 if err = rows.Scan(&link, &did); err != nil {
422 return nil, err
423 }
424
425 idx := idxs[did]
426 profileMap[did].Links[idx] = link
427 idxs[did] = idx + 1
428 }
429
430 pinsQuery := fmt.Sprintf("select at_uri, did from profile_pinned_repositories where did in (%s)", inClause)
431 rows, err = e.Query(pinsQuery, args...)
432 if err != nil {
433 return nil, err
434 }
435 idxs = make(map[string]int)
436 for did := range profileMap {
437 idxs[did] = 0
438 }
439 for rows.Next() {
440 var link syntax.ATURI
441 var did string
442 if err = rows.Scan(&link, &did); err != nil {
443 return nil, err
444 }
445
446 idx := idxs[did]
447 profileMap[did].PinnedRepos[idx] = link
448 idxs[did] = idx + 1
449 }
450
451 return profileMap, nil
452}
453
454func GetProfile(e Execer, did string) (*Profile, error) {
455 var profile Profile
456 profile.Did = did
457
458 includeBluesky := 0
459 err := e.QueryRow(
460 `select description, include_bluesky, location from profile where did = ?`,
461 did,
462 ).Scan(&profile.Description, &includeBluesky, &profile.Location)
463 if err == sql.ErrNoRows {
464 profile := Profile{}
465 profile.Did = did
466 return &profile, nil
467 }
468
469 if err != nil {
470 return nil, err
471 }
472
473 if includeBluesky != 0 {
474 profile.IncludeBluesky = true
475 }
476
477 rows, err := e.Query(`select link from profile_links where did = ?`, did)
478 if err != nil {
479 return nil, err
480 }
481 defer rows.Close()
482 i := 0
483 for rows.Next() {
484 if err := rows.Scan(&profile.Links[i]); err != nil {
485 return nil, err
486 }
487 i++
488 }
489
490 rows, err = e.Query(`select kind from profile_stats where did = ?`, did)
491 if err != nil {
492 return nil, err
493 }
494 defer rows.Close()
495 i = 0
496 for rows.Next() {
497 if err := rows.Scan(&profile.Stats[i].Kind); err != nil {
498 return nil, err
499 }
500 value, err := GetVanityStat(e, profile.Did, profile.Stats[i].Kind)
501 if err != nil {
502 return nil, err
503 }
504 profile.Stats[i].Value = value
505 i++
506 }
507
508 rows, err = e.Query(`select at_uri from profile_pinned_repositories where did = ?`, did)
509 if err != nil {
510 return nil, err
511 }
512 defer rows.Close()
513 i = 0
514 for rows.Next() {
515 if err := rows.Scan(&profile.PinnedRepos[i]); err != nil {
516 return nil, err
517 }
518 i++
519 }
520
521 return &profile, nil
522}
523
524func GetVanityStat(e Execer, did string, stat VanityStatKind) (uint64, error) {
525 query := ""
526 var args []any
527 switch stat {
528 case VanityStatMergedPRCount:
529 query = `select count(id) from pulls where owner_did = ? and state = ?`
530 args = append(args, did, PullMerged)
531 case VanityStatClosedPRCount:
532 query = `select count(id) from pulls where owner_did = ? and state = ?`
533 args = append(args, did, PullClosed)
534 case VanityStatOpenPRCount:
535 query = `select count(id) from pulls where owner_did = ? and state = ?`
536 args = append(args, did, PullOpen)
537 case VanityStatOpenIssueCount:
538 query = `select count(id) from issues where owner_did = ? and open = 1`
539 args = append(args, did)
540 case VanityStatClosedIssueCount:
541 query = `select count(id) from issues where owner_did = ? and open = 0`
542 args = append(args, did)
543 case VanityStatRepositoryCount:
544 query = `select count(id) from repos where did = ?`
545 args = append(args, did)
546 }
547
548 var result uint64
549 err := e.QueryRow(query, args...).Scan(&result)
550 if err != nil {
551 return 0, err
552 }
553
554 return result, nil
555}
556
557func ValidateProfile(e Execer, profile *Profile) error {
558 // ensure description is not too long
559 if len(profile.Description) > 256 {
560 return fmt.Errorf("Entered bio is too long.")
561 }
562
563 // ensure description is not too long
564 if len(profile.Location) > 40 {
565 return fmt.Errorf("Entered location is too long.")
566 }
567
568 // ensure links are in order
569 err := validateLinks(profile)
570 if err != nil {
571 return err
572 }
573
574 // ensure all pinned repos are either own repos or collaborating repos
575 repos, err := GetAllReposByDid(e, profile.Did)
576 if err != nil {
577 log.Printf("getting repos for %s: %s", profile.Did, err)
578 }
579
580 collaboratingRepos, err := CollaboratingIn(e, profile.Did)
581 if err != nil {
582 log.Printf("getting collaborating repos for %s: %s", profile.Did, err)
583 }
584
585 var validRepos []syntax.ATURI
586 for _, r := range repos {
587 validRepos = append(validRepos, r.RepoAt())
588 }
589 for _, r := range collaboratingRepos {
590 validRepos = append(validRepos, r.RepoAt())
591 }
592
593 for _, pinned := range profile.PinnedRepos {
594 if pinned == "" {
595 continue
596 }
597 if !slices.Contains(validRepos, pinned) {
598 return fmt.Errorf("Invalid pinned repo: `%s, does not belong to own or collaborating repos", pinned)
599 }
600 }
601
602 return nil
603}
604
605func validateLinks(profile *Profile) error {
606 for i, link := range profile.Links {
607 if link == "" {
608 continue
609 }
610
611 parsedURL, err := url.Parse(link)
612 if err != nil {
613 return fmt.Errorf("Invalid URL '%s': %v\n", link, err)
614 }
615
616 if parsedURL.Scheme == "" {
617 if strings.HasPrefix(link, "//") {
618 profile.Links[i] = "https:" + link
619 } else {
620 profile.Links[i] = "https://" + link
621 }
622 continue
623 } else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
624 return fmt.Errorf("Warning: URL '%s' has unusual scheme: %s\n", link, parsedURL.Scheme)
625 }
626
627 // catch relative paths
628 if parsedURL.Host == "" {
629 return fmt.Errorf("Warning: URL '%s' appears to be a relative path\n", link)
630 }
631 }
632 return nil
633}