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