Add profile activity and stars data
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
142e85f42dc9ca77ee8893b54f0b1b89b1a2a2f3- Parents
-
5381fe8 - Tree
ed1824e
142e85f
142e85f42dc9ca77ee8893b54f0b1b89b1a2a2f35381fe8
ed1824e| Status | File | + | - |
|---|---|---|---|
| M |
internal/issues/queries/issues.sql
|
21 | 0 |
| M |
internal/issues/sqlc/issues.sql.go
|
86 | 0 |
| M |
internal/issues/sqlc/querier.go
|
4 | 0 |
| M |
internal/social/queries/stars.sql
|
4 | 1 |
| M |
internal/social/sqlc/stars.sql.go
|
6 | 1 |
| M |
internal/web/handlers/profile/overview.go
|
328 | 0 |
| M |
internal/web/handlers/profile/profile_test.go
|
108 | 1 |
| M |
internal/web/handlers/profile/stars_tab.go
|
206 | 37 |
internal/issues/queries/issues.sqlmodified@@ -206,6 +206,27 @@ SELECT * FROM issue_events | ||
| 206 | 206 | WHERE issue_id = $1 |
| 207 | 207 | ORDER BY created_at ASC; |
| 208 | 208 | |
| 209 | +-- name: ListProfileAuthoredIssuesForUser :many | |
| 210 | +-- Cross-repository profile contribution activity. The handler performs the | |
| 211 | +-- final repo visibility gate with policy.IsVisibleTo so private issues and | |
| 212 | +-- PRs never leak through the public profile timeline. | |
| 213 | +SELECT | |
| 214 | + i.id, i.repo_id, i.number, i.kind, i.title, i.state, i.created_at, i.closed_at, | |
| 215 | + pr.merged_at, | |
| 216 | + r.name AS repo_name, r.visibility, r.owner_user_id, r.owner_org_id, | |
| 217 | + COALESCE(u.username, o.slug)::text AS owner_slug | |
| 218 | +FROM issues i | |
| 219 | +JOIN repos r ON r.id = i.repo_id | |
| 220 | +LEFT JOIN pull_requests pr ON pr.issue_id = i.id | |
| 221 | +LEFT JOIN users u ON u.id = r.owner_user_id | |
| 222 | +LEFT JOIN orgs o ON o.id = r.owner_org_id | |
| 223 | +WHERE i.author_user_id = $1 | |
| 224 | + AND i.created_at >= $2 | |
| 225 | + AND i.created_at < $3 | |
| 226 | + AND r.deleted_at IS NULL | |
| 227 | +ORDER BY i.created_at DESC, i.id DESC | |
| 228 | +LIMIT $4; | |
| 229 | + | |
| 209 | 230 | -- name: InsertIssueReference :exec |
| 210 | 231 | INSERT INTO issue_references ( |
| 211 | 232 | source_issue_id, target_issue_id, source_kind, source_object_id |
internal/issues/sqlc/issues.sql.gomodified@@ -765,6 +765,92 @@ func (q *Queries) ListMilestones(ctx context.Context, db DBTX, repoID int64) ([] | ||
| 765 | 765 | return items, nil |
| 766 | 766 | } |
| 767 | 767 | |
| 768 | +const listProfileAuthoredIssuesForUser = `-- name: ListProfileAuthoredIssuesForUser :many | |
| 769 | +SELECT | |
| 770 | + i.id, i.repo_id, i.number, i.kind, i.title, i.state, i.created_at, i.closed_at, | |
| 771 | + pr.merged_at, | |
| 772 | + r.name AS repo_name, r.visibility, r.owner_user_id, r.owner_org_id, | |
| 773 | + COALESCE(u.username, o.slug)::text AS owner_slug | |
| 774 | +FROM issues i | |
| 775 | +JOIN repos r ON r.id = i.repo_id | |
| 776 | +LEFT JOIN pull_requests pr ON pr.issue_id = i.id | |
| 777 | +LEFT JOIN users u ON u.id = r.owner_user_id | |
| 778 | +LEFT JOIN orgs o ON o.id = r.owner_org_id | |
| 779 | +WHERE i.author_user_id = $1 | |
| 780 | + AND i.created_at >= $2 | |
| 781 | + AND i.created_at < $3 | |
| 782 | + AND r.deleted_at IS NULL | |
| 783 | +ORDER BY i.created_at DESC, i.id DESC | |
| 784 | +LIMIT $4 | |
| 785 | +` | |
| 786 | + | |
| 787 | +type ListProfileAuthoredIssuesForUserParams struct { | |
| 788 | + AuthorUserID pgtype.Int8 | |
| 789 | + CreatedAt pgtype.Timestamptz | |
| 790 | + CreatedAt_2 pgtype.Timestamptz | |
| 791 | + Limit int32 | |
| 792 | +} | |
| 793 | + | |
| 794 | +type ListProfileAuthoredIssuesForUserRow struct { | |
| 795 | + ID int64 | |
| 796 | + RepoID int64 | |
| 797 | + Number int64 | |
| 798 | + Kind IssueKind | |
| 799 | + Title string | |
| 800 | + State IssueState | |
| 801 | + CreatedAt pgtype.Timestamptz | |
| 802 | + ClosedAt pgtype.Timestamptz | |
| 803 | + MergedAt pgtype.Timestamptz | |
| 804 | + RepoName string | |
| 805 | + Visibility RepoVisibility | |
| 806 | + OwnerUserID pgtype.Int8 | |
| 807 | + OwnerOrgID pgtype.Int8 | |
| 808 | + OwnerSlug string | |
| 809 | +} | |
| 810 | + | |
| 811 | +// Cross-repository profile contribution activity. The handler performs the | |
| 812 | +// final repo visibility gate with policy.IsVisibleTo so private issues and | |
| 813 | +// PRs never leak through the public profile timeline. | |
| 814 | +func (q *Queries) ListProfileAuthoredIssuesForUser(ctx context.Context, db DBTX, arg ListProfileAuthoredIssuesForUserParams) ([]ListProfileAuthoredIssuesForUserRow, error) { | |
| 815 | + rows, err := db.Query(ctx, listProfileAuthoredIssuesForUser, | |
| 816 | + arg.AuthorUserID, | |
| 817 | + arg.CreatedAt, | |
| 818 | + arg.CreatedAt_2, | |
| 819 | + arg.Limit, | |
| 820 | + ) | |
| 821 | + if err != nil { | |
| 822 | + return nil, err | |
| 823 | + } | |
| 824 | + defer rows.Close() | |
| 825 | + items := []ListProfileAuthoredIssuesForUserRow{} | |
| 826 | + for rows.Next() { | |
| 827 | + var i ListProfileAuthoredIssuesForUserRow | |
| 828 | + if err := rows.Scan( | |
| 829 | + &i.ID, | |
| 830 | + &i.RepoID, | |
| 831 | + &i.Number, | |
| 832 | + &i.Kind, | |
| 833 | + &i.Title, | |
| 834 | + &i.State, | |
| 835 | + &i.CreatedAt, | |
| 836 | + &i.ClosedAt, | |
| 837 | + &i.MergedAt, | |
| 838 | + &i.RepoName, | |
| 839 | + &i.Visibility, | |
| 840 | + &i.OwnerUserID, | |
| 841 | + &i.OwnerOrgID, | |
| 842 | + &i.OwnerSlug, | |
| 843 | + ); err != nil { | |
| 844 | + return nil, err | |
| 845 | + } | |
| 846 | + items = append(items, i) | |
| 847 | + } | |
| 848 | + if err := rows.Err(); err != nil { | |
| 849 | + return nil, err | |
| 850 | + } | |
| 851 | + return items, nil | |
| 852 | +} | |
| 853 | + | |
| 768 | 854 | const milestoneIssueCounts = `-- name: MilestoneIssueCounts :one |
| 769 | 855 | SELECT |
| 770 | 856 | count(*) FILTER (WHERE state = 'open')::int AS open_count, |
internal/issues/sqlc/querier.gomodified@@ -57,6 +57,10 @@ type Querier interface { | ||
| 57 | 57 | ListLabels(ctx context.Context, db DBTX, repoID int64) ([]Label, error) |
| 58 | 58 | ListLabelsOnIssue(ctx context.Context, db DBTX, issueID int64) ([]Label, error) |
| 59 | 59 | ListMilestones(ctx context.Context, db DBTX, repoID int64) ([]Milestone, error) |
| 60 | + // Cross-repository profile contribution activity. The handler performs the | |
| 61 | + // final repo visibility gate with policy.IsVisibleTo so private issues and | |
| 62 | + // PRs never leak through the public profile timeline. | |
| 63 | + ListProfileAuthoredIssuesForUser(ctx context.Context, db DBTX, arg ListProfileAuthoredIssuesForUserParams) ([]ListProfileAuthoredIssuesForUserRow, error) | |
| 60 | 64 | // Open + closed counts for the milestone progress bar. |
| 61 | 65 | MilestoneIssueCounts(ctx context.Context, db DBTX, milestoneID pgtype.Int8) (MilestoneIssueCountsRow, error) |
| 62 | 66 | RemoveIssueLabel(ctx context.Context, db DBTX, arg RemoveIssueLabelParams) error |
internal/web/handlers/profile/overview.gomodified@@ -10,6 +10,7 @@ import ( | ||
| 10 | 10 | "io" |
| 11 | 11 | "net/url" |
| 12 | 12 | "path" |
| 13 | + "sort" | |
| 13 | 14 | "strconv" |
| 14 | 15 | "strings" |
| 15 | 16 | "time" |
@@ -19,6 +20,7 @@ import ( | ||
| 19 | 20 | "golang.org/x/net/html" |
| 20 | 21 | |
| 21 | 22 | "github.com/tenseleyFlow/shithub/internal/auth/policy" |
| 23 | + issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc" | |
| 22 | 24 | mdrender "github.com/tenseleyFlow/shithub/internal/markdown" |
| 23 | 25 | orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc" |
| 24 | 26 | repogit "github.com/tenseleyFlow/shithub/internal/repos/git" |
@@ -31,6 +33,8 @@ const ( | ||
| 31 | 33 | profileContribRepoLimit = 500 |
| 32 | 34 | profileContribMaxPerRepo = 5000 |
| 33 | 35 | profileReadmeMaxBytes = 1 * 1024 * 1024 |
| 36 | + profileActivityMonths = 1 | |
| 37 | + profileActivityIssueRows = 1000 | |
| 34 | 38 | ) |
| 35 | 39 | |
| 36 | 40 | type profileOrgBadge struct { |
@@ -59,6 +63,8 @@ type contributionCalendar struct { | ||
| 59 | 63 | MonthRepoCount int |
| 60 | 64 | HasRepositoryData bool |
| 61 | 65 | IncludePrivateContributions bool |
| 66 | + Activity []profileActivityMonth | |
| 67 | + HasMoreActivity bool | |
| 62 | 68 | } |
| 63 | 69 | |
| 64 | 70 | type contributionYear struct { |
@@ -81,6 +87,37 @@ type contributionDay struct { | ||
| 81 | 87 | IsInWindow bool |
| 82 | 88 | } |
| 83 | 89 | |
| 90 | +type profileActivityMonth struct { | |
| 91 | + Label string | |
| 92 | + Items []profileActivityItem | |
| 93 | + InitiallyHidden bool | |
| 94 | +} | |
| 95 | + | |
| 96 | +type profileActivityItem struct { | |
| 97 | + Kind string | |
| 98 | + Icon string | |
| 99 | + Summary string | |
| 100 | + Total int | |
| 101 | + Repos []profileActivityRepo | |
| 102 | +} | |
| 103 | + | |
| 104 | +type profileActivityRepo struct { | |
| 105 | + FullName string | |
| 106 | + URL string | |
| 107 | + Count int | |
| 108 | + CountLabel string | |
| 109 | + Language string | |
| 110 | + LanguageColor template.CSS | |
| 111 | + DateLabel string | |
| 112 | + StateCounts []profileActivityStateCount | |
| 113 | +} | |
| 114 | + | |
| 115 | +type profileActivityStateCount struct { | |
| 116 | + Label string | |
| 117 | + Class string | |
| 118 | + Count int | |
| 119 | +} | |
| 120 | + | |
| 84 | 121 | type profileContributionRepo struct { |
| 85 | 122 | Repo reposdb.Repo |
| 86 | 123 | OwnerSlug string |
@@ -214,6 +251,7 @@ func (h *Handlers) contributionCalendar(ctx context.Context, user usersdb.User, | ||
| 214 | 251 | windowEnd := windowEndDay.Add(24 * time.Hour) |
| 215 | 252 | activityMonth := time.Date(windowEndDay.Year(), windowEndDay.Month(), 1, 0, 0, 0, 0, time.UTC) |
| 216 | 253 | repos := h.profileContributionRepos(ctx, user, viewer, user.IncludePrivateContributions) |
| 254 | + activity := newProfileActivityBuilder() | |
| 217 | 255 | |
| 218 | 256 | counts := map[string]int{} |
| 219 | 257 | reposWithMonthActivity := map[int64]struct{}{} |
@@ -246,12 +284,24 @@ func (h *Handlers) contributionCalendar(ctx context.Context, user usersdb.User, | ||
| 246 | 284 | } |
| 247 | 285 | key := day.Format("2006-01-02") |
| 248 | 286 | counts[key]++ |
| 287 | + activity.addCommit(day, source) | |
| 249 | 288 | if day.Year() == activityMonth.Year() && day.Month() == activityMonth.Month() { |
| 250 | 289 | reposWithMonthActivity[source.Repo.ID] = struct{}{} |
| 251 | 290 | } |
| 252 | 291 | } |
| 253 | 292 | } |
| 254 | 293 | } |
| 294 | + for _, source := range repos { | |
| 295 | + created := source.Repo.CreatedAt.Time.UTC() | |
| 296 | + if !source.Repo.CreatedAt.Valid || created.Before(windowStart) || !created.Before(windowEnd) { | |
| 297 | + continue | |
| 298 | + } | |
| 299 | + if source.Repo.OwnerUserID.Int64 != user.ID || source.Repo.Visibility != reposdb.RepoVisibilityPublic { | |
| 300 | + continue | |
| 301 | + } | |
| 302 | + activity.addCreatedRepo(created, source) | |
| 303 | + } | |
| 304 | + h.addProfileThreadActivity(ctx, user, viewer, windowStart, windowEnd, activity) | |
| 255 | 305 | |
| 256 | 306 | weeks := make([]contributionWeek, 0, weekCount) |
| 257 | 307 | total := 0 |
@@ -296,6 +346,284 @@ func (h *Handlers) contributionCalendar(ctx context.Context, user usersdb.User, | ||
| 296 | 346 | MonthRepoCount: len(reposWithMonthActivity), |
| 297 | 347 | HasRepositoryData: h.d.RepoFS != nil && len(repos) > 0, |
| 298 | 348 | IncludePrivateContributions: user.IncludePrivateContributions, |
| 349 | + Activity: activity.months(profileActivityMonths), | |
| 350 | + HasMoreActivity: activity.hasMore(profileActivityMonths), | |
| 351 | + } | |
| 352 | +} | |
| 353 | + | |
| 354 | +type profileActivityEvent struct { | |
| 355 | + Kind string | |
| 356 | + When time.Time | |
| 357 | + FullName string | |
| 358 | + URL string | |
| 359 | + Language string | |
| 360 | + IsPrivate bool | |
| 361 | + State string | |
| 362 | +} | |
| 363 | + | |
| 364 | +type profileActivityBuilder struct { | |
| 365 | + events []profileActivityEvent | |
| 366 | +} | |
| 367 | + | |
| 368 | +func newProfileActivityBuilder() *profileActivityBuilder { | |
| 369 | + return &profileActivityBuilder{events: make([]profileActivityEvent, 0, 64)} | |
| 370 | +} | |
| 371 | + | |
| 372 | +func (b *profileActivityBuilder) addCommit(day time.Time, source profileContributionRepo) { | |
| 373 | + isPrivate := source.Repo.Visibility == reposdb.RepoVisibilityPrivate | |
| 374 | + fullName := source.OwnerSlug + "/" + source.Repo.Name | |
| 375 | + url := "/" + url.PathEscape(source.OwnerSlug) + "/" + url.PathEscape(source.Repo.Name) | |
| 376 | + if isPrivate { | |
| 377 | + fullName = "Private repositories" | |
| 378 | + url = "" | |
| 379 | + } | |
| 380 | + b.events = append(b.events, profileActivityEvent{ | |
| 381 | + Kind: "commits", | |
| 382 | + When: day, | |
| 383 | + FullName: fullName, | |
| 384 | + URL: url, | |
| 385 | + Language: pgTextStringOrEmpty(source.Repo.PrimaryLanguage), | |
| 386 | + IsPrivate: isPrivate, | |
| 387 | + }) | |
| 388 | +} | |
| 389 | + | |
| 390 | +func (b *profileActivityBuilder) addCreatedRepo(day time.Time, source profileContributionRepo) { | |
| 391 | + b.events = append(b.events, profileActivityEvent{ | |
| 392 | + Kind: "repos", | |
| 393 | + When: day, | |
| 394 | + FullName: source.OwnerSlug + "/" + source.Repo.Name, | |
| 395 | + URL: "/" + url.PathEscape(source.OwnerSlug) + "/" + url.PathEscape(source.Repo.Name), | |
| 396 | + Language: pgTextStringOrEmpty(source.Repo.PrimaryLanguage), | |
| 397 | + }) | |
| 398 | +} | |
| 399 | + | |
| 400 | +func (b *profileActivityBuilder) addThread(day time.Time, kind, ownerSlug, repoName, state string) { | |
| 401 | + b.events = append(b.events, profileActivityEvent{ | |
| 402 | + Kind: kind, | |
| 403 | + When: day, | |
| 404 | + FullName: ownerSlug + "/" + repoName, | |
| 405 | + URL: "/" + url.PathEscape(ownerSlug) + "/" + url.PathEscape(repoName), | |
| 406 | + State: state, | |
| 407 | + }) | |
| 408 | +} | |
| 409 | + | |
| 410 | +func (b *profileActivityBuilder) hasMore(initialMonths int) bool { | |
| 411 | + return len(b.monthKeys()) > initialMonths | |
| 412 | +} | |
| 413 | + | |
| 414 | +func (b *profileActivityBuilder) months(initialMonths int) []profileActivityMonth { | |
| 415 | + keys := b.monthKeys() | |
| 416 | + byMonth := map[string]map[string]map[string]*profileActivityRepo{} | |
| 417 | + monthTimes := map[string]time.Time{} | |
| 418 | + for _, event := range b.events { | |
| 419 | + month := time.Date(event.When.UTC().Year(), event.When.UTC().Month(), 1, 0, 0, 0, 0, time.UTC) | |
| 420 | + key := month.Format("2006-01") | |
| 421 | + monthTimes[key] = month | |
| 422 | + if byMonth[key] == nil { | |
| 423 | + byMonth[key] = map[string]map[string]*profileActivityRepo{} | |
| 424 | + } | |
| 425 | + if byMonth[key][event.Kind] == nil { | |
| 426 | + byMonth[key][event.Kind] = map[string]*profileActivityRepo{} | |
| 427 | + } | |
| 428 | + repoKey := event.FullName | |
| 429 | + if event.IsPrivate { | |
| 430 | + repoKey = "__private__" | |
| 431 | + } | |
| 432 | + repo := byMonth[key][event.Kind][repoKey] | |
| 433 | + if repo == nil { | |
| 434 | + language := event.Language | |
| 435 | + if event.IsPrivate { | |
| 436 | + language = "" | |
| 437 | + } | |
| 438 | + repo = &profileActivityRepo{ | |
| 439 | + FullName: event.FullName, | |
| 440 | + URL: event.URL, | |
| 441 | + Language: language, | |
| 442 | + LanguageColor: template.CSS(orgLanguageColor(language)), //nolint:gosec // server-side constant map. | |
| 443 | + } | |
| 444 | + byMonth[key][event.Kind][repoKey] = repo | |
| 445 | + } | |
| 446 | + repo.Count++ | |
| 447 | + if event.Kind == "repos" { | |
| 448 | + repo.DateLabel = event.When.UTC().Format("Jan 2") | |
| 449 | + } | |
| 450 | + if event.State != "" { | |
| 451 | + incrementActivityState(repo, event.State) | |
| 452 | + } | |
| 453 | + } | |
| 454 | + | |
| 455 | + out := make([]profileActivityMonth, 0, len(keys)) | |
| 456 | + for i, key := range keys { | |
| 457 | + kindRepos := byMonth[key] | |
| 458 | + items := make([]profileActivityItem, 0, len(kindRepos)) | |
| 459 | + for _, kind := range []string{"commits", "repos", "pulls", "issues"} { | |
| 460 | + repos, ok := kindRepos[kind] | |
| 461 | + if !ok { | |
| 462 | + continue | |
| 463 | + } | |
| 464 | + item := profileActivityItem{ | |
| 465 | + Kind: kind, | |
| 466 | + Icon: profileActivityIcon(kind), | |
| 467 | + Repos: sortedActivityRepos(kind, repos), | |
| 468 | + } | |
| 469 | + for _, repo := range repos { | |
| 470 | + item.Total += repo.Count | |
| 471 | + } | |
| 472 | + item.Summary = profileActivitySummary(kind, item.Total, len(repos)) | |
| 473 | + items = append(items, item) | |
| 474 | + } | |
| 475 | + out = append(out, profileActivityMonth{ | |
| 476 | + Label: monthTimes[key].Format("January 2006"), | |
| 477 | + Items: items, | |
| 478 | + InitiallyHidden: i >= initialMonths, | |
| 479 | + }) | |
| 480 | + } | |
| 481 | + return out | |
| 482 | +} | |
| 483 | + | |
| 484 | +func (b *profileActivityBuilder) monthKeys() []string { | |
| 485 | + seen := map[string]struct{}{} | |
| 486 | + for _, event := range b.events { | |
| 487 | + key := event.When.UTC().Format("2006-01") | |
| 488 | + seen[key] = struct{}{} | |
| 489 | + } | |
| 490 | + keys := make([]string, 0, len(seen)) | |
| 491 | + for key := range seen { | |
| 492 | + keys = append(keys, key) | |
| 493 | + } | |
| 494 | + sort.Sort(sort.Reverse(sort.StringSlice(keys))) | |
| 495 | + return keys | |
| 496 | +} | |
| 497 | + | |
| 498 | +func incrementActivityState(repo *profileActivityRepo, state string) { | |
| 499 | + for i := range repo.StateCounts { | |
| 500 | + if repo.StateCounts[i].Class == state { | |
| 501 | + repo.StateCounts[i].Count++ | |
| 502 | + return | |
| 503 | + } | |
| 504 | + } | |
| 505 | + repo.StateCounts = append(repo.StateCounts, profileActivityStateCount{ | |
| 506 | + Label: state, | |
| 507 | + Class: state, | |
| 508 | + Count: 1, | |
| 509 | + }) | |
| 510 | +} | |
| 511 | + | |
| 512 | +func sortedActivityRepos(kind string, repos map[string]*profileActivityRepo) []profileActivityRepo { | |
| 513 | + out := make([]profileActivityRepo, 0, len(repos)) | |
| 514 | + for _, repo := range repos { | |
| 515 | + if kind == "commits" { | |
| 516 | + repo.CountLabel = pluralizeCount(repo.Count, "commit", "commits") | |
| 517 | + } | |
| 518 | + sort.SliceStable(repo.StateCounts, func(i, j int) bool { | |
| 519 | + return activityStateOrder(repo.StateCounts[i].Class) < activityStateOrder(repo.StateCounts[j].Class) | |
| 520 | + }) | |
| 521 | + out = append(out, *repo) | |
| 522 | + } | |
| 523 | + sort.SliceStable(out, func(i, j int) bool { | |
| 524 | + if kind == "repos" && out[i].DateLabel != out[j].DateLabel { | |
| 525 | + return out[i].DateLabel > out[j].DateLabel | |
| 526 | + } | |
| 527 | + if out[i].Count != out[j].Count { | |
| 528 | + return out[i].Count > out[j].Count | |
| 529 | + } | |
| 530 | + return out[i].FullName < out[j].FullName | |
| 531 | + }) | |
| 532 | + if len(out) > 5 { | |
| 533 | + return out[:5] | |
| 534 | + } | |
| 535 | + return out | |
| 536 | +} | |
| 537 | + | |
| 538 | +func activityStateOrder(state string) int { | |
| 539 | + switch state { | |
| 540 | + case "merged": | |
| 541 | + return 0 | |
| 542 | + case "open": | |
| 543 | + return 1 | |
| 544 | + case "closed": | |
| 545 | + return 2 | |
| 546 | + default: | |
| 547 | + return 3 | |
| 548 | + } | |
| 549 | +} | |
| 550 | + | |
| 551 | +func profileActivityIcon(kind string) string { | |
| 552 | + switch kind { | |
| 553 | + case "commits": | |
| 554 | + return "git-commit" | |
| 555 | + case "pulls": | |
| 556 | + return "git-pull-request" | |
| 557 | + case "issues": | |
| 558 | + return "issue-opened" | |
| 559 | + default: | |
| 560 | + return "repo" | |
| 561 | + } | |
| 562 | +} | |
| 563 | + | |
| 564 | +func profileActivitySummary(kind string, total, repoCount int) string { | |
| 565 | + switch kind { | |
| 566 | + case "commits": | |
| 567 | + return "Created " + pluralizeCount(total, "commit", "commits") + " in " + pluralizeCount(repoCount, "repository", "repositories") | |
| 568 | + case "repos": | |
| 569 | + return "Created " + pluralizeCount(total, "repository", "repositories") | |
| 570 | + case "pulls": | |
| 571 | + return "Opened " + pluralizeCount(total, "pull request", "pull requests") + " in " + pluralizeCount(repoCount, "repository", "repositories") | |
| 572 | + case "issues": | |
| 573 | + return "Opened " + pluralizeCount(total, "issue", "issues") + " in " + pluralizeCount(repoCount, "repository", "repositories") | |
| 574 | + default: | |
| 575 | + return pluralizeCount(total, "contribution", "contributions") | |
| 576 | + } | |
| 577 | +} | |
| 578 | + | |
| 579 | +func pluralizeCount(count int, one, many string) string { | |
| 580 | + if count == 1 { | |
| 581 | + return "1 " + one | |
| 582 | + } | |
| 583 | + return strconv.Itoa(count) + " " + many | |
| 584 | +} | |
| 585 | + | |
| 586 | +func (h *Handlers) addProfileThreadActivity(ctx context.Context, user usersdb.User, viewer middleware.CurrentUser, windowStart, windowEnd time.Time, activity *profileActivityBuilder) { | |
| 587 | + rows, err := issuesdb.New().ListProfileAuthoredIssuesForUser(ctx, h.d.Pool, issuesdb.ListProfileAuthoredIssuesForUserParams{ | |
| 588 | + AuthorUserID: pgtype.Int8{Int64: user.ID, Valid: true}, | |
| 589 | + CreatedAt: pgtype.Timestamptz{Time: windowStart, Valid: true}, | |
| 590 | + CreatedAt_2: pgtype.Timestamptz{Time: windowEnd, Valid: true}, | |
| 591 | + Limit: profileActivityIssueRows, | |
| 592 | + }) | |
| 593 | + if err != nil { | |
| 594 | + h.d.Logger.WarnContext(ctx, "profile overview: list authored issues", "user_id", user.ID, "error", err) | |
| 595 | + return | |
| 596 | + } | |
| 597 | + actor := policy.AnonymousActor() | |
| 598 | + if !viewer.IsAnonymous() { | |
| 599 | + actor = viewer.PolicyActor() | |
| 600 | + } | |
| 601 | + deps := policy.Deps{Pool: h.d.Pool} | |
| 602 | + for _, row := range rows { | |
| 603 | + if row.Visibility == issuesdb.RepoVisibilityPrivate && !user.IncludePrivateContributions { | |
| 604 | + continue | |
| 605 | + } | |
| 606 | + ref := policy.RepoRef{ | |
| 607 | + ID: row.RepoID, | |
| 608 | + OwnerUserID: row.OwnerUserID.Int64, | |
| 609 | + OwnerOrgID: row.OwnerOrgID.Int64, | |
| 610 | + Visibility: string(row.Visibility), | |
| 611 | + } | |
| 612 | + if !policy.IsVisibleTo(ctx, deps, actor, ref) { | |
| 613 | + continue | |
| 614 | + } | |
| 615 | + kind := "issues" | |
| 616 | + state := "open" | |
| 617 | + if row.State == issuesdb.IssueStateClosed { | |
| 618 | + state = "closed" | |
| 619 | + } | |
| 620 | + if row.Kind == issuesdb.IssueKindPr { | |
| 621 | + kind = "pulls" | |
| 622 | + if row.MergedAt.Valid { | |
| 623 | + state = "merged" | |
| 624 | + } | |
| 625 | + } | |
| 626 | + activity.addThread(row.CreatedAt.Time, kind, row.OwnerSlug, row.RepoName, state) | |
| 299 | 627 | } |
| 300 | 628 | } |
| 301 | 629 | |
internal/web/handlers/profile/profile_test.gomodified@@ -61,8 +61,9 @@ func setupProfileEnvWithDeps(t *testing.T, objectStore storage.ObjectStore, repo | ||
| 61 | 61 | tmplFS := fstest.MapFS{ |
| 62 | 62 | "_layout.html": {Data: []byte(`{{ define "layout" }}<html><head><title>{{ .Title }}</title></head><body>{{ template "page" . }}</body></html>{{ end }}`)}, |
| 63 | 63 | "hello.html": {Data: []byte(`{{ define "page" }}home{{ end }}`)}, |
| 64 | - "profile/view.html": {Data: []byte(`{{ define "page" }}USER={{.User.Username}} DISPLAY={{.User.DisplayName}}{{ if .IsSelf }} SELF=1{{ end }}{{ if .IsFollowing }} FOLLOWING=1{{ end }} FOLLOWERS={{.FollowersCount}} FOLLOWINGCOUNT={{.FollowingCount}} BIO={{.User.Bio}} VISIBLE={{.VisibleRepoCount}} ORGS={{len .Orgs}} README={{.HasProfileReadme}} CONTRIB={{.Contributions.Total}} PERIOD={{.Contributions.Period}} PRIVATE={{.Contributions.IncludePrivateContributions}} WEEKS={{len .Contributions.Weeks}} YEARS={{len .Contributions.Years}} YEARLINKS={{range .Contributions.Years}}{{.Year}}:{{.Active}}:{{.Href}};{{end}} PINS={{len .PinnedRepos}} PINNAMES={{range .PinnedRepos}}{{.Name}};{{end}} CANDIDATES={{len .PinCandidates}} CANDIDATENAMES={{range .PinCandidates}}{{.OwnerSlug}}/{{.Name}};{{end}} SELECTED={{range .PinCandidates}}{{if .IsPinned}}{{.Name}};{{end}}{{end}}{{ if .CanCustomizePins }} CUSTOMIZE=1 ACTION={{.ContributionSettingsAction}} RETURN={{.ContributionSettingsReturn}}{{ end }}{{ end }}`)}, | |
| 64 | + "profile/view.html": {Data: []byte(`{{ define "page" }}USER={{.User.Username}} DISPLAY={{.User.DisplayName}}{{ if .IsSelf }} SELF=1{{ end }}{{ if .IsFollowing }} FOLLOWING=1{{ end }} FOLLOWERS={{.FollowersCount}} FOLLOWINGCOUNT={{.FollowingCount}} BIO={{.User.Bio}} VISIBLE={{.VisibleRepoCount}} ORGS={{len .Orgs}} README={{.HasProfileReadme}} CONTRIB={{.Contributions.Total}} PERIOD={{.Contributions.Period}} PRIVATE={{.Contributions.IncludePrivateContributions}} WEEKS={{len .Contributions.Weeks}} YEARS={{len .Contributions.Years}} YEARLINKS={{range .Contributions.Years}}{{.Year}}:{{.Active}}:{{.Href}};{{end}} ACTIVITY={{len .Contributions.Activity}} HASMORE={{.Contributions.HasMoreActivity}} ACTIVITYITEMS={{range .Contributions.Activity}}{{.Label}}:{{range .Items}}{{.Kind}}={{.Total}}/{{len .Repos}};{{end}}{{end}} PINS={{len .PinnedRepos}} PINNAMES={{range .PinnedRepos}}{{.Name}};{{end}} CANDIDATES={{len .PinCandidates}} CANDIDATENAMES={{range .PinCandidates}}{{.OwnerSlug}}/{{.Name}};{{end}} SELECTED={{range .PinCandidates}}{{if .IsPinned}}{{.Name}};{{end}}{{end}}{{ if .CanCustomizePins }} CUSTOMIZE=1 ACTION={{.ContributionSettingsAction}} RETURN={{.ContributionSettingsReturn}}{{ end }}{{ end }}`)}, | |
| 65 | 65 | "profile/follows_tab.html": {Data: []byte(`{{ define "page" }}FOLLOWTAB={{.ActiveTab}} USER={{.User.Username}} TOTAL={{len .Items}} ITEMS={{range .Items}}{{.Kind}}:{{.Username}};{{end}}{{ end }}`)}, |
| 66 | + "profile/stars_tab.html": {Data: []byte(`{{ define "page" }}STARSTAB={{.ActiveTab}} USER={{.User.Username}} DISPLAY={{.DisplayName}} TOTAL={{.StarTotal}} FILTERED={{.FilteredCount}} PAGE={{.Page}} HASNEXT={{.HasNext}} HASPREV={{.HasPrev}} STARS={{len .Stars}} ITEMS={{range .Stars}}{{.OwnerName}}/{{.RepoName}}:{{.PrimaryLanguage}}:{{.StarCount}};{{end}} LANGS={{range .LanguageOptions}}{{.}};{{end}} FILTERS={{.StarFilters.Query}}/{{.StarFilters.Type}}/{{.StarFilters.Language}}/{{.StarFilters.Sort}}{{ end }}`)}, | |
| 66 | 67 | "profile/suspended.html": {Data: []byte(`{{ define "page" }}SUSPENDED={{.Username}}{{ end }}`)}, |
| 67 | 68 | "orgs/profile.html": {Data: []byte(`{{ define "page" }}ORG={{.Org.Slug}}{{ if .IsFollowing }} FOLLOWING=1{{ end }} FOLLOWERS={{.FollowerCount}} REPOS={{len .Repos}} PINS={{len .PinnedRepos}} PINNAMES={{range .PinnedRepos}}{{.Name}};{{end}} CANDIDATES={{len .PinCandidates}} SELECTED={{range .PinCandidates}}{{if .IsPinned}}{{.Name}};{{end}}{{end}} MEMBERS={{.MemberCount}} PEOPLE={{len .People}} NAMES={{range .Repos}}{{.Name}};{{end}} LANGS={{range .TopLanguages}}{{.Name}}={{.Count}};{{end}} TOPICS={{range .TopTopics}}{{.Name}}={{.Count}};{{end}} VIEWAS={{.ViewAs}}{{ if .CanCustomizePins }} CUSTOMIZE=1{{ end }}{{ end }}`)}, |
| 68 | 69 | "orgs/repositories.html": {Data: []byte(`{{ define "page" }}ORGREPOS={{.Org.Slug}} ACTIVE={{.ActiveOrgNav}} TOTAL={{.RepoCount}} FILTERED={{.FilteredCount}} PAGE={{.Page}}/{{.PageCount}} TYPE={{.SelectedType}} LANG={{.SelectedLanguage}} SORT={{.SelectedSort}} PREV={{.PrevHref}} NEXT={{.NextHref}} NAMES={{range .Repos}}{{.Name}};{{end}}{{range .PaginationPages}} P{{.Number}}={{.Current}}{{end}}{{ end }}`)}, |
@@ -219,6 +220,42 @@ func (e *profileEnv) insertRepoCollaborator(t *testing.T, repoID, userID int64, | ||
| 219 | 220 | } |
| 220 | 221 | } |
| 221 | 222 | |
| 223 | +func (e *profileEnv) insertStar(t *testing.T, userID, repoID int64, when time.Time) { | |
| 224 | + t.Helper() | |
| 225 | + if _, err := e.pool.Exec(context.Background(), | |
| 226 | + `INSERT INTO stars (user_id, repo_id, starred_at) VALUES ($1, $2, $3)`, | |
| 227 | + userID, repoID, when); err != nil { | |
| 228 | + t.Fatalf("insert star: %v", err) | |
| 229 | + } | |
| 230 | +} | |
| 231 | + | |
| 232 | +func (e *profileEnv) insertIssue(t *testing.T, repoID, authorID, number int64, kind, state, title string, when time.Time) int64 { | |
| 233 | + t.Helper() | |
| 234 | + var issueID int64 | |
| 235 | + if err := e.pool.QueryRow(context.Background(), | |
| 236 | + `INSERT INTO issues (repo_id, number, kind, title, author_user_id, state, created_at, updated_at) | |
| 237 | + VALUES ($1, $2, $3, $4, $5, $6, $7, $7) | |
| 238 | + RETURNING id`, | |
| 239 | + repoID, number, kind, title, authorID, state, when).Scan(&issueID); err != nil { | |
| 240 | + t.Fatalf("insert issue: %v", err) | |
| 241 | + } | |
| 242 | + return issueID | |
| 243 | +} | |
| 244 | + | |
| 245 | +func (e *profileEnv) insertPullRequest(t *testing.T, issueID, repoID int64, mergedAt *time.Time) { | |
| 246 | + t.Helper() | |
| 247 | + var merged any | |
| 248 | + if mergedAt != nil { | |
| 249 | + merged = *mergedAt | |
| 250 | + } | |
| 251 | + if _, err := e.pool.Exec(context.Background(), | |
| 252 | + `INSERT INTO pull_requests (issue_id, base_ref, head_ref, head_repo_id, base_oid, head_oid, merged_at) | |
| 253 | + VALUES ($1, 'trunk', 'feature/profile-activity', $2, 'base', 'head', $3)`, | |
| 254 | + issueID, repoID, merged); err != nil { | |
| 255 | + t.Fatalf("insert pull request: %v", err) | |
| 256 | + } | |
| 257 | +} | |
| 258 | + | |
| 222 | 259 | func (e *profileEnv) writeInitialCommit(t *testing.T, owner, repoName, authorName, authorEmail string, when time.Time) string { |
| 223 | 260 | t.Helper() |
| 224 | 261 | if e.repoFS == nil { |
@@ -574,6 +611,40 @@ func TestProfile_ContributionsSelectedYearHasStableLinks(t *testing.T) { | ||
| 574 | 611 | } |
| 575 | 612 | } |
| 576 | 613 | |
| 614 | +func TestProfile_ContributionActivityIncludesCommitsReposIssuesAndPulls(t *testing.T) { | |
| 615 | + t.Parallel() | |
| 616 | + env := setupProfileEnvWithRepoFS(t) | |
| 617 | + alice := env.insertUser(t, "alice", "Alice Anderson", "") | |
| 618 | + env.insertVerifiedEmail(t, alice.ID, "alice@example.com") | |
| 619 | + currentRepoID := env.insertUserRepo(t, alice.ID, "current", "current work", "public", "Go", 0, 0) | |
| 620 | + oldRepoID := env.insertUserRepo(t, alice.ID, "archive", "older work", "public", "Rust", 0, 0) | |
| 621 | + | |
| 622 | + now := time.Now().UTC() | |
| 623 | + env.writeInitialCommit(t, "alice", "current", "Alice Anderson", "alice@example.com", now.AddDate(0, 0, -1)) | |
| 624 | + env.writeInitialCommit(t, "alice", "archive", "Alice Anderson", "alice@example.com", now.AddDate(0, -2, 0)) | |
| 625 | + env.insertIssue(t, currentRepoID, alice.ID, 1, "issue", "open", "Track profile activity", now.AddDate(0, 0, -3)) | |
| 626 | + prID := env.insertIssue(t, currentRepoID, alice.ID, 2, "pr", "closed", "Ship profile activity", now.AddDate(0, 0, -2)) | |
| 627 | + mergedAt := now.AddDate(0, 0, -1) | |
| 628 | + env.insertPullRequest(t, prID, currentRepoID, &mergedAt) | |
| 629 | + | |
| 630 | + body := env.getAs(t, "/alice", alice) | |
| 631 | + for _, want := range []string{ | |
| 632 | + "CONTRIB=2", | |
| 633 | + "HASMORE=true", | |
| 634 | + "commits=", | |
| 635 | + "repos=", | |
| 636 | + "issues=1/1", | |
| 637 | + "pulls=1/1", | |
| 638 | + } { | |
| 639 | + if !strings.Contains(body, want) { | |
| 640 | + t.Errorf("missing %q in body: %s", want, body) | |
| 641 | + } | |
| 642 | + } | |
| 643 | + if strings.Contains(body, strconv.FormatInt(oldRepoID, 10)) { | |
| 644 | + t.Fatalf("test template leaked implementation ids unexpectedly: %s", body) | |
| 645 | + } | |
| 646 | +} | |
| 647 | + | |
| 577 | 648 | func TestProfile_PrivateContributionsRequireOwnerOptIn(t *testing.T) { |
| 578 | 649 | t.Parallel() |
| 579 | 650 | env := setupProfileEnvWithRepoFS(t) |
@@ -871,6 +942,42 @@ func TestProfile_UserPinsIncludeAffiliatedOrgAndCollaboratorRepos(t *testing.T) | ||
| 871 | 942 | } |
| 872 | 943 | } |
| 873 | 944 | |
| 945 | +func TestProfile_StarsTabUsesProfileLayoutFiltersAndOrgOwners(t *testing.T) { | |
| 946 | + t.Parallel() | |
| 947 | + env := setupProfileEnv(t) | |
| 948 | + alice := env.insertUser(t, "alice", "Alice Anderson", "") | |
| 949 | + orgID := env.insertOrg(t, "acme", "Acme", "", alice) | |
| 950 | + orgRepoID := env.insertOrgRepo(t, orgID, "org-tool", "org owned work", "public", "Go", 7, 0) | |
| 951 | + userRepoID := env.insertUserRepo(t, alice.ID, "personal", "personal library", "public", "Rust", 2, 0) | |
| 952 | + privateRepoID := env.insertUserRepo(t, alice.ID, "secret", "private work", "private", "Go", 10, 0) | |
| 953 | + | |
| 954 | + now := time.Now().UTC() | |
| 955 | + env.insertStar(t, alice.ID, userRepoID, now.AddDate(0, 0, -3)) | |
| 956 | + env.insertStar(t, alice.ID, orgRepoID, now.AddDate(0, 0, -2)) | |
| 957 | + env.insertStar(t, alice.ID, privateRepoID, now.AddDate(0, 0, -1)) | |
| 958 | + | |
| 959 | + body := env.getAs(t, "/alice?tab=stars&q=org&language=Go&sort=stars", usersdb.User{}) | |
| 960 | + for _, want := range []string{ | |
| 961 | + "STARSTAB=stars", | |
| 962 | + "DISPLAY=Alice Anderson", | |
| 963 | + "TOTAL=2", | |
| 964 | + "FILTERED=1", | |
| 965 | + "STARS=1", | |
| 966 | + "ITEMS=acme/org-tool:Go:8;", | |
| 967 | + "LANGS=Go;Rust;", | |
| 968 | + "FILTERS=org/all/Go/stars", | |
| 969 | + } { | |
| 970 | + if !strings.Contains(body, want) { | |
| 971 | + t.Fatalf("missing %q in body: %s", want, body) | |
| 972 | + } | |
| 973 | + } | |
| 974 | + for _, notWant := range []string{"personal", "secret"} { | |
| 975 | + if strings.Contains(body, notWant) { | |
| 976 | + t.Fatalf("stars filter included %q unexpectedly: %s", notWant, body) | |
| 977 | + } | |
| 978 | + } | |
| 979 | +} | |
| 980 | + | |
| 874 | 981 | func TestProfile_OrgPinsFallbackUntilCustomized(t *testing.T) { |
| 875 | 982 | t.Parallel() |
| 876 | 983 | env := setupProfileEnv(t) |
internal/web/handlers/profile/stars_tab.gomodified@@ -4,9 +4,13 @@ package profile | ||
| 4 | 4 | |
| 5 | 5 | import ( |
| 6 | 6 | "fmt" |
| 7 | + "html/template" | |
| 7 | 8 | "net/http" |
| 8 | 9 | "net/url" |
| 10 | + "sort" | |
| 9 | 11 | "strconv" |
| 12 | + "strings" | |
| 13 | + "time" | |
| 10 | 14 | |
| 11 | 15 | "github.com/jackc/pgx/v5/pgtype" |
| 12 | 16 | |
@@ -16,7 +20,32 @@ import ( | ||
| 16 | 20 | "github.com/tenseleyFlow/shithub/internal/web/middleware" |
| 17 | 21 | ) |
| 18 | 22 | |
| 19 | -const starsTabPageSize = 30 | |
| 23 | +const ( | |
| 24 | + starsTabPageSize = 30 | |
| 25 | + starsTabScanLimit = 1000 | |
| 26 | +) | |
| 27 | + | |
| 28 | +type profileStarFilters struct { | |
| 29 | + Query string | |
| 30 | + Type string | |
| 31 | + Language string | |
| 32 | + Sort string | |
| 33 | +} | |
| 34 | + | |
| 35 | +type profileStarItem struct { | |
| 36 | + OwnerName string | |
| 37 | + RepoName string | |
| 38 | + FullName string | |
| 39 | + URL string | |
| 40 | + Description string | |
| 41 | + Visibility string | |
| 42 | + IsPrivate bool | |
| 43 | + StarCount int64 | |
| 44 | + PrimaryLanguage string | |
| 45 | + LanguageColor template.CSS | |
| 46 | + UpdatedAt time.Time | |
| 47 | + StarredAt time.Time | |
| 48 | +} | |
| 20 | 49 | |
| 21 | 50 | // serveStarsTab renders the `/{user}?tab=stars` view. |
| 22 | 51 | // |
@@ -29,16 +58,17 @@ func (h *Handlers) serveStarsTab(w http.ResponseWriter, r *http.Request, user us | ||
| 29 | 58 | if page < 1 { |
| 30 | 59 | page = 1 |
| 31 | 60 | } |
| 61 | + filters := profileStarFilters{ | |
| 62 | + Query: strings.TrimSpace(r.URL.Query().Get("q")), | |
| 63 | + Type: normalizeStarType(r.URL.Query().Get("type")), | |
| 64 | + Language: strings.TrimSpace(r.URL.Query().Get("language")), | |
| 65 | + Sort: normalizeStarSort(r.URL.Query().Get("sort")), | |
| 66 | + } | |
| 32 | 67 | |
| 33 | - // We over-fetch a little so the per-row visibility filter can | |
| 34 | - // drop private-repo rows without leaving a short page. With most | |
| 35 | - // stars being public this is a no-op; for users with many private | |
| 36 | - // stars the filter just trims more. | |
| 37 | - const fetch = starsTabPageSize * 2 | |
| 38 | 68 | rows, err := socialdb.New().ListStarsForUser(r.Context(), h.d.Pool, socialdb.ListStarsForUserParams{ |
| 39 | 69 | UserID: user.ID, |
| 40 | - Limit: fetch, | |
| 41 | - Offset: int32((page - 1) * starsTabPageSize), | |
| 70 | + Limit: starsTabScanLimit, | |
| 71 | + Offset: 0, | |
| 42 | 72 | }) |
| 43 | 73 | if err != nil { |
| 44 | 74 | h.d.Logger.ErrorContext(r.Context(), "stars tab: list", "error", err) |
@@ -52,11 +82,8 @@ func (h *Handlers) serveStarsTab(w http.ResponseWriter, r *http.Request, user us | ||
| 52 | 82 | } |
| 53 | 83 | deps := policy.Deps{Pool: h.d.Pool} |
| 54 | 84 | |
| 55 | - visible := make([]map[string]any, 0, len(rows)) | |
| 85 | + visible := make([]profileStarItem, 0, len(rows)) | |
| 56 | 86 | for _, row := range rows { |
| 57 | - if len(visible) >= starsTabPageSize { | |
| 58 | - break | |
| 59 | - } | |
| 60 | 87 | // Re-check visibility per row. Star is on a repo that may have |
| 61 | 88 | // flipped visibility between star-time and view-time. |
| 62 | 89 | ref := policy.RepoRef{ |
@@ -68,25 +95,41 @@ func (h *Handlers) serveStarsTab(w http.ResponseWriter, r *http.Request, user us | ||
| 68 | 95 | if !policy.IsVisibleTo(r.Context(), deps, actor, ref) { |
| 69 | 96 | continue |
| 70 | 97 | } |
| 71 | - // Resolve the owner's username for the link. The S31 org case | |
| 72 | - // adds an org-username path; for now user-owned only. | |
| 73 | - ownerName := "" | |
| 74 | - if row.OwnerUserID.Valid { | |
| 75 | - if u, err := h.q.GetUserByID(r.Context(), h.d.Pool, row.OwnerUserID.Int64); err == nil { | |
| 76 | - ownerName = u.Username | |
| 77 | - } | |
| 98 | + ownerName := row.OwnerSlug | |
| 99 | + if ownerName == "" { | |
| 100 | + continue | |
| 78 | 101 | } |
| 79 | - visible = append(visible, map[string]any{ | |
| 80 | - "OwnerName": ownerName, | |
| 81 | - "RepoName": row.RepoName, | |
| 82 | - "Description": row.Description, | |
| 83 | - "Visibility": string(row.Visibility), | |
| 84 | - "StarCount": row.StarCount, | |
| 85 | - "PrimaryLanguage": pgTextStringOrEmpty(row.PrimaryLanguage), | |
| 86 | - "StarredAt": row.StarredAt.Time, | |
| 102 | + language := pgTextStringOrEmpty(row.PrimaryLanguage) | |
| 103 | + visible = append(visible, profileStarItem{ | |
| 104 | + OwnerName: ownerName, | |
| 105 | + RepoName: row.RepoName, | |
| 106 | + FullName: ownerName + "/" + row.RepoName, | |
| 107 | + URL: "/" + url.PathEscape(ownerName) + "/" + url.PathEscape(row.RepoName), | |
| 108 | + Description: row.Description, | |
| 109 | + Visibility: string(row.Visibility), | |
| 110 | + IsPrivate: row.Visibility == socialdb.RepoVisibilityPrivate, | |
| 111 | + StarCount: row.StarCount, | |
| 112 | + PrimaryLanguage: language, | |
| 113 | + LanguageColor: template.CSS(orgLanguageColor(language)), //nolint:gosec // server-side constant map. | |
| 114 | + UpdatedAt: row.UpdatedAt.Time, | |
| 115 | + StarredAt: row.StarredAt.Time, | |
| 87 | 116 | }) |
| 88 | 117 | } |
| 89 | 118 | |
| 119 | + languageOptions := starLanguageOptions(visible) | |
| 120 | + filtered := filterProfileStars(visible, filters) | |
| 121 | + sortProfileStars(filtered, filters.Sort) | |
| 122 | + totalFiltered := len(filtered) | |
| 123 | + start := (page - 1) * starsTabPageSize | |
| 124 | + if start > totalFiltered { | |
| 125 | + start = totalFiltered | |
| 126 | + } | |
| 127 | + end := start + starsTabPageSize | |
| 128 | + if end > totalFiltered { | |
| 129 | + end = totalFiltered | |
| 130 | + } | |
| 131 | + paged := filtered[start:end] | |
| 132 | + | |
| 90 | 133 | if isSelf { |
| 91 | 134 | w.Header().Set("Cache-Control", "no-cache, private") |
| 92 | 135 | } else { |
@@ -94,18 +137,36 @@ func (h *Handlers) serveStarsTab(w http.ResponseWriter, r *http.Request, user us | ||
| 94 | 137 | } |
| 95 | 138 | |
| 96 | 139 | avatarURL := fmt.Sprintf("/avatars/%s", url.PathEscape(user.Username)) |
| 140 | + displayName := user.DisplayName | |
| 141 | + if displayName == "" { | |
| 142 | + displayName = user.Username | |
| 143 | + } | |
| 144 | + followState := h.userFollowState(r.Context(), user.ID, viewer) | |
| 97 | 145 | tabs := h.tabCounts(r.Context(), user.ID, viewer) |
| 98 | 146 | data := map[string]any{ |
| 99 | - "Title": "Stars · " + user.DisplayName, | |
| 100 | - "User": user, | |
| 101 | - "IsSelf": isSelf, | |
| 102 | - "AvatarURL": avatarURL, | |
| 103 | - "Stars": visible, | |
| 104 | - "Page": page, | |
| 105 | - "HasNext": len(visible) >= starsTabPageSize, | |
| 106 | - "HasPrev": page > 1, | |
| 107 | - "Tabs": tabs, | |
| 108 | - "ActiveTab": "stars", | |
| 147 | + "Title": "Stars · " + displayName, | |
| 148 | + "User": user, | |
| 149 | + "DisplayName": displayName, | |
| 150 | + "IsSelf": isSelf, | |
| 151 | + "AvatarURL": avatarURL, | |
| 152 | + "JoinedFormatted": user.CreatedAt.Time.Format("January 2, 2006"), | |
| 153 | + "WebsiteSafe": safeWebsite(user.Website), | |
| 154 | + "FollowersCount": followState.FollowersCount, | |
| 155 | + "FollowingCount": followState.FollowingCount, | |
| 156 | + "Orgs": h.profileOrganizations(r.Context(), user.ID), | |
| 157 | + "Stars": paged, | |
| 158 | + "StarTotal": len(visible), | |
| 159 | + "FilteredCount": totalFiltered, | |
| 160 | + "LanguageOptions": languageOptions, | |
| 161 | + "StarFilters": filters, | |
| 162 | + "Page": page, | |
| 163 | + "HasNext": end < totalFiltered, | |
| 164 | + "HasPrev": page > 1, | |
| 165 | + "NextHref": starsPageHref(user.Username, r.URL.Query(), page+1), | |
| 166 | + "PrevHref": starsPageHref(user.Username, r.URL.Query(), page-1), | |
| 167 | + "Tabs": tabs, | |
| 168 | + "ActiveTab": "stars", | |
| 169 | + "StarsSearchLabel": starsSearchLabel(filters), | |
| 109 | 170 | } |
| 110 | 171 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 111 | 172 | if err := h.d.Render.RenderPage(w, r, "profile/stars_tab", data); err != nil { |
@@ -113,6 +174,114 @@ func (h *Handlers) serveStarsTab(w http.ResponseWriter, r *http.Request, user us | ||
| 113 | 174 | } |
| 114 | 175 | } |
| 115 | 176 | |
| 177 | +func normalizeStarType(raw string) string { | |
| 178 | + switch strings.ToLower(strings.TrimSpace(raw)) { | |
| 179 | + case "public", "private": | |
| 180 | + return strings.ToLower(strings.TrimSpace(raw)) | |
| 181 | + default: | |
| 182 | + return "all" | |
| 183 | + } | |
| 184 | +} | |
| 185 | + | |
| 186 | +func normalizeStarSort(raw string) string { | |
| 187 | + switch strings.ToLower(strings.TrimSpace(raw)) { | |
| 188 | + case "recently-active", "stars": | |
| 189 | + return strings.ToLower(strings.TrimSpace(raw)) | |
| 190 | + default: | |
| 191 | + return "recently-starred" | |
| 192 | + } | |
| 193 | +} | |
| 194 | + | |
| 195 | +func starLanguageOptions(items []profileStarItem) []string { | |
| 196 | + seen := map[string]struct{}{} | |
| 197 | + for _, item := range items { | |
| 198 | + if item.PrimaryLanguage != "" { | |
| 199 | + seen[item.PrimaryLanguage] = struct{}{} | |
| 200 | + } | |
| 201 | + } | |
| 202 | + out := make([]string, 0, len(seen)) | |
| 203 | + for language := range seen { | |
| 204 | + out = append(out, language) | |
| 205 | + } | |
| 206 | + sort.Strings(out) | |
| 207 | + return out | |
| 208 | +} | |
| 209 | + | |
| 210 | +func filterProfileStars(items []profileStarItem, filters profileStarFilters) []profileStarItem { | |
| 211 | + query := strings.ToLower(filters.Query) | |
| 212 | + out := make([]profileStarItem, 0, len(items)) | |
| 213 | + for _, item := range items { | |
| 214 | + if filters.Type == "public" && item.IsPrivate { | |
| 215 | + continue | |
| 216 | + } | |
| 217 | + if filters.Type == "private" && !item.IsPrivate { | |
| 218 | + continue | |
| 219 | + } | |
| 220 | + if filters.Language != "" && item.PrimaryLanguage != filters.Language { | |
| 221 | + continue | |
| 222 | + } | |
| 223 | + if query != "" && !strings.Contains(strings.ToLower(item.FullName+" "+item.Description+" "+item.PrimaryLanguage), query) { | |
| 224 | + continue | |
| 225 | + } | |
| 226 | + out = append(out, item) | |
| 227 | + } | |
| 228 | + return out | |
| 229 | +} | |
| 230 | + | |
| 231 | +func sortProfileStars(items []profileStarItem, sortMode string) { | |
| 232 | + sort.SliceStable(items, func(i, j int) bool { | |
| 233 | + switch sortMode { | |
| 234 | + case "recently-active": | |
| 235 | + if !items[i].UpdatedAt.Equal(items[j].UpdatedAt) { | |
| 236 | + return items[i].UpdatedAt.After(items[j].UpdatedAt) | |
| 237 | + } | |
| 238 | + case "stars": | |
| 239 | + if items[i].StarCount != items[j].StarCount { | |
| 240 | + return items[i].StarCount > items[j].StarCount | |
| 241 | + } | |
| 242 | + default: | |
| 243 | + if !items[i].StarredAt.Equal(items[j].StarredAt) { | |
| 244 | + return items[i].StarredAt.After(items[j].StarredAt) | |
| 245 | + } | |
| 246 | + } | |
| 247 | + return items[i].FullName < items[j].FullName | |
| 248 | + }) | |
| 249 | +} | |
| 250 | + | |
| 251 | +func starsPageHref(username string, values url.Values, page int) string { | |
| 252 | + next := url.Values{} | |
| 253 | + for key, vals := range values { | |
| 254 | + if key == "page" { | |
| 255 | + continue | |
| 256 | + } | |
| 257 | + for _, val := range vals { | |
| 258 | + next.Add(key, val) | |
| 259 | + } | |
| 260 | + } | |
| 261 | + next.Set("tab", "stars") | |
| 262 | + if page > 1 { | |
| 263 | + next.Set("page", strconv.Itoa(page)) | |
| 264 | + } | |
| 265 | + return "/" + url.PathEscape(username) + "?" + next.Encode() | |
| 266 | +} | |
| 267 | + | |
| 268 | +func starsSearchLabel(filters profileStarFilters) string { | |
| 269 | + parts := make([]string, 0, 3) | |
| 270 | + if filters.Query != "" { | |
| 271 | + parts = append(parts, "matching "+filters.Query) | |
| 272 | + } | |
| 273 | + if filters.Type != "all" { | |
| 274 | + parts = append(parts, filters.Type) | |
| 275 | + } | |
| 276 | + if filters.Language != "" { | |
| 277 | + parts = append(parts, filters.Language) | |
| 278 | + } | |
| 279 | + if len(parts) == 0 { | |
| 280 | + return "Starred repositories" | |
| 281 | + } | |
| 282 | + return "Starred repositories " + strings.Join(parts, ", ") | |
| 283 | +} | |
| 284 | + | |
| 116 | 285 | // pgTextStringOrEmpty unwraps a nullable text column to "" when null. |
| 117 | 286 | func pgTextStringOrEmpty(t pgtype.Text) string { |
| 118 | 287 | if !t.Valid { |