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