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 default_knot
143 )
144 values (?, ?, ?, ?, ?, ?)`,
145 profile.Did,
146 profile.Description,
147 includeBskyValue,
148 profile.Location,
149 profile.Pronouns,
150 profile.DefaultKnot,
151 )
152
153 if err != nil {
154 log.Println("profile", "err", err)
155 return err
156 }
157
158 for _, link := range profile.Links {
159 if link == "" {
160 continue
161 }
162
163 _, err := tx.Exec(
164 `insert into profile_links (did, link) values (?, ?)`,
165 profile.Did,
166 link,
167 )
168
169 if err != nil {
170 log.Println("profile_links", "err", err)
171 return err
172 }
173 }
174
175 for _, v := range profile.Stats {
176 if v.Kind == "" {
177 continue
178 }
179
180 _, err := tx.Exec(
181 `insert into profile_stats (did, kind) values (?, ?)`,
182 profile.Did,
183 v.Kind,
184 )
185
186 if err != nil {
187 log.Println("profile_stats", "err", err)
188 return err
189 }
190 }
191
192 for _, pin := range profile.PinnedRepos {
193 if pin == "" {
194 continue
195 }
196
197 _, err := tx.Exec(
198 `insert into profile_pinned_repositories (did, at_uri) values (?, ?)`,
199 profile.Did,
200 pin,
201 )
202
203 if err != nil {
204 log.Println("profile_pinned_repositories", "err", err)
205 return err
206 }
207 }
208
209 return tx.Commit()
210}
211
212func GetProfiles(e Execer, filters ...orm.Filter) (map[string]*models.Profile, error) {
213 var conditions []string
214 var args []any
215 for _, filter := range filters {
216 conditions = append(conditions, filter.Condition())
217 args = append(args, filter.Arg()...)
218 }
219
220 whereClause := ""
221 if conditions != nil {
222 whereClause = " where " + strings.Join(conditions, " and ")
223 }
224
225 profilesQuery := fmt.Sprintf(
226 `select
227 id,
228 did,
229 description,
230 include_bluesky,
231 location,
232 pronouns
233 from
234 profile
235 %s`,
236 whereClause,
237 )
238 rows, err := e.Query(profilesQuery, args...)
239 if err != nil {
240 return nil, err
241 }
242 defer rows.Close()
243
244 profileMap := make(map[string]*models.Profile)
245 for rows.Next() {
246 var profile models.Profile
247 var includeBluesky int
248 var pronouns sql.Null[string]
249
250 err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &pronouns)
251 if err != nil {
252 return nil, err
253 }
254
255 if includeBluesky != 0 {
256 profile.IncludeBluesky = true
257 }
258
259 if pronouns.Valid {
260 profile.Pronouns = pronouns.V
261 }
262
263 profileMap[profile.Did] = &profile
264 }
265 if err = rows.Err(); err != nil {
266 return nil, err
267 }
268
269 // populate profile links
270 inClause := strings.TrimSuffix(strings.Repeat("?, ", len(profileMap)), ", ")
271 args = make([]any, len(profileMap))
272 i := 0
273 for did := range profileMap {
274 args[i] = did
275 i++
276 }
277
278 linksQuery := fmt.Sprintf("select link, did from profile_links where did in (%s)", inClause)
279 rows, err = e.Query(linksQuery, args...)
280 if err != nil {
281 return nil, err
282 }
283 defer rows.Close()
284
285 idxs := make(map[string]int)
286 for did := range profileMap {
287 idxs[did] = 0
288 }
289 for rows.Next() {
290 var link, did string
291 if err = rows.Scan(&link, &did); err != nil {
292 return nil, err
293 }
294
295 idx := idxs[did]
296 profileMap[did].Links[idx] = link
297 idxs[did] = idx + 1
298 }
299
300 pinsQuery := fmt.Sprintf("select at_uri, did from profile_pinned_repositories where did in (%s)", inClause)
301 rows, err = e.Query(pinsQuery, args...)
302 if err != nil {
303 return nil, err
304 }
305 defer rows.Close()
306
307 idxs = make(map[string]int)
308 for did := range profileMap {
309 idxs[did] = 0
310 }
311 for rows.Next() {
312 var link syntax.ATURI
313 var did string
314 if err = rows.Scan(&link, &did); err != nil {
315 return nil, err
316 }
317
318 idx := idxs[did]
319 profileMap[did].PinnedRepos[idx] = link
320 idxs[did] = idx + 1
321 }
322
323 return profileMap, nil
324}
325
326func GetProfile(e Execer, did string) (*models.Profile, error) {
327 var profile models.Profile
328 var pronouns sql.Null[string]
329 var defaultKnot sql.Null[string]
330
331 profile.Did = did
332
333 includeBluesky := 0
334
335 err := e.QueryRow(
336 `select description, include_bluesky, location, pronouns, default_knot from profile where did = ?`,
337 did,
338 ).Scan(&profile.Description, &includeBluesky, &profile.Location, &pronouns, &defaultKnot)
339 if err == sql.ErrNoRows {
340 profile := models.Profile{}
341 profile.Did = did
342 return &profile, nil
343 }
344
345 if err != nil {
346 return nil, err
347 }
348
349 if includeBluesky != 0 {
350 profile.IncludeBluesky = true
351 }
352
353 if pronouns.Valid {
354 profile.Pronouns = pronouns.V
355 }
356
357 if defaultKnot.Valid {
358 profile.DefaultKnot = defaultKnot.V
359 }
360
361 rows, err := e.Query(`select link from profile_links where did = ?`, did)
362 if err != nil {
363 return nil, err
364 }
365 defer rows.Close()
366 i := 0
367 for rows.Next() {
368 if err := rows.Scan(&profile.Links[i]); err != nil {
369 return nil, err
370 }
371 i++
372 }
373
374 rows, err = e.Query(`select kind from profile_stats 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.Stats[i].Kind); err != nil {
382 return nil, err
383 }
384 value, err := GetVanityStat(e, profile.Did, profile.Stats[i].Kind)
385 if err != nil {
386 return nil, err
387 }
388 profile.Stats[i].Value = value
389 i++
390 }
391
392 rows, err = e.Query(`select at_uri from profile_pinned_repositories where did = ?`, did)
393 if err != nil {
394 return nil, err
395 }
396 defer rows.Close()
397 i = 0
398 for rows.Next() {
399 if err := rows.Scan(&profile.PinnedRepos[i]); err != nil {
400 return nil, err
401 }
402 i++
403 }
404
405 return &profile, nil
406}
407
408func GetVanityStat(e Execer, did string, stat models.VanityStatKind) (uint64, error) {
409 query := ""
410 var args []any
411 switch stat {
412 case models.VanityStatMergedPRCount:
413 query = `select count(id) from pulls where owner_did = ? and state = ?`
414 args = append(args, did, models.PullMerged)
415 case models.VanityStatClosedPRCount:
416 query = `select count(id) from pulls where owner_did = ? and state = ?`
417 args = append(args, did, models.PullClosed)
418 case models.VanityStatOpenPRCount:
419 query = `select count(id) from pulls where owner_did = ? and state = ?`
420 args = append(args, did, models.PullOpen)
421 case models.VanityStatOpenIssueCount:
422 query = `select count(id) from issues where did = ? and open = 1`
423 args = append(args, did)
424 case models.VanityStatClosedIssueCount:
425 query = `select count(id) from issues where did = ? and open = 0`
426 args = append(args, did)
427 case models.VanityStatRepositoryCount:
428 query = `select count(id) from repos where did = ?`
429 args = append(args, did)
430 }
431
432 var result uint64
433 err := e.QueryRow(query, args...).Scan(&result)
434 if err != nil {
435 return 0, err
436 }
437
438 return result, nil
439}
440
441func ValidateProfile(e Execer, profile *models.Profile) error {
442 // ensure description is not too long
443 if len(profile.Description) > 256 {
444 return fmt.Errorf("Entered bio is too long.")
445 }
446
447 // ensure description is not too long
448 if len(profile.Location) > 40 {
449 return fmt.Errorf("Entered location is too long.")
450 }
451
452 // ensure pronouns are not too long
453 if len(profile.Pronouns) > 40 {
454 return fmt.Errorf("Entered pronouns are too long.")
455 }
456
457 // ensure links are in order
458 err := validateLinks(profile)
459 if err != nil {
460 return err
461 }
462
463 // ensure all pinned repos are either own repos or collaborating repos
464 repos, err := GetRepos(e, 0, orm.FilterEq("did", profile.Did))
465 if err != nil {
466 log.Printf("getting repos for %s: %s", profile.Did, err)
467 }
468
469 collaboratingRepos, err := CollaboratingIn(e, profile.Did)
470 if err != nil {
471 log.Printf("getting collaborating repos for %s: %s", profile.Did, err)
472 }
473
474 var validRepos []syntax.ATURI
475 for _, r := range repos {
476 validRepos = append(validRepos, r.RepoAt())
477 }
478 for _, r := range collaboratingRepos {
479 validRepos = append(validRepos, r.RepoAt())
480 }
481
482 for _, pinned := range profile.PinnedRepos {
483 if pinned == "" {
484 continue
485 }
486 if !slices.Contains(validRepos, pinned) {
487 return fmt.Errorf("Invalid pinned repo: `%s, does not belong to own or collaborating repos", pinned)
488 }
489 }
490
491 return nil
492}
493
494func validateLinks(profile *models.Profile) error {
495 for i, link := range profile.Links {
496 if link == "" {
497 continue
498 }
499
500 parsedURL, err := url.Parse(link)
501 if err != nil {
502 return fmt.Errorf("Invalid URL '%s': %v\n", link, err)
503 }
504
505 if parsedURL.Scheme == "" {
506 if strings.HasPrefix(link, "//") {
507 profile.Links[i] = "https:" + link
508 } else {
509 profile.Links[i] = "https://" + link
510 }
511 continue
512 } else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
513 return fmt.Errorf("Warning: URL '%s' has unusual scheme: %s\n", link, parsedURL.Scheme)
514 }
515
516 // catch relative paths
517 if parsedURL.Host == "" {
518 return fmt.Errorf("Warning: URL '%s' appears to be a relative path\n", link)
519 }
520 }
521 return nil
522}