forked from
tangled.org/core
fork
Configure Feed
Select the types of activity you want to include in your feed.
this repo has no description
fork
Configure Feed
Select the types of activity you want to include in your feed.
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}