forked from
tangled.org/core
fork
Configure Feed
Select the types of activity you want to include in your feed.
Monorepo for Tangled
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)
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}