tenseleyflow/shithub / cedfc23

Browse files

Allow profile pins for affiliated repos

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
cedfc239ed3ec1e02ea6b62e191e7a89ddfd718d
Parents
17f5db4
Tree
9707497

7 changed files

StatusFile+-
M docs/internal/profile.md 9 6
M internal/repos/queries/repos.sql 28 0
M internal/repos/sqlc/querier.go 1 0
M internal/repos/sqlc/repos.sql.go 84 0
M internal/web/handlers/profile/pins.go 6 6
M internal/web/handlers/profile/profile_test.go 62 1
M internal/web/templates/_pins_modal.html 2 2
docs/internal/profile.mdmodified
@@ -112,9 +112,12 @@ When the viewer's session matches the profile's user (`viewer.ID == user.ID`):
112
 
112
 
113
 - A small "you" badge renders next to the display name.
113
 - A small "you" badge renders next to the display name.
114
 - An "Edit profile" button links to `/settings/profile` (S10).
114
 - An "Edit profile" button links to `/settings/profile` (S10).
115
-- A "Customize pins" modal lists the user's public repositories with a
115
+- A "Customize pins" modal lists public repositories affiliated with the
116
-  live client-side filter and persists up to six selected repos through
116
+  user: user-owned repositories, repositories owned by organizations the
117
-  `profile_pin_sets` / `profile_pins` (migration 0040).
117
+  user belongs to, and repositories where the user is an explicit
118
+  collaborator. The modal has a live client-side filter and persists up
119
+  to six selected repos through `profile_pin_sets` / `profile_pins`
120
+  (migration 0040).
118
 - The "Contribution settings" menu toggles the owner's
121
 - The "Contribution settings" menu toggles the owner's
119
   `users.include_private_contributions` preference. The checked state
122
   `users.include_private_contributions` preference. The checked state
120
   mirrors the persisted setting, and the graph is recomputed after the
123
   mirrors the persisted setting, and the graph is recomputed after the
@@ -123,9 +126,9 @@ When the viewer's session matches the profile's user (`viewer.ID == user.ID`):
123
 
126
 
124
 Pinned repositories are intentionally public-only. Private repos are
127
 Pinned repositories are intentionally public-only. Private repos are
125
 not offered in the picker and saved pin IDs are revalidated against the
128
 not offered in the picker and saved pin IDs are revalidated against the
126
-current public owner repo list before write. A `profile_pin_sets` row
129
+current public affiliated repo list before write. A `profile_pin_sets`
127
-records that the owner customized the set, so "zero pins" is distinct
130
+row records that the owner customized the set, so "zero pins" is
128
-from "never customized."
131
+distinct from "never customized."
129
 
132
 
130
 ## OG metadata
133
 ## OG metadata
131
 
134
 
internal/repos/queries/repos.sqlmodified
@@ -134,6 +134,34 @@ FROM repos
134
 WHERE owner_org_id = $1 AND deleted_at IS NULL
134
 WHERE owner_org_id = $1 AND deleted_at IS NULL
135
 ORDER BY updated_at DESC;
135
 ORDER BY updated_at DESC;
136
 
136
 
137
+-- name: ListProfilePinCandidateReposForUser :many
138
+SELECT sqlc.embed(r), COALESCE(owner_user.username, owner_org.slug)::text AS owner_slug
139
+FROM repos r
140
+LEFT JOIN users owner_user ON owner_user.id = r.owner_user_id
141
+LEFT JOIN orgs owner_org ON owner_org.id = r.owner_org_id
142
+WHERE r.deleted_at IS NULL
143
+  AND r.visibility = 'public'
144
+  AND (
145
+    (r.owner_user_id IS NOT NULL AND owner_user.deleted_at IS NULL AND owner_user.suspended_at IS NULL)
146
+    OR (r.owner_org_id IS NOT NULL AND owner_org.deleted_at IS NULL)
147
+  )
148
+  AND (
149
+    r.owner_user_id = $1
150
+    OR EXISTS (
151
+      SELECT 1
152
+      FROM org_members m
153
+      WHERE m.org_id = r.owner_org_id
154
+        AND m.user_id = $1
155
+    )
156
+    OR EXISTS (
157
+      SELECT 1
158
+      FROM repo_collaborators c
159
+      WHERE c.repo_id = r.id
160
+        AND c.user_id = $1
161
+    )
162
+  )
163
+ORDER BY lower(COALESCE(owner_user.username::text, owner_org.slug::text, '')), lower(r.name::text), r.id;
164
+
137
 -- name: ListPublicContributionRepos :many
165
 -- name: ListPublicContributionRepos :many
138
 SELECT sqlc.embed(r), COALESCE(u.username, o.slug)::text AS owner_slug
166
 SELECT sqlc.embed(r), COALESCE(u.username, o.slug)::text AS owner_slug
139
 FROM repos r
167
 FROM repos r
internal/repos/sqlc/querier.gomodified
@@ -94,6 +94,7 @@ type Querier interface {
94
 	ListForksOfRepoForRepack(ctx context.Context, db DBTX, forkOfRepoID pgtype.Int8) ([]ListForksOfRepoForRepackRow, error)
94
 	ListForksOfRepoForRepack(ctx context.Context, db DBTX, forkOfRepoID pgtype.Int8) ([]ListForksOfRepoForRepackRow, error)
95
 	// Inbox view: pending offers a user can act on.
95
 	// Inbox view: pending offers a user can act on.
96
 	ListPendingTransfersForUser(ctx context.Context, db DBTX, toPrincipalID int64) ([]RepoTransferRequest, error)
96
 	ListPendingTransfersForUser(ctx context.Context, db DBTX, toPrincipalID int64) ([]RepoTransferRequest, error)
97
+	ListProfilePinCandidateReposForUser(ctx context.Context, db DBTX, ownerUserID pgtype.Int8) ([]ListProfilePinCandidateReposForUserRow, error)
97
 	ListProfilePinsForSet(ctx context.Context, db DBTX, setID int64) ([]ListProfilePinsForSetRow, error)
98
 	ListProfilePinsForSet(ctx context.Context, db DBTX, setID int64) ([]ListProfilePinsForSetRow, error)
98
 	ListPublicContributionRepos(ctx context.Context, db DBTX, limit int32) ([]ListPublicContributionReposRow, error)
99
 	ListPublicContributionRepos(ctx context.Context, db DBTX, limit int32) ([]ListPublicContributionReposRow, error)
99
 	// ─── soft-delete sweep query ───────────────────────────────────────────
100
 	// ─── soft-delete sweep query ───────────────────────────────────────────
internal/repos/sqlc/repos.sql.gomodified
@@ -732,6 +732,90 @@ func (q *Queries) ListForksOfRepoForRepack(ctx context.Context, db DBTX, forkOfR
732
 	return items, nil
732
 	return items, nil
733
 }
733
 }
734
 
734
 
735
+const listProfilePinCandidateReposForUser = `-- name: ListProfilePinCandidateReposForUser :many
736
+SELECT r.id, r.owner_user_id, r.owner_org_id, r.name, r.description, r.visibility, r.default_branch, r.is_archived, r.archived_at, r.deleted_at, r.disk_used_bytes, r.fork_of_repo_id, r.license_key, r.primary_language, r.has_issues, r.has_pulls, r.created_at, r.updated_at, r.default_branch_oid, r.allow_squash_merge, r.allow_rebase_merge, r.allow_merge_commit, r.default_merge_method, r.star_count, r.watcher_count, r.fork_count, r.init_status, r.last_indexed_oid, COALESCE(owner_user.username, owner_org.slug)::text AS owner_slug
737
+FROM repos r
738
+LEFT JOIN users owner_user ON owner_user.id = r.owner_user_id
739
+LEFT JOIN orgs owner_org ON owner_org.id = r.owner_org_id
740
+WHERE r.deleted_at IS NULL
741
+  AND r.visibility = 'public'
742
+  AND (
743
+    (r.owner_user_id IS NOT NULL AND owner_user.deleted_at IS NULL AND owner_user.suspended_at IS NULL)
744
+    OR (r.owner_org_id IS NOT NULL AND owner_org.deleted_at IS NULL)
745
+  )
746
+  AND (
747
+    r.owner_user_id = $1
748
+    OR EXISTS (
749
+      SELECT 1
750
+      FROM org_members m
751
+      WHERE m.org_id = r.owner_org_id
752
+        AND m.user_id = $1
753
+    )
754
+    OR EXISTS (
755
+      SELECT 1
756
+      FROM repo_collaborators c
757
+      WHERE c.repo_id = r.id
758
+        AND c.user_id = $1
759
+    )
760
+  )
761
+ORDER BY lower(COALESCE(owner_user.username::text, owner_org.slug::text, '')), lower(r.name::text), r.id
762
+`
763
+
764
+type ListProfilePinCandidateReposForUserRow struct {
765
+	Repo      Repo
766
+	OwnerSlug string
767
+}
768
+
769
+func (q *Queries) ListProfilePinCandidateReposForUser(ctx context.Context, db DBTX, ownerUserID pgtype.Int8) ([]ListProfilePinCandidateReposForUserRow, error) {
770
+	rows, err := db.Query(ctx, listProfilePinCandidateReposForUser, ownerUserID)
771
+	if err != nil {
772
+		return nil, err
773
+	}
774
+	defer rows.Close()
775
+	items := []ListProfilePinCandidateReposForUserRow{}
776
+	for rows.Next() {
777
+		var i ListProfilePinCandidateReposForUserRow
778
+		if err := rows.Scan(
779
+			&i.Repo.ID,
780
+			&i.Repo.OwnerUserID,
781
+			&i.Repo.OwnerOrgID,
782
+			&i.Repo.Name,
783
+			&i.Repo.Description,
784
+			&i.Repo.Visibility,
785
+			&i.Repo.DefaultBranch,
786
+			&i.Repo.IsArchived,
787
+			&i.Repo.ArchivedAt,
788
+			&i.Repo.DeletedAt,
789
+			&i.Repo.DiskUsedBytes,
790
+			&i.Repo.ForkOfRepoID,
791
+			&i.Repo.LicenseKey,
792
+			&i.Repo.PrimaryLanguage,
793
+			&i.Repo.HasIssues,
794
+			&i.Repo.HasPulls,
795
+			&i.Repo.CreatedAt,
796
+			&i.Repo.UpdatedAt,
797
+			&i.Repo.DefaultBranchOid,
798
+			&i.Repo.AllowSquashMerge,
799
+			&i.Repo.AllowRebaseMerge,
800
+			&i.Repo.AllowMergeCommit,
801
+			&i.Repo.DefaultMergeMethod,
802
+			&i.Repo.StarCount,
803
+			&i.Repo.WatcherCount,
804
+			&i.Repo.ForkCount,
805
+			&i.Repo.InitStatus,
806
+			&i.Repo.LastIndexedOid,
807
+			&i.OwnerSlug,
808
+		); err != nil {
809
+			return nil, err
810
+		}
811
+		items = append(items, i)
812
+	}
813
+	if err := rows.Err(); err != nil {
814
+		return nil, err
815
+	}
816
+	return items, nil
817
+}
818
+
735
 const listProfilePinsForSet = `-- name: ListProfilePinsForSet :many
819
 const listProfilePinsForSet = `-- name: ListProfilePinsForSet :many
736
 SELECT repo_id, position
820
 SELECT repo_id, position
737
 FROM profile_pins
821
 FROM profile_pins
internal/web/handlers/profile/pins.gomodified
@@ -125,7 +125,7 @@ func (h *Handlers) updateUserPins(w http.ResponseWriter, r *http.Request, rawNam
125
 		return
125
 		return
126
 	}
126
 	}
127
 
127
 
128
-	candidates := h.publicUserPinCandidates(ctx, user.ID, user.Username)
128
+	candidates := h.publicUserPinCandidates(ctx, user.ID)
129
 	repoIDs, err := selectedProfilePinIDs(r.PostForm["repo_id"], candidates)
129
 	repoIDs, err := selectedProfilePinIDs(r.PostForm["repo_id"], candidates)
130
 	if err != nil {
130
 	if err != nil {
131
 		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
131
 		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
@@ -156,7 +156,7 @@ func (h *Handlers) orgPinData(ctx context.Context, orgID int64, orgSlug string,
156
 }
156
 }
157
 
157
 
158
 func (h *Handlers) userPinData(ctx context.Context, user usersdb.User) ([]profilePinCandidate, []profilePinCandidate) {
158
 func (h *Handlers) userPinData(ctx context.Context, user usersdb.User) ([]profilePinCandidate, []profilePinCandidate) {
159
-	candidates := h.publicUserPinCandidates(ctx, user.ID, user.Username)
159
+	candidates := h.publicUserPinCandidates(ctx, user.ID)
160
 	var selectedIDs []int64
160
 	var selectedIDs []int64
161
 
161
 
162
 	setID, explicit, err := h.lookupUserPinSet(ctx, user.ID)
162
 	setID, explicit, err := h.lookupUserPinSet(ctx, user.ID)
@@ -210,18 +210,18 @@ func (h *Handlers) savedOrgPins(ctx context.Context, setID int64, repos []orgPro
210
 	return selectedOrgProfileRepos(repos, h.savedPinIDs(ctx, setID))
210
 	return selectedOrgProfileRepos(repos, h.savedPinIDs(ctx, setID))
211
 }
211
 }
212
 
212
 
213
-func (h *Handlers) publicUserPinCandidates(ctx context.Context, userID int64, ownerSlug string) []profilePinCandidate {
213
+func (h *Handlers) publicUserPinCandidates(ctx context.Context, userID int64) []profilePinCandidate {
214
-	rows, err := reposdb.New().ListReposForOwnerUser(ctx, h.d.Pool, pgtype.Int8{Int64: userID, Valid: true})
214
+	rows, err := reposdb.New().ListProfilePinCandidateReposForUser(ctx, h.d.Pool, pgtype.Int8{Int64: userID, Valid: true})
215
 	if err != nil {
215
 	if err != nil {
216
 		h.d.Logger.WarnContext(ctx, "profile pins: list user repos", "user_id", userID, "error", err)
216
 		h.d.Logger.WarnContext(ctx, "profile pins: list user repos", "user_id", userID, "error", err)
217
 		return nil
217
 		return nil
218
 	}
218
 	}
219
 	out := make([]profilePinCandidate, 0, len(rows))
219
 	out := make([]profilePinCandidate, 0, len(rows))
220
 	for _, row := range rows {
220
 	for _, row := range rows {
221
-		if !policy.NewRepoRefFromRepo(row).IsPublic() {
221
+		if !policy.NewRepoRefFromRepo(row.Repo).IsPublic() {
222
 			continue
222
 			continue
223
 		}
223
 		}
224
-		out = append(out, profilePinCandidateFromRepo(ownerSlug, row))
224
+		out = append(out, profilePinCandidateFromRepo(row.OwnerSlug, row.Repo))
225
 	}
225
 	}
226
 	sortPinCandidates(out)
226
 	sortPinCandidates(out)
227
 	return out
227
 	return out
internal/web/handlers/profile/profile_test.gomodified
@@ -61,7 +61,7 @@ func setupProfileEnvWithDeps(t *testing.T, objectStore storage.ObjectStore, repo
61
 	tmplFS := fstest.MapFS{
61
 	tmplFS := fstest.MapFS{
62
 		"_layout.html":             {Data: []byte(`{{ define "layout" }}<html><head><title>{{ .Title }}</title></head><body>{{ template "page" . }}</body></html>{{ end }}`)},
62
 		"_layout.html":             {Data: []byte(`{{ define "layout" }}<html><head><title>{{ .Title }}</title></head><body>{{ template "page" . }}</body></html>{{ end }}`)},
63
 		"hello.html":               {Data: []byte(`{{ define "page" }}home{{ end }}`)},
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}} 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}} 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
 		"profile/follows_tab.html": {Data: []byte(`{{ define "page" }}FOLLOWTAB={{.ActiveTab}} USER={{.User.Username}} TOTAL={{len .Items}} ITEMS={{range .Items}}{{.Kind}}:{{.Username}};{{end}}{{ end }}`)},
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/suspended.html":   {Data: []byte(`{{ define "page" }}SUSPENDED={{.Username}}{{ end }}`)},
66
 		"profile/suspended.html":   {Data: []byte(`{{ define "page" }}SUSPENDED={{.Username}}{{ end }}`)},
67
 		"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 }}`)},
67
 		"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 }}`)},
@@ -210,6 +210,15 @@ func (e *profileEnv) insertUserRepo(t *testing.T, userID int64, name, desc, visi
210
 	return repoID
210
 	return repoID
211
 }
211
 }
212
 
212
 
213
+func (e *profileEnv) insertRepoCollaborator(t *testing.T, repoID, userID int64, role string) {
214
+	t.Helper()
215
+	if _, err := e.pool.Exec(context.Background(),
216
+		`INSERT INTO repo_collaborators (repo_id, user_id, role) VALUES ($1, $2, $3)`,
217
+		repoID, userID, role); err != nil {
218
+		t.Fatalf("insert repo collaborator: %v", err)
219
+	}
220
+}
221
+
213
 func (e *profileEnv) writeInitialCommit(t *testing.T, owner, repoName, authorName, authorEmail string, when time.Time) string {
222
 func (e *profileEnv) writeInitialCommit(t *testing.T, owner, repoName, authorName, authorEmail string, when time.Time) string {
214
 	t.Helper()
223
 	t.Helper()
215
 	if e.repoFS == nil {
224
 	if e.repoFS == nil {
@@ -787,6 +796,58 @@ func TestProfile_UserPinsCanBeCustomized(t *testing.T) {
787
 	}
796
 	}
788
 }
797
 }
789
 
798
 
799
+func TestProfile_UserPinsIncludeAffiliatedOrgAndCollaboratorRepos(t *testing.T) {
800
+	t.Parallel()
801
+	env := setupProfileEnv(t)
802
+	alice := env.insertUser(t, "alice", "Alice", "")
803
+	bob := env.insertUser(t, "bob", "Bob", "")
804
+	env.insertUserRepo(t, alice.ID, "owned", "user-owned work", "public", "Go", 0, 0)
805
+	orgID := env.insertOrg(t, "tenseleyflow", "tenseleyFlow", "workflows", alice)
806
+	orgToolID := env.insertOrgRepo(t, orgID, "org-tool", "org-owned work", "public", "Go", 2, 0)
807
+	env.insertOrgRepo(t, orgID, "secret-org-tool", "hidden org work", "private", "Rust", 0, 0)
808
+	otherOrgID := env.insertOrg(t, "strangers", "Strangers", "", bob)
809
+	strangerRepoID := env.insertOrgRepo(t, otherOrgID, "stranger-tool", "unaffiliated public repo", "public", "Go", 0, 0)
810
+	collabID := env.insertUserRepo(t, bob.ID, "collab-tool", "collaborator work", "public", "Python", 1, 0)
811
+	env.insertRepoCollaborator(t, collabID, alice.ID, "write")
812
+
813
+	got := env.getAs(t, "/alice", alice)
814
+	for _, want := range []string{
815
+		"CANDIDATES=3",
816
+		"alice/owned;",
817
+		"bob/collab-tool;",
818
+		"tenseleyflow/org-tool;",
819
+	} {
820
+		if !strings.Contains(got, want) {
821
+			t.Fatalf("missing %q in body: %s", want, got)
822
+		}
823
+	}
824
+	for _, notWant := range []string{"secret-org-tool", "strangers/stranger-tool"} {
825
+		if strings.Contains(got, notWant) {
826
+			t.Fatalf("unavailable repo %q was offered as a pin candidate: %s", notWant, got)
827
+		}
828
+	}
829
+
830
+	resp := env.postPins(t, "/alice/pins", alice, orgToolID, collabID)
831
+	if resp.StatusCode != http.StatusSeeOther {
832
+		t.Fatalf("status %d, want 303", resp.StatusCode)
833
+	}
834
+	if loc := resp.Header.Get("Location"); loc != "/alice#pinned" {
835
+		t.Fatalf("Location = %q", loc)
836
+	}
837
+
838
+	got = env.getAs(t, "/alice", alice)
839
+	for _, want := range []string{"PINS=2", "PINNAMES=org-tool;collab-tool;", "SELECTED=org-tool;collab-tool;"} {
840
+		if !strings.Contains(got, want) {
841
+			t.Fatalf("missing %q in body: %s", want, got)
842
+		}
843
+	}
844
+
845
+	resp = env.postPins(t, "/alice/pins", alice, strangerRepoID)
846
+	if resp.StatusCode != http.StatusBadRequest {
847
+		t.Fatalf("unaffiliated repo status %d, want 400", resp.StatusCode)
848
+	}
849
+}
850
+
790
 func TestProfile_OrgPinsFallbackUntilCustomized(t *testing.T) {
851
 func TestProfile_OrgPinsFallbackUntilCustomized(t *testing.T) {
791
 	t.Parallel()
852
 	t.Parallel()
792
 	env := setupProfileEnv(t)
853
 	env := setupProfileEnv(t)
internal/web/templates/_pins_modal.htmlmodified
@@ -17,11 +17,11 @@
17
       <div class="shithub-pins-list" data-pins-list>
17
       <div class="shithub-pins-list" data-pins-list>
18
         {{ if .PinCandidates }}
18
         {{ if .PinCandidates }}
19
           {{ range .PinCandidates }}
19
           {{ range .PinCandidates }}
20
-          <label class="shithub-pins-row" data-pins-row data-pins-search="{{ .Name }} {{ .Description }} {{ .PrimaryLanguage }}">
20
+          <label class="shithub-pins-row" data-pins-row data-pins-search="{{ .OwnerSlug }} {{ .Name }} {{ .Description }} {{ .PrimaryLanguage }}">
21
             <input type="checkbox" name="repo_id" value="{{ .ID }}" data-pins-checkbox{{ if .IsPinned }} checked{{ end }}>
21
             <input type="checkbox" name="repo_id" value="{{ .ID }}" data-pins-checkbox{{ if .IsPinned }} checked{{ end }}>
22
             <span class="shithub-pins-row-icon">{{ octicon "repo" }}</span>
22
             <span class="shithub-pins-row-icon">{{ octicon "repo" }}</span>
23
             <span class="shithub-pins-row-main">
23
             <span class="shithub-pins-row-main">
24
-              <span class="shithub-pins-row-name">{{ .Name }}</span>
24
+              <span class="shithub-pins-row-name">{{ .OwnerSlug }}/{{ .Name }}</span>
25
               {{ if .Description }}<span class="shithub-pins-row-desc">{{ .Description }}</span>{{ end }}
25
               {{ if .Description }}<span class="shithub-pins-row-desc">{{ .Description }}</span>{{ end }}
26
             </span>
26
             </span>
27
             <span class="shithub-pins-row-stars">{{ .StarCount }} {{ octicon "star" }}</span>
27
             <span class="shithub-pins-row-stars">{{ .StarCount }} {{ octicon "star" }}</span>