tenseleyflow/shithub / 142e85f

Browse files

Add profile activity and stars data

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
142e85f42dc9ca77ee8893b54f0b1b89b1a2a2f3
Parents
5381fe8
Tree
ed1824e

8 changed files

StatusFile+-
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
206206
 WHERE issue_id = $1
207207
 ORDER BY created_at ASC;
208208
 
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
+
209230
 -- name: InsertIssueReference :exec
210231
 INSERT INTO issue_references (
211232
     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) ([]
765765
 	return items, nil
766766
 }
767767
 
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
+
768854
 const milestoneIssueCounts = `-- name: MilestoneIssueCounts :one
769855
 SELECT
770856
     count(*) FILTER (WHERE state = 'open')::int   AS open_count,
internal/issues/sqlc/querier.gomodified
@@ -57,6 +57,10 @@ type Querier interface {
5757
 	ListLabels(ctx context.Context, db DBTX, repoID int64) ([]Label, error)
5858
 	ListLabelsOnIssue(ctx context.Context, db DBTX, issueID int64) ([]Label, error)
5959
 	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)
6064
 	// Open + closed counts for the milestone progress bar.
6165
 	MilestoneIssueCounts(ctx context.Context, db DBTX, milestoneID pgtype.Int8) (MilestoneIssueCountsRow, error)
6266
 	RemoveIssueLabel(ctx context.Context, db DBTX, arg RemoveIssueLabelParams) error
internal/social/queries/stars.sqlmodified
@@ -41,9 +41,12 @@ WHERE s.repo_id = $1
4141
 SELECT s.repo_id, s.starred_at,
4242
        r.name AS repo_name, r.description, r.visibility,
4343
        r.star_count, r.primary_language, r.updated_at,
44
-       r.owner_user_id, r.owner_org_id
44
+       r.owner_user_id, r.owner_org_id,
45
+       COALESCE(u.username, o.slug)::text AS owner_slug
4546
 FROM stars s
4647
 JOIN repos r ON r.id = s.repo_id
48
+LEFT JOIN users u ON u.id = r.owner_user_id
49
+LEFT JOIN orgs o ON o.id = r.owner_org_id
4750
 WHERE s.user_id = $1
4851
   AND r.deleted_at IS NULL
4952
 ORDER BY s.starred_at DESC
internal/social/sqlc/stars.sql.gomodified
@@ -146,9 +146,12 @@ const listStarsForUser = `-- name: ListStarsForUser :many
146146
 SELECT s.repo_id, s.starred_at,
147147
        r.name AS repo_name, r.description, r.visibility,
148148
        r.star_count, r.primary_language, r.updated_at,
149
-       r.owner_user_id, r.owner_org_id
149
+       r.owner_user_id, r.owner_org_id,
150
+       COALESCE(u.username, o.slug)::text AS owner_slug
150151
 FROM stars s
151152
 JOIN repos r ON r.id = s.repo_id
153
+LEFT JOIN users u ON u.id = r.owner_user_id
154
+LEFT JOIN orgs o ON o.id = r.owner_org_id
152155
 WHERE s.user_id = $1
153156
   AND r.deleted_at IS NULL
154157
 ORDER BY s.starred_at DESC
@@ -172,6 +175,7 @@ type ListStarsForUserRow struct {
172175
 	UpdatedAt       pgtype.Timestamptz
173176
 	OwnerUserID     pgtype.Int8
174177
 	OwnerOrgID      pgtype.Int8
178
+	OwnerSlug       string
175179
 }
176180
 
177181
 // The "Stars" profile tab. The handler layer post-filters for repo
@@ -198,6 +202,7 @@ func (q *Queries) ListStarsForUser(ctx context.Context, db DBTX, arg ListStarsFo
198202
 			&i.UpdatedAt,
199203
 			&i.OwnerUserID,
200204
 			&i.OwnerOrgID,
205
+			&i.OwnerSlug,
201206
 		); err != nil {
202207
 			return nil, err
203208
 		}
internal/web/handlers/profile/overview.gomodified
@@ -10,6 +10,7 @@ import (
1010
 	"io"
1111
 	"net/url"
1212
 	"path"
13
+	"sort"
1314
 	"strconv"
1415
 	"strings"
1516
 	"time"
@@ -19,6 +20,7 @@ import (
1920
 	"golang.org/x/net/html"
2021
 
2122
 	"github.com/tenseleyFlow/shithub/internal/auth/policy"
23
+	issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
2224
 	mdrender "github.com/tenseleyFlow/shithub/internal/markdown"
2325
 	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
2426
 	repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
@@ -31,6 +33,8 @@ const (
3133
 	profileContribRepoLimit  = 500
3234
 	profileContribMaxPerRepo = 5000
3335
 	profileReadmeMaxBytes    = 1 * 1024 * 1024
36
+	profileActivityMonths    = 1
37
+	profileActivityIssueRows = 1000
3438
 )
3539
 
3640
 type profileOrgBadge struct {
@@ -59,6 +63,8 @@ type contributionCalendar struct {
5963
 	MonthRepoCount              int
6064
 	HasRepositoryData           bool
6165
 	IncludePrivateContributions bool
66
+	Activity                    []profileActivityMonth
67
+	HasMoreActivity             bool
6268
 }
6369
 
6470
 type contributionYear struct {
@@ -81,6 +87,37 @@ type contributionDay struct {
8187
 	IsInWindow bool
8288
 }
8389
 
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
+
84121
 type profileContributionRepo struct {
85122
 	Repo                  reposdb.Repo
86123
 	OwnerSlug             string
@@ -214,6 +251,7 @@ func (h *Handlers) contributionCalendar(ctx context.Context, user usersdb.User,
214251
 	windowEnd := windowEndDay.Add(24 * time.Hour)
215252
 	activityMonth := time.Date(windowEndDay.Year(), windowEndDay.Month(), 1, 0, 0, 0, 0, time.UTC)
216253
 	repos := h.profileContributionRepos(ctx, user, viewer, user.IncludePrivateContributions)
254
+	activity := newProfileActivityBuilder()
217255
 
218256
 	counts := map[string]int{}
219257
 	reposWithMonthActivity := map[int64]struct{}{}
@@ -246,12 +284,24 @@ func (h *Handlers) contributionCalendar(ctx context.Context, user usersdb.User,
246284
 				}
247285
 				key := day.Format("2006-01-02")
248286
 				counts[key]++
287
+				activity.addCommit(day, source)
249288
 				if day.Year() == activityMonth.Year() && day.Month() == activityMonth.Month() {
250289
 					reposWithMonthActivity[source.Repo.ID] = struct{}{}
251290
 				}
252291
 			}
253292
 		}
254293
 	}
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)
255305
 
256306
 	weeks := make([]contributionWeek, 0, weekCount)
257307
 	total := 0
@@ -296,6 +346,284 @@ func (h *Handlers) contributionCalendar(ctx context.Context, user usersdb.User,
296346
 		MonthRepoCount:              len(reposWithMonthActivity),
297347
 		HasRepositoryData:           h.d.RepoFS != nil && len(repos) > 0,
298348
 		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)
299627
 	}
300628
 }
301629
 
internal/web/handlers/profile/profile_test.gomodified
@@ -61,8 +61,9 @@ func setupProfileEnvWithDeps(t *testing.T, objectStore storage.ObjectStore, repo
6161
 	tmplFS := fstest.MapFS{
6262
 		"_layout.html":             {Data: []byte(`{{ define "layout" }}<html><head><title>{{ .Title }}</title></head><body>{{ template "page" . }}</body></html>{{ end }}`)},
6363
 		"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 }}`)},
6565
 		"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 }}`)},
6667
 		"profile/suspended.html":   {Data: []byte(`{{ define "page" }}SUSPENDED={{.Username}}{{ end }}`)},
6768
 		"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 }}`)},
6869
 		"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,
219220
 	}
220221
 }
221222
 
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
+
222259
 func (e *profileEnv) writeInitialCommit(t *testing.T, owner, repoName, authorName, authorEmail string, when time.Time) string {
223260
 	t.Helper()
224261
 	if e.repoFS == nil {
@@ -574,6 +611,40 @@ func TestProfile_ContributionsSelectedYearHasStableLinks(t *testing.T) {
574611
 	}
575612
 }
576613
 
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
+
577648
 func TestProfile_PrivateContributionsRequireOwnerOptIn(t *testing.T) {
578649
 	t.Parallel()
579650
 	env := setupProfileEnvWithRepoFS(t)
@@ -871,6 +942,42 @@ func TestProfile_UserPinsIncludeAffiliatedOrgAndCollaboratorRepos(t *testing.T)
871942
 	}
872943
 }
873944
 
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
+
874981
 func TestProfile_OrgPinsFallbackUntilCustomized(t *testing.T) {
875982
 	t.Parallel()
876983
 	env := setupProfileEnv(t)
internal/web/handlers/profile/stars_tab.gomodified
@@ -4,9 +4,13 @@ package profile
44
 
55
 import (
66
 	"fmt"
7
+	"html/template"
78
 	"net/http"
89
 	"net/url"
10
+	"sort"
911
 	"strconv"
12
+	"strings"
13
+	"time"
1014
 
1115
 	"github.com/jackc/pgx/v5/pgtype"
1216
 
@@ -16,7 +20,32 @@ import (
1620
 	"github.com/tenseleyFlow/shithub/internal/web/middleware"
1721
 )
1822
 
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
+}
2049
 
2150
 // serveStarsTab renders the `/{user}?tab=stars` view.
2251
 //
@@ -29,16 +58,17 @@ func (h *Handlers) serveStarsTab(w http.ResponseWriter, r *http.Request, user us
2958
 	if page < 1 {
3059
 		page = 1
3160
 	}
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
+	}
3267
 
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
3868
 	rows, err := socialdb.New().ListStarsForUser(r.Context(), h.d.Pool, socialdb.ListStarsForUserParams{
3969
 		UserID: user.ID,
40
-		Limit:  fetch,
41
-		Offset: int32((page - 1) * starsTabPageSize),
70
+		Limit:  starsTabScanLimit,
71
+		Offset: 0,
4272
 	})
4373
 	if err != nil {
4474
 		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
5282
 	}
5383
 	deps := policy.Deps{Pool: h.d.Pool}
5484
 
55
-	visible := make([]map[string]any, 0, len(rows))
85
+	visible := make([]profileStarItem, 0, len(rows))
5686
 	for _, row := range rows {
57
-		if len(visible) >= starsTabPageSize {
58
-			break
59
-		}
6087
 		// Re-check visibility per row. Star is on a repo that may have
6188
 		// flipped visibility between star-time and view-time.
6289
 		ref := policy.RepoRef{
@@ -68,25 +95,41 @@ func (h *Handlers) serveStarsTab(w http.ResponseWriter, r *http.Request, user us
6895
 		if !policy.IsVisibleTo(r.Context(), deps, actor, ref) {
6996
 			continue
7097
 		}
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
78101
 		}
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,
87116
 		})
88117
 	}
89118
 
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
+
90133
 	if isSelf {
91134
 		w.Header().Set("Cache-Control", "no-cache, private")
92135
 	} else {
@@ -94,18 +137,36 @@ func (h *Handlers) serveStarsTab(w http.ResponseWriter, r *http.Request, user us
94137
 	}
95138
 
96139
 	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)
97145
 	tabs := h.tabCounts(r.Context(), user.ID, viewer)
98146
 	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),
109170
 	}
110171
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
111172
 	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
113174
 	}
114175
 }
115176
 
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
+
116285
 // pgTextStringOrEmpty unwraps a nullable text column to "" when null.
117286
 func pgTextStringOrEmpty(t pgtype.Text) string {
118287
 	if !t.Valid {