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