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