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