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