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