forked from tangled.org/core
Monorepo for Tangled — https://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.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 GetProfiles(e Execer, filters ...filter) ([]Profile, error) { 352 var conditions []string 353 var args []any 354 for _, filter := range filters { 355 conditions = append(conditions, filter.Condition()) 356 args = append(args, filter.Arg()...) 357 } 358 359 whereClause := "" 360 if conditions != nil { 361 whereClause = " where " + strings.Join(conditions, " and ") 362 } 363 364 profilesQuery := fmt.Sprintf( 365 `select 366 id, 367 did, 368 description, 369 include_bluesky, 370 location 371 from 372 profile 373 %s`, 374 whereClause, 375 ) 376 rows, err := e.Query(profilesQuery, args...) 377 if err != nil { 378 return nil, err 379 } 380 381 profileMap := make(map[string]*Profile) 382 for rows.Next() { 383 var profile Profile 384 var includeBluesky int 385 386 err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location) 387 if err != nil { 388 return nil, err 389 } 390 391 if includeBluesky != 0 { 392 profile.IncludeBluesky = true 393 } 394 395 profileMap[profile.Did] = &profile 396 } 397 if err = rows.Err(); err != nil { 398 return nil, err 399 } 400 401 // populate profile links 402 inClause := strings.TrimSuffix(strings.Repeat("?, ", len(profileMap)), ", ") 403 args = make([]any, len(profileMap)) 404 i := 0 405 for did := range profileMap { 406 args[i] = did 407 i++ 408 } 409 410 linksQuery := fmt.Sprintf("select link, did from profile_links where did in (%s)", inClause) 411 rows, err = e.Query(linksQuery, args...) 412 if err != nil { 413 return nil, err 414 } 415 idxs := make(map[string]int) 416 for did := range profileMap { 417 idxs[did] = 0 418 } 419 for rows.Next() { 420 var link, did string 421 if err = rows.Scan(&link, &did); err != nil { 422 return nil, err 423 } 424 425 idx := idxs[did] 426 profileMap[did].Links[idx] = link 427 idxs[did] = idx + 1 428 } 429 430 pinsQuery := fmt.Sprintf("select at_uri, did from profile_pinned_repositories where did in (%s)", inClause) 431 rows, err = e.Query(pinsQuery, args...) 432 if err != nil { 433 return nil, err 434 } 435 idxs = make(map[string]int) 436 for did := range profileMap { 437 idxs[did] = 0 438 } 439 for rows.Next() { 440 var link syntax.ATURI 441 var did string 442 if err = rows.Scan(&link, &did); err != nil { 443 return nil, err 444 } 445 446 idx := idxs[did] 447 profileMap[did].PinnedRepos[idx] = link 448 idxs[did] = idx + 1 449 } 450 451 var profiles []Profile 452 for _, p := range profileMap { 453 profiles = append(profiles, *p) 454 } 455 456 return profiles, nil 457} 458 459func GetProfile(e Execer, did string) (*Profile, error) { 460 var profile Profile 461 profile.Did = did 462 463 includeBluesky := 0 464 err := e.QueryRow( 465 `select description, include_bluesky, location from profile where did = ?`, 466 did, 467 ).Scan(&profile.Description, &includeBluesky, &profile.Location) 468 if err == sql.ErrNoRows { 469 profile := Profile{} 470 profile.Did = did 471 return &profile, nil 472 } 473 474 if err != nil { 475 return nil, err 476 } 477 478 if includeBluesky != 0 { 479 profile.IncludeBluesky = true 480 } 481 482 rows, err := e.Query(`select link from profile_links where did = ?`, did) 483 if err != nil { 484 return nil, err 485 } 486 defer rows.Close() 487 i := 0 488 for rows.Next() { 489 if err := rows.Scan(&profile.Links[i]); err != nil { 490 return nil, err 491 } 492 i++ 493 } 494 495 rows, err = e.Query(`select kind from profile_stats where did = ?`, did) 496 if err != nil { 497 return nil, err 498 } 499 defer rows.Close() 500 i = 0 501 for rows.Next() { 502 if err := rows.Scan(&profile.Stats[i].Kind); err != nil { 503 return nil, err 504 } 505 value, err := GetVanityStat(e, profile.Did, profile.Stats[i].Kind) 506 if err != nil { 507 return nil, err 508 } 509 profile.Stats[i].Value = value 510 i++ 511 } 512 513 rows, err = e.Query(`select at_uri from profile_pinned_repositories where did = ?`, did) 514 if err != nil { 515 return nil, err 516 } 517 defer rows.Close() 518 i = 0 519 for rows.Next() { 520 if err := rows.Scan(&profile.PinnedRepos[i]); err != nil { 521 return nil, err 522 } 523 i++ 524 } 525 526 return &profile, nil 527} 528 529func GetVanityStat(e Execer, did string, stat VanityStatKind) (uint64, error) { 530 query := "" 531 var args []any 532 switch stat { 533 case VanityStatMergedPRCount: 534 query = `select count(id) from pulls where owner_did = ? and state = ?` 535 args = append(args, did, PullMerged) 536 case VanityStatClosedPRCount: 537 query = `select count(id) from pulls where owner_did = ? and state = ?` 538 args = append(args, did, PullClosed) 539 case VanityStatOpenPRCount: 540 query = `select count(id) from pulls where owner_did = ? and state = ?` 541 args = append(args, did, PullOpen) 542 case VanityStatOpenIssueCount: 543 query = `select count(id) from issues where owner_did = ? and open = 1` 544 args = append(args, did) 545 case VanityStatClosedIssueCount: 546 query = `select count(id) from issues where owner_did = ? and open = 0` 547 args = append(args, did) 548 case VanityStatRepositoryCount: 549 query = `select count(id) from repos where did = ?` 550 args = append(args, did) 551 } 552 553 var result uint64 554 err := e.QueryRow(query, args...).Scan(&result) 555 if err != nil { 556 return 0, err 557 } 558 559 return result, nil 560} 561 562func ValidateProfile(e Execer, profile *Profile) error { 563 // ensure description is not too long 564 if len(profile.Description) > 256 { 565 return fmt.Errorf("Entered bio is too long.") 566 } 567 568 // ensure description is not too long 569 if len(profile.Location) > 40 { 570 return fmt.Errorf("Entered location is too long.") 571 } 572 573 // ensure links are in order 574 err := validateLinks(profile) 575 if err != nil { 576 return err 577 } 578 579 // ensure all pinned repos are either own repos or collaborating repos 580 repos, err := GetAllReposByDid(e, profile.Did) 581 if err != nil { 582 log.Printf("getting repos for %s: %s", profile.Did, err) 583 } 584 585 collaboratingRepos, err := CollaboratingIn(e, profile.Did) 586 if err != nil { 587 log.Printf("getting collaborating repos for %s: %s", profile.Did, err) 588 } 589 590 var validRepos []syntax.ATURI 591 for _, r := range repos { 592 validRepos = append(validRepos, r.RepoAt()) 593 } 594 for _, r := range collaboratingRepos { 595 validRepos = append(validRepos, r.RepoAt()) 596 } 597 598 for _, pinned := range profile.PinnedRepos { 599 if pinned == "" { 600 continue 601 } 602 if !slices.Contains(validRepos, pinned) { 603 return fmt.Errorf("Invalid pinned repo: `%s, does not belong to own or collaborating repos", pinned) 604 } 605 } 606 607 return nil 608} 609 610func validateLinks(profile *Profile) error { 611 for i, link := range profile.Links { 612 if link == "" { 613 continue 614 } 615 616 parsedURL, err := url.Parse(link) 617 if err != nil { 618 return fmt.Errorf("Invalid URL '%s': %v\n", link, err) 619 } 620 621 if parsedURL.Scheme == "" { 622 if strings.HasPrefix(link, "//") { 623 profile.Links[i] = "https:" + link 624 } else { 625 profile.Links[i] = "https://" + link 626 } 627 continue 628 } else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { 629 return fmt.Errorf("Warning: URL '%s' has unusual scheme: %s\n", link, parsedURL.Scheme) 630 } 631 632 // catch relative paths 633 if parsedURL.Host == "" { 634 return fmt.Errorf("Warning: URL '%s' appears to be a relative path\n", link) 635 } 636 } 637 return nil 638}