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