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