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 GetProfile(e Execer, did string) (*Profile, error) {
352 var profile Profile
353 profile.Did = did
354
355 includeBluesky := 0
356 err := e.QueryRow(
357 `select description, include_bluesky, location from profile where did = ?`,
358 did,
359 ).Scan(&profile.Description, &includeBluesky, &profile.Location)
360 if err == sql.ErrNoRows {
361 profile := Profile{}
362 profile.Did = did
363 return &profile, nil
364 }
365
366 if err != nil {
367 return nil, err
368 }
369
370 if includeBluesky != 0 {
371 profile.IncludeBluesky = true
372 }
373
374 rows, err := e.Query(`select link from profile_links where did = ?`, did)
375 if err != nil {
376 return nil, err
377 }
378 defer rows.Close()
379 i := 0
380 for rows.Next() {
381 if err := rows.Scan(&profile.Links[i]); err != nil {
382 return nil, err
383 }
384 i++
385 }
386
387 rows, err = e.Query(`select kind from profile_stats where did = ?`, did)
388 if err != nil {
389 return nil, err
390 }
391 defer rows.Close()
392 i = 0
393 for rows.Next() {
394 if err := rows.Scan(&profile.Stats[i].Kind); err != nil {
395 return nil, err
396 }
397 value, err := GetVanityStat(e, profile.Did, profile.Stats[i].Kind)
398 if err != nil {
399 return nil, err
400 }
401 profile.Stats[i].Value = value
402 i++
403 }
404
405 rows, err = e.Query(`select at_uri from profile_pinned_repositories where did = ?`, did)
406 if err != nil {
407 return nil, err
408 }
409 defer rows.Close()
410 i = 0
411 for rows.Next() {
412 if err := rows.Scan(&profile.PinnedRepos[i]); err != nil {
413 return nil, err
414 }
415 i++
416 }
417
418 return &profile, nil
419}
420
421func GetVanityStat(e Execer, did string, stat VanityStatKind) (uint64, error) {
422 query := ""
423 var args []any
424 switch stat {
425 case VanityStatMergedPRCount:
426 query = `select count(id) from pulls where owner_did = ? and state = ?`
427 args = append(args, did, PullMerged)
428 case VanityStatClosedPRCount:
429 query = `select count(id) from pulls where owner_did = ? and state = ?`
430 args = append(args, did, PullClosed)
431 case VanityStatOpenPRCount:
432 query = `select count(id) from pulls where owner_did = ? and state = ?`
433 args = append(args, did, PullOpen)
434 case VanityStatOpenIssueCount:
435 query = `select count(id) from issues where owner_did = ? and open = 1`
436 args = append(args, did)
437 case VanityStatClosedIssueCount:
438 query = `select count(id) from issues where owner_did = ? and open = 0`
439 args = append(args, did)
440 case VanityStatRepositoryCount:
441 query = `select count(id) from repos where did = ?`
442 args = append(args, did)
443 }
444
445 var result uint64
446 err := e.QueryRow(query, args...).Scan(&result)
447 if err != nil {
448 return 0, err
449 }
450
451 return result, nil
452}
453
454func ValidateProfile(e Execer, profile *Profile) error {
455 // ensure description is not too long
456 if len(profile.Description) > 256 {
457 return fmt.Errorf("Entered bio is too long.")
458 }
459
460 // ensure description is not too long
461 if len(profile.Location) > 40 {
462 return fmt.Errorf("Entered location is too long.")
463 }
464
465 // ensure links are in order
466 err := validateLinks(profile)
467 if err != nil {
468 return err
469 }
470
471 // ensure all pinned repos are either own repos or collaborating repos
472 repos, err := GetAllReposByDid(e, profile.Did)
473 if err != nil {
474 log.Printf("getting repos for %s: %s", profile.Did, err)
475 }
476
477 collaboratingRepos, err := CollaboratingIn(e, profile.Did)
478 if err != nil {
479 log.Printf("getting collaborating repos for %s: %s", profile.Did, err)
480 }
481
482 var validRepos []syntax.ATURI
483 for _, r := range repos {
484 validRepos = append(validRepos, r.RepoAt())
485 }
486 for _, r := range collaboratingRepos {
487 validRepos = append(validRepos, r.RepoAt())
488 }
489
490 for _, pinned := range profile.PinnedRepos {
491 if pinned == "" {
492 continue
493 }
494 if !slices.Contains(validRepos, pinned) {
495 return fmt.Errorf("Invalid pinned repo: `%s, does not belong to own or collaborating repos", pinned)
496 }
497 }
498
499 return nil
500}
501
502func validateLinks(profile *Profile) error {
503 for i, link := range profile.Links {
504 if link == "" {
505 continue
506 }
507
508 parsedURL, err := url.Parse(link)
509 if err != nil {
510 return fmt.Errorf("Invalid URL '%s': %v\n", link, err)
511 }
512
513 if parsedURL.Scheme == "" {
514 if strings.HasPrefix(link, "//") {
515 profile.Links[i] = "https:" + link
516 } else {
517 profile.Links[i] = "https://" + link
518 }
519 continue
520 } else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
521 return fmt.Errorf("Warning: URL '%s' has unusual scheme: %s\n", link, parsedURL.Scheme)
522 }
523
524 // catch relative paths
525 if parsedURL.Host == "" {
526 return fmt.Errorf("Warning: URL '%s' appears to be a relative path\n", link)
527 }
528 }
529 return nil
530}