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