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/appview/models"
14 "tangled.org/core/orm"
15)
16
17const TimeframeMonths = 7
18
19func MakeProfileTimeline(e Execer, forDid string) (*models.ProfileTimeline, error) {
20 timeline := models.ProfileTimeline{
21 ByMonth: make([]models.ByMonth, TimeframeMonths),
22 }
23 now := time.Now()
24 timeframe := fmt.Sprintf("-%d months", TimeframeMonths)
25
26 pulls, err := GetPullsByOwnerDid(e, forDid, timeframe)
27 if err != nil {
28 return nil, fmt.Errorf("error getting pulls by owner did: %w", err)
29 }
30
31 // group pulls by month
32 for _, pull := range pulls {
33 monthsAgo := monthsBetween(pull.Created, now)
34
35 if monthsAgo >= TimeframeMonths {
36 // shouldn't happen; but times are weird
37 continue
38 }
39
40 idx := monthsAgo
41 items := &timeline.ByMonth[idx].PullEvents.Items
42
43 *items = append(*items, &pull)
44 }
45
46 issues, err := GetIssues(
47 e,
48 orm.FilterEq("did", forDid),
49 orm.FilterGte("created", time.Now().AddDate(0, -TimeframeMonths, 0)),
50 )
51 if err != nil {
52 return nil, fmt.Errorf("error getting issues by owner did: %w", err)
53 }
54
55 for _, issue := range issues {
56 monthsAgo := monthsBetween(issue.Created, now)
57
58 if monthsAgo >= TimeframeMonths {
59 // shouldn't happen; but times are weird
60 continue
61 }
62
63 idx := monthsAgo
64 items := &timeline.ByMonth[idx].IssueEvents.Items
65
66 *items = append(*items, &issue)
67 }
68
69 repos, err := GetRepos(e, 0, orm.FilterEq("did", forDid))
70 if err != nil {
71 return nil, fmt.Errorf("error getting all repos by did: %w", err)
72 }
73
74 for _, repo := range repos {
75 // TODO: get this in the original query; requires COALESCE because nullable
76 var sourceRepo *models.Repo
77 if repo.Source != "" {
78 sourceRepo, err = GetRepoByAtUri(e, repo.Source)
79 if err != nil {
80 // the source repo was not found, skip this bit
81 log.Println("profile", "err", err)
82 }
83 }
84
85 monthsAgo := monthsBetween(repo.Created, now)
86
87 if monthsAgo >= TimeframeMonths {
88 // shouldn't happen; but times are weird
89 continue
90 }
91
92 idx := monthsAgo
93
94 items := &timeline.ByMonth[idx].RepoEvents
95 *items = append(*items, models.RepoEvent{
96 Repo: &repo,
97 Source: sourceRepo,
98 })
99 }
100
101 return &timeline, nil
102}
103
104func monthsBetween(from, to time.Time) int {
105 years := to.Year() - from.Year()
106 months := int(to.Month() - from.Month())
107 return years*12 + months
108}
109
110func UpsertProfile(tx *sql.Tx, profile *models.Profile) error {
111 defer tx.Rollback()
112
113 // update links
114 _, err := tx.Exec(`delete from profile_links where did = ?`, profile.Did)
115 if err != nil {
116 return err
117 }
118 // update vanity stats
119 _, err = tx.Exec(`delete from profile_stats where did = ?`, profile.Did)
120 if err != nil {
121 return err
122 }
123
124 // update pinned repos
125 _, err = tx.Exec(`delete from profile_pinned_repositories where did = ?`, profile.Did)
126 if err != nil {
127 return err
128 }
129
130 includeBskyValue := 0
131 if profile.IncludeBluesky {
132 includeBskyValue = 1
133 }
134
135 _, err = tx.Exec(
136 `insert or replace into profile (
137 did,
138 description,
139 include_bluesky,
140 location,
141 pronouns
142 )
143 values (?, ?, ?, ?, ?)`,
144 profile.Did,
145 profile.Description,
146 includeBskyValue,
147 profile.Location,
148 profile.Pronouns,
149 )
150
151 if err != nil {
152 log.Println("profile", "err", err)
153 return err
154 }
155
156 for _, link := range profile.Links {
157 if link == "" {
158 continue
159 }
160
161 _, err := tx.Exec(
162 `insert into profile_links (did, link) values (?, ?)`,
163 profile.Did,
164 link,
165 )
166
167 if err != nil {
168 log.Println("profile_links", "err", err)
169 return err
170 }
171 }
172
173 for _, v := range profile.Stats {
174 if v.Kind == "" {
175 continue
176 }
177
178 _, err := tx.Exec(
179 `insert into profile_stats (did, kind) values (?, ?)`,
180 profile.Did,
181 v.Kind,
182 )
183
184 if err != nil {
185 log.Println("profile_stats", "err", err)
186 return err
187 }
188 }
189
190 for _, pin := range profile.PinnedRepos {
191 if pin == "" {
192 continue
193 }
194
195 _, err := tx.Exec(
196 `insert into profile_pinned_repositories (did, at_uri) values (?, ?)`,
197 profile.Did,
198 pin,
199 )
200
201 if err != nil {
202 log.Println("profile_pinned_repositories", "err", err)
203 return err
204 }
205 }
206
207 return tx.Commit()
208}
209
210func GetProfiles(e Execer, filters ...orm.Filter) (map[string]*models.Profile, error) {
211 var conditions []string
212 var args []any
213 for _, filter := range filters {
214 conditions = append(conditions, filter.Condition())
215 args = append(args, filter.Arg()...)
216 }
217
218 whereClause := ""
219 if conditions != nil {
220 whereClause = " where " + strings.Join(conditions, " and ")
221 }
222
223 profilesQuery := fmt.Sprintf(
224 `select
225 id,
226 did,
227 description,
228 include_bluesky,
229 location,
230 pronouns
231 from
232 profile
233 %s`,
234 whereClause,
235 )
236 rows, err := e.Query(profilesQuery, args...)
237 if err != nil {
238 return nil, err
239 }
240 defer rows.Close()
241
242 profileMap := make(map[string]*models.Profile)
243 for rows.Next() {
244 var profile models.Profile
245 var includeBluesky int
246 var pronouns sql.Null[string]
247
248 err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &pronouns)
249 if err != nil {
250 return nil, err
251 }
252
253 if includeBluesky != 0 {
254 profile.IncludeBluesky = true
255 }
256
257 if pronouns.Valid {
258 profile.Pronouns = pronouns.V
259 }
260
261 profileMap[profile.Did] = &profile
262 }
263 if err = rows.Err(); err != nil {
264 return nil, err
265 }
266
267 // populate profile links
268 inClause := strings.TrimSuffix(strings.Repeat("?, ", len(profileMap)), ", ")
269 args = make([]any, len(profileMap))
270 i := 0
271 for did := range profileMap {
272 args[i] = did
273 i++
274 }
275
276 linksQuery := fmt.Sprintf("select link, did from profile_links where did in (%s)", inClause)
277 rows, err = e.Query(linksQuery, args...)
278 if err != nil {
279 return nil, err
280 }
281 defer rows.Close()
282
283 idxs := make(map[string]int)
284 for did := range profileMap {
285 idxs[did] = 0
286 }
287 for rows.Next() {
288 var link, did string
289 if err = rows.Scan(&link, &did); err != nil {
290 return nil, err
291 }
292
293 idx := idxs[did]
294 profileMap[did].Links[idx] = link
295 idxs[did] = idx + 1
296 }
297
298 pinsQuery := fmt.Sprintf("select at_uri, did from profile_pinned_repositories where did in (%s)", inClause)
299 rows, err = e.Query(pinsQuery, args...)
300 if err != nil {
301 return nil, err
302 }
303 defer rows.Close()
304
305 idxs = make(map[string]int)
306 for did := range profileMap {
307 idxs[did] = 0
308 }
309 for rows.Next() {
310 var link syntax.ATURI
311 var did string
312 if err = rows.Scan(&link, &did); err != nil {
313 return nil, err
314 }
315
316 idx := idxs[did]
317 profileMap[did].PinnedRepos[idx] = link
318 idxs[did] = idx + 1
319 }
320
321 return profileMap, nil
322}
323
324func GetProfile(e Execer, did string) (*models.Profile, error) {
325 var profile models.Profile
326 var pronouns sql.Null[string]
327
328 profile.Did = did
329
330 includeBluesky := 0
331
332 err := e.QueryRow(
333 `select description, include_bluesky, location, pronouns from profile where did = ?`,
334 did,
335 ).Scan(&profile.Description, &includeBluesky, &profile.Location, &pronouns)
336 if err == sql.ErrNoRows {
337 profile := models.Profile{}
338 profile.Did = did
339 return &profile, nil
340 }
341
342 if err != nil {
343 return nil, err
344 }
345
346 if includeBluesky != 0 {
347 profile.IncludeBluesky = true
348 }
349
350 if pronouns.Valid {
351 profile.Pronouns = pronouns.V
352 }
353
354 rows, err := e.Query(`select link from profile_links where did = ?`, did)
355 if err != nil {
356 return nil, err
357 }
358 defer rows.Close()
359 i := 0
360 for rows.Next() {
361 if err := rows.Scan(&profile.Links[i]); err != nil {
362 return nil, err
363 }
364 i++
365 }
366
367 rows, err = e.Query(`select kind from profile_stats where did = ?`, did)
368 if err != nil {
369 return nil, err
370 }
371 defer rows.Close()
372 i = 0
373 for rows.Next() {
374 if err := rows.Scan(&profile.Stats[i].Kind); err != nil {
375 return nil, err
376 }
377 value, err := GetVanityStat(e, profile.Did, profile.Stats[i].Kind)
378 if err != nil {
379 return nil, err
380 }
381 profile.Stats[i].Value = value
382 i++
383 }
384
385 rows, err = e.Query(`select at_uri from profile_pinned_repositories where did = ?`, did)
386 if err != nil {
387 return nil, err
388 }
389 defer rows.Close()
390 i = 0
391 for rows.Next() {
392 if err := rows.Scan(&profile.PinnedRepos[i]); err != nil {
393 return nil, err
394 }
395 i++
396 }
397
398 return &profile, nil
399}
400
401func GetVanityStat(e Execer, did string, stat models.VanityStatKind) (uint64, error) {
402 query := ""
403 var args []any
404 switch stat {
405 case models.VanityStatMergedPRCount:
406 query = `select count(id) from pulls where owner_did = ? and state = ?`
407 args = append(args, did, models.PullMerged)
408 case models.VanityStatClosedPRCount:
409 query = `select count(id) from pulls where owner_did = ? and state = ?`
410 args = append(args, did, models.PullClosed)
411 case models.VanityStatOpenPRCount:
412 query = `select count(id) from pulls where owner_did = ? and state = ?`
413 args = append(args, did, models.PullOpen)
414 case models.VanityStatOpenIssueCount:
415 query = `select count(id) from issues where did = ? and open = 1`
416 args = append(args, did)
417 case models.VanityStatClosedIssueCount:
418 query = `select count(id) from issues where did = ? and open = 0`
419 args = append(args, did)
420 case models.VanityStatRepositoryCount:
421 query = `select count(id) from repos where did = ?`
422 args = append(args, did)
423 }
424
425 var result uint64
426 err := e.QueryRow(query, args...).Scan(&result)
427 if err != nil {
428 return 0, err
429 }
430
431 return result, nil
432}
433
434func ValidateProfile(e Execer, profile *models.Profile) error {
435 // ensure description is not too long
436 if len(profile.Description) > 256 {
437 return fmt.Errorf("Entered bio is too long.")
438 }
439
440 // ensure description is not too long
441 if len(profile.Location) > 40 {
442 return fmt.Errorf("Entered location is too long.")
443 }
444
445 // ensure pronouns are not too long
446 if len(profile.Pronouns) > 40 {
447 return fmt.Errorf("Entered pronouns are too long.")
448 }
449
450 // ensure links are in order
451 err := validateLinks(profile)
452 if err != nil {
453 return err
454 }
455
456 // ensure all pinned repos are either own repos or collaborating repos
457 repos, err := GetRepos(e, 0, orm.FilterEq("did", profile.Did))
458 if err != nil {
459 log.Printf("getting repos for %s: %s", profile.Did, err)
460 }
461
462 collaboratingRepos, err := CollaboratingIn(e, profile.Did)
463 if err != nil {
464 log.Printf("getting collaborating repos for %s: %s", profile.Did, err)
465 }
466
467 var validRepos []syntax.ATURI
468 for _, r := range repos {
469 validRepos = append(validRepos, r.RepoAt())
470 }
471 for _, r := range collaboratingRepos {
472 validRepos = append(validRepos, r.RepoAt())
473 }
474
475 for _, pinned := range profile.PinnedRepos {
476 if pinned == "" {
477 continue
478 }
479 if !slices.Contains(validRepos, pinned) {
480 return fmt.Errorf("Invalid pinned repo: `%s, does not belong to own or collaborating repos", pinned)
481 }
482 }
483
484 return nil
485}
486
487func validateLinks(profile *models.Profile) error {
488 for i, link := range profile.Links {
489 if link == "" {
490 continue
491 }
492
493 parsedURL, err := url.Parse(link)
494 if err != nil {
495 return fmt.Errorf("Invalid URL '%s': %v\n", link, err)
496 }
497
498 if parsedURL.Scheme == "" {
499 if strings.HasPrefix(link, "//") {
500 profile.Links[i] = "https:" + link
501 } else {
502 profile.Links[i] = "https://" + link
503 }
504 continue
505 } else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
506 return fmt.Errorf("Warning: URL '%s' has unusual scheme: %s\n", link, parsedURL.Scheme)
507 }
508
509 // catch relative paths
510 if parsedURL.Host == "" {
511 return fmt.Errorf("Warning: URL '%s' appears to be a relative path\n", link)
512 }
513 }
514 return nil
515}