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