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