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