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