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