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`):
112112
 
113113
 - A small "you" badge renders next to the display name.
114114
 - An "Edit profile" button links to `/settings/profile` (S10).
115
-- A "Customize pins" modal lists the user's public repositories with a
116
-  live client-side filter and persists up to six selected repos through
117
-  `profile_pin_sets` / `profile_pins` (migration 0040).
115
+- A "Customize pins" modal lists public repositories affiliated with the
116
+  user: user-owned repositories, repositories owned by organizations the
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).
118121
 - The "Contribution settings" menu toggles the owner's
119122
   `users.include_private_contributions` preference. The checked state
120123
   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`):
123126
 
124127
 Pinned repositories are intentionally public-only. Private repos are
125128
 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
127
-records that the owner customized the set, so "zero pins" is distinct
128
-from "never customized."
129
+current public affiliated repo list before write. A `profile_pin_sets`
130
+row records that the owner customized the set, so "zero pins" is
131
+distinct from "never customized."
129132
 
130133
 ## OG metadata
131134
 
internal/repos/queries/repos.sqlmodified
@@ -134,6 +134,34 @@ FROM repos
134134
 WHERE owner_org_id = $1 AND deleted_at IS NULL
135135
 ORDER BY updated_at DESC;
136136
 
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
+
137165
 -- name: ListPublicContributionRepos :many
138166
 SELECT sqlc.embed(r), COALESCE(u.username, o.slug)::text AS owner_slug
139167
 FROM repos r
internal/repos/sqlc/querier.gomodified
@@ -94,6 +94,7 @@ type Querier interface {
9494
 	ListForksOfRepoForRepack(ctx context.Context, db DBTX, forkOfRepoID pgtype.Int8) ([]ListForksOfRepoForRepackRow, error)
9595
 	// Inbox view: pending offers a user can act on.
9696
 	ListPendingTransfersForUser(ctx context.Context, db DBTX, toPrincipalID int64) ([]RepoTransferRequest, error)
97
+	ListProfilePinCandidateReposForUser(ctx context.Context, db DBTX, ownerUserID pgtype.Int8) ([]ListProfilePinCandidateReposForUserRow, error)
9798
 	ListProfilePinsForSet(ctx context.Context, db DBTX, setID int64) ([]ListProfilePinsForSetRow, error)
9899
 	ListPublicContributionRepos(ctx context.Context, db DBTX, limit int32) ([]ListPublicContributionReposRow, error)
99100
 	// ─── soft-delete sweep query ───────────────────────────────────────────
internal/repos/sqlc/repos.sql.gomodified
@@ -732,6 +732,90 @@ func (q *Queries) ListForksOfRepoForRepack(ctx context.Context, db DBTX, forkOfR
732732
 	return items, nil
733733
 }
734734
 
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
+
735819
 const listProfilePinsForSet = `-- name: ListProfilePinsForSet :many
736820
 SELECT repo_id, position
737821
 FROM profile_pins
internal/web/handlers/profile/pins.gomodified
@@ -125,7 +125,7 @@ func (h *Handlers) updateUserPins(w http.ResponseWriter, r *http.Request, rawNam
125125
 		return
126126
 	}
127127
 
128
-	candidates := h.publicUserPinCandidates(ctx, user.ID, user.Username)
128
+	candidates := h.publicUserPinCandidates(ctx, user.ID)
129129
 	repoIDs, err := selectedProfilePinIDs(r.PostForm["repo_id"], candidates)
130130
 	if err != nil {
131131
 		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
@@ -156,7 +156,7 @@ func (h *Handlers) orgPinData(ctx context.Context, orgID int64, orgSlug string,
156156
 }
157157
 
158158
 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)
160160
 	var selectedIDs []int64
161161
 
162162
 	setID, explicit, err := h.lookupUserPinSet(ctx, user.ID)
@@ -210,18 +210,18 @@ func (h *Handlers) savedOrgPins(ctx context.Context, setID int64, repos []orgPro
210210
 	return selectedOrgProfileRepos(repos, h.savedPinIDs(ctx, setID))
211211
 }
212212
 
213
-func (h *Handlers) publicUserPinCandidates(ctx context.Context, userID int64, ownerSlug string) []profilePinCandidate {
214
-	rows, err := reposdb.New().ListReposForOwnerUser(ctx, h.d.Pool, pgtype.Int8{Int64: userID, Valid: true})
213
+func (h *Handlers) publicUserPinCandidates(ctx context.Context, userID int64) []profilePinCandidate {
214
+	rows, err := reposdb.New().ListProfilePinCandidateReposForUser(ctx, h.d.Pool, pgtype.Int8{Int64: userID, Valid: true})
215215
 	if err != nil {
216216
 		h.d.Logger.WarnContext(ctx, "profile pins: list user repos", "user_id", userID, "error", err)
217217
 		return nil
218218
 	}
219219
 	out := make([]profilePinCandidate, 0, len(rows))
220220
 	for _, row := range rows {
221
-		if !policy.NewRepoRefFromRepo(row).IsPublic() {
221
+		if !policy.NewRepoRefFromRepo(row.Repo).IsPublic() {
222222
 			continue
223223
 		}
224
-		out = append(out, profilePinCandidateFromRepo(ownerSlug, row))
224
+		out = append(out, profilePinCandidateFromRepo(row.OwnerSlug, row.Repo))
225225
 	}
226226
 	sortPinCandidates(out)
227227
 	return out
internal/web/handlers/profile/profile_test.gomodified
@@ -61,7 +61,7 @@ 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}} 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 }}`)},
6565
 		"profile/follows_tab.html": {Data: []byte(`{{ define "page" }}FOLLOWTAB={{.ActiveTab}} USER={{.User.Username}} TOTAL={{len .Items}} ITEMS={{range .Items}}{{.Kind}}:{{.Username}};{{end}}{{ end }}`)},
6666
 		"profile/suspended.html":   {Data: []byte(`{{ define "page" }}SUSPENDED={{.Username}}{{ end }}`)},
6767
 		"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
210210
 	return repoID
211211
 }
212212
 
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
+
213222
 func (e *profileEnv) writeInitialCommit(t *testing.T, owner, repoName, authorName, authorEmail string, when time.Time) string {
214223
 	t.Helper()
215224
 	if e.repoFS == nil {
@@ -787,6 +796,58 @@ func TestProfile_UserPinsCanBeCustomized(t *testing.T) {
787796
 	}
788797
 }
789798
 
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
+
790851
 func TestProfile_OrgPinsFallbackUntilCustomized(t *testing.T) {
791852
 	t.Parallel()
792853
 	env := setupProfileEnv(t)
internal/web/templates/_pins_modal.htmlmodified
@@ -17,11 +17,11 @@
1717
       <div class="shithub-pins-list" data-pins-list>
1818
         {{ if .PinCandidates }}
1919
           {{ 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 }}">
2121
             <input type="checkbox" name="repo_id" value="{{ .ID }}" data-pins-checkbox{{ if .IsPinned }} checked{{ end }}>
2222
             <span class="shithub-pins-row-icon">{{ octicon "repo" }}</span>
2323
             <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>
2525
               {{ if .Description }}<span class="shithub-pins-row-desc">{{ .Description }}</span>{{ end }}
2626
             </span>
2727
             <span class="shithub-pins-row-stars">{{ .StarCount }} {{ octicon "star" }}</span>