tenseleyflow/shithub / 5d5cf64

Browse files

feat(repo): GitHub-style subnav (Code/Issues/Pulls/Forks/Settings) + chroma dark-mode CSS

Authored by espadonne
SHA
5d5cf6428f52df9e784d8c5d6db0244c4b0f002e
Parents
e9c5337
Tree
d725e76

18 changed files

StatusFile+-
A internal/auth/policy/org_owner_test.go 92 0
M internal/auth/policy/policy.go 25 1
M internal/repos/highlight/chroma.go 83 2
M internal/repos/queries/repos.sql 32 0
M internal/repos/sqlc/querier.go 6 0
M internal/repos/sqlc/repos.sql.go 137 0
M internal/web/handlers/repo/code.go 21 15
M internal/web/handlers/repo/fork.go 11 8
M internal/web/handlers/repo/issues.go 14 11
M internal/web/handlers/repo/pulls.go 12 9
M internal/web/handlers/repo/repo.go 26 6
A internal/web/handlers/repo/subnav.go 55 0
M internal/web/static/css/shithub.css 28 0
A internal/web/templates/_repo_subnav.html 24 0
M internal/web/templates/repo/blob.html 11 0
M internal/web/templates/repo/issues_list.html 12 5
M internal/web/templates/repo/pulls_list.html 12 5
M internal/web/templates/repo/tree.html 12 0
internal/auth/policy/org_owner_test.goadded
@@ -0,0 +1,92 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package policy_test
4
+
5
+import (
6
+	"context"
7
+	"testing"
8
+
9
+	"github.com/jackc/pgx/v5/pgtype"
10
+
11
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
12
+	"github.com/tenseleyFlow/shithub/internal/orgs"
13
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
14
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
15
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
16
+)
17
+
18
+const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
19
+	"AAAAAAAAAAAAAAAA$" +
20
+	"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
21
+
22
+// TestOrgOwner_ImplicitAdmin pins the S30 contract: an `org_members.role
23
+// = 'owner'` row promotes the user to RoleAdmin on every repo owned by
24
+// that org. Without this, an org owner can't push to their own org's
25
+// repos (the dogfood-blocking case).
26
+func TestOrgOwner_ImplicitAdmin(t *testing.T) {
27
+	pool := dbtest.NewTestDB(t)
28
+	ctx := context.Background()
29
+
30
+	creator, err := usersdb.New().CreateUser(ctx, pool, usersdb.CreateUserParams{
31
+		Username: "alice", DisplayName: "Alice", PasswordHash: fixtureHash,
32
+	})
33
+	if err != nil {
34
+		t.Fatalf("create user: %v", err)
35
+	}
36
+	deps := orgs.Deps{Pool: pool}
37
+	org, err := orgs.Create(ctx, deps, orgs.CreateParams{
38
+		Slug: "acme", DisplayName: "Acme", CreatedByUserID: creator.ID,
39
+	})
40
+	if err != nil {
41
+		t.Fatalf("create org: %v", err)
42
+	}
43
+	// Org-owned private repo.
44
+	repo, err := reposdb.New().CreateRepo(ctx, pool, reposdb.CreateRepoParams{
45
+		OwnerUserID:   pgtype.Int8{Valid: false},
46
+		OwnerOrgID:    pgtype.Int8{Int64: org.ID, Valid: true},
47
+		Name:          "secret",
48
+		DefaultBranch: "trunk",
49
+		Visibility:    reposdb.RepoVisibilityPrivate,
50
+	})
51
+	if err != nil {
52
+		t.Fatalf("create org repo: %v", err)
53
+	}
54
+	ref := policy.NewRepoRefFromRepo(repo)
55
+
56
+	actor := policy.UserActor(creator.ID, "alice", false, false)
57
+	pdeps := policy.Deps{Pool: pool}
58
+
59
+	// Push (write tier) must allow.
60
+	if got := policy.Can(ctx, pdeps, actor, policy.ActionRepoWrite, ref); !got.Allow {
61
+		t.Fatalf("org owner should be able to write: %+v", got)
62
+	}
63
+	// Repo-admin tier must allow.
64
+	if got := policy.Can(ctx, pdeps, actor, policy.ActionRepoAdmin, ref); !got.Allow {
65
+		t.Fatalf("org owner should be admin: %+v", got)
66
+	}
67
+
68
+	// A non-member must NOT see a private org repo.
69
+	bob, err := usersdb.New().CreateUser(ctx, pool, usersdb.CreateUserParams{
70
+		Username: "bob", DisplayName: "Bob", PasswordHash: fixtureHash,
71
+	})
72
+	if err != nil {
73
+		t.Fatalf("create bob: %v", err)
74
+	}
75
+	bobActor := policy.UserActor(bob.ID, "bob", false, false)
76
+	if got := policy.Can(ctx, pdeps, bobActor, policy.ActionRepoRead, ref); got.Allow {
77
+		t.Fatalf("non-member should not read private org repo: %+v", got)
78
+	}
79
+
80
+	// Member-but-not-owner must NOT get implicit admin (teams are S31).
81
+	if err := orgs.AddMember(ctx, deps, org.ID, bob.ID, creator.ID, "member"); err != nil {
82
+		t.Fatalf("add member: %v", err)
83
+	}
84
+	if got := policy.Can(ctx, pdeps, bobActor, policy.ActionRepoWrite, ref); got.Allow {
85
+		t.Fatalf("plain org member should NOT have implicit write: %+v", got)
86
+	}
87
+	// And read on a private org repo for a plain member is also denied
88
+	// today — implicit read for org members is deferred to S31 (teams).
89
+	if got := policy.Can(ctx, pdeps, bobActor, policy.ActionRepoRead, ref); got.Allow {
90
+		t.Fatalf("plain org member should NOT have implicit read on private repo: %+v", got)
91
+	}
92
+}
internal/auth/policy/policy.gomodified
@@ -186,6 +186,13 @@ func Maybe404(decision Decision, repo RepoRef, actor Actor) int {
186186
 // effectiveRole computes the highest-effective role for actor on repo.
187187
 // Owner ⇒ implicit admin; collaborator role from repo_collaborators;
188188
 // nothing otherwise.
189
+//
190
+// Org-owned repos: every `org_members.role='owner'` of the owning org
191
+// is treated as an implicit admin on every org-owned repo. This is
192
+// the S30 owner-implicit-admin contract — without it an org owner
193
+// can't push to their own org's repos. Org `member` role grants no
194
+// implicit access; teams (S31) and direct collaboration (S15) are the
195
+// only paths to repo permission for non-owners.
189196
 func effectiveRole(ctx context.Context, d Deps, actor Actor, repo RepoRef) (Role, error) {
190197
 	if actor.UserID != 0 && actor.UserID == repo.OwnerUserID {
191198
 		return RoleAdmin, nil
@@ -201,11 +208,28 @@ func effectiveRole(ctx context.Context, d Deps, actor Actor, repo RepoRef) (Role
201208
 		return r, nil
202209
 	}
203210
 
204
-	// DB lookup.
205211
 	if d.Pool == nil {
206212
 		// In tests where a Deps without Pool is passed, fail closed.
207213
 		return RoleNone, nil
208214
 	}
215
+
216
+	// Org-owner check fires first because it short-circuits with admin
217
+	// regardless of any per-repo collaborator row. The lookup is
218
+	// indexed on (org_id, user_id) — same cost as the collab lookup.
219
+	if repo.OwnerOrgID != 0 {
220
+		var dbOrgRole string
221
+		err := d.Pool.QueryRow(ctx,
222
+			`SELECT role::text FROM org_members WHERE org_id = $1 AND user_id = $2`,
223
+			repo.OwnerOrgID, actor.UserID,
224
+		).Scan(&dbOrgRole)
225
+		if err == nil && dbOrgRole == "owner" {
226
+			cachePut(cache, key, RoleAdmin)
227
+			return RoleAdmin, nil
228
+		}
229
+		// "no rows" or member-only falls through to the collab-row
230
+		// lookup below.
231
+	}
232
+
209233
 	q := policydb.New()
210234
 	dbRole, err := q.GetCollabRole(ctx, d.Pool, policydb.GetCollabRoleParams{
211235
 		RepoID: repo.ID,
internal/repos/highlight/chroma.gomodified
@@ -57,9 +57,28 @@ func Render(filename, source string) string {
5757
 
5858
 // CSS returns the `<style>`-wrappable CSS for the highlight theme so
5959
 // the operator can serve it once at /static/css/chroma.css. Generated
60
-// from the same `github` style Render uses, so colors stay consistent.
60
+// from BOTH the light (`github`) and dark (`github-dark`) Chroma styles
61
+// so blob views render correctly under either theme. Each block is
62
+// gated by `[data-theme="…"]` (the layout sets that on <html>) so only
63
+// one set of rules is active per view. Without the dark variant the
64
+// blob viewer renders code on a light background regardless of the
65
+// page's theme — invisible text in dark mode.
6166
 func CSS() string {
62
-	style := styles.Get("github")
67
+	light := writeStyleCSS("github")
68
+	dark := writeStyleCSS("github-dark")
69
+
70
+	var buf bytes.Buffer
71
+	buf.WriteString("/* light (default) — applies when [data-theme] is unset or 'light' */\n")
72
+	buf.WriteString(prefixChromaSelectors(light, `[data-theme="light"] `, ""))
73
+	buf.WriteString("\n/* dark */\n")
74
+	buf.WriteString(prefixChromaSelectors(dark, `[data-theme="dark"] `, ""))
75
+	return buf.String()
76
+}
77
+
78
+// writeStyleCSS emits Chroma's classes-mode CSS for a named style.
79
+// Falls back to the Fallback style when the name is unknown.
80
+func writeStyleCSS(name string) string {
81
+	style := styles.Get(name)
6382
 	if style == nil {
6483
 		style = styles.Fallback
6584
 	}
@@ -72,6 +91,68 @@ func CSS() string {
7291
 	return buf.String()
7392
 }
7493
 
94
+// prefixChromaSelectors prefixes every selector in css with `prefix`
95
+// so the rule only applies under the given theme attribute. Chroma's
96
+// CSS rules all start with `.chroma` (or its line-number child
97
+// classes); we walk top-level rules and prefix each.
98
+//
99
+// `_` is a placeholder for a future per-theme suffix (e.g. !important
100
+// on borders) — currently unused.
101
+func prefixChromaSelectors(css, prefix, _ string) string {
102
+	var out bytes.Buffer
103
+	for _, raw := range splitTopLevelRules(css) {
104
+		rule := strings.TrimSpace(raw)
105
+		if rule == "" {
106
+			continue
107
+		}
108
+		brace := strings.IndexByte(rule, '{')
109
+		if brace < 0 {
110
+			out.WriteString(rule)
111
+			continue
112
+		}
113
+		selectors := rule[:brace]
114
+		body := rule[brace:]
115
+		// Selector lists like ".chroma .nx, .chroma .nf" — prefix each.
116
+		parts := strings.Split(selectors, ",")
117
+		for i, p := range parts {
118
+			parts[i] = prefix + strings.TrimSpace(p)
119
+		}
120
+		out.WriteString(strings.Join(parts, ", "))
121
+		out.WriteString(" ")
122
+		out.WriteString(body)
123
+		out.WriteByte('\n')
124
+	}
125
+	return out.String()
126
+}
127
+
128
+// splitTopLevelRules splits a CSS blob on `}` boundaries while
129
+// preserving the brace as part of the preceding rule. Chroma's output
130
+// has no nested rules so naive depth-1 splitting is sufficient.
131
+func splitTopLevelRules(css string) []string {
132
+	var rules []string
133
+	start := 0
134
+	depth := 0
135
+	for i := 0; i < len(css); i++ {
136
+		switch css[i] {
137
+		case '{':
138
+			depth++
139
+		case '}':
140
+			depth--
141
+			if depth == 0 {
142
+				rules = append(rules, css[start:i+1])
143
+				start = i + 1
144
+			}
145
+		}
146
+	}
147
+	if start < len(css) {
148
+		tail := strings.TrimSpace(css[start:])
149
+		if tail != "" {
150
+			rules = append(rules, tail)
151
+		}
152
+	}
153
+	return rules
154
+}
155
+
75156
 // plainPre escapes source and wraps it in a <pre> for the no-lexer
76157
 // fallback. We still provide line numbers via a <table> so the blob
77158
 // template renders consistently.
internal/repos/queries/repos.sqlmodified
@@ -68,6 +68,38 @@ ORDER BY updated_at DESC;
6868
 SELECT count(*) FROM repos
6969
 WHERE owner_user_id = $1 AND deleted_at IS NULL;
7070
 
71
+-- name: GetRepoByOwnerOrgAndName :one
72
+-- S30: org-owner mirror of GetRepoByOwnerUserAndName. The (owner_org_id,
73
+-- name) partial unique index from 0017 backs this lookup with the same
74
+-- O(1) cost the user-side path enjoys.
75
+SELECT id, owner_user_id, owner_org_id, name, description, visibility,
76
+       default_branch, is_archived, archived_at, deleted_at,
77
+       disk_used_bytes, fork_of_repo_id, license_key, primary_language,
78
+       has_issues, has_pulls, created_at, updated_at, default_branch_oid,
79
+       allow_squash_merge, allow_rebase_merge, allow_merge_commit, default_merge_method,
80
+       star_count, watcher_count, fork_count, init_status,
81
+       last_indexed_oid
82
+FROM repos
83
+WHERE owner_org_id = $1 AND name = $2 AND deleted_at IS NULL;
84
+
85
+-- name: ExistsRepoForOwnerOrg :one
86
+SELECT EXISTS(
87
+    SELECT 1 FROM repos
88
+    WHERE owner_org_id = $1 AND name = $2 AND deleted_at IS NULL
89
+);
90
+
91
+-- name: ListReposForOwnerOrg :many
92
+SELECT id, owner_user_id, owner_org_id, name, description, visibility,
93
+       default_branch, is_archived, archived_at, deleted_at,
94
+       disk_used_bytes, fork_of_repo_id, license_key, primary_language,
95
+       has_issues, has_pulls, created_at, updated_at, default_branch_oid,
96
+       allow_squash_merge, allow_rebase_merge, allow_merge_commit, default_merge_method,
97
+       star_count, watcher_count, fork_count, init_status,
98
+       last_indexed_oid
99
+FROM repos
100
+WHERE owner_org_id = $1 AND deleted_at IS NULL
101
+ORDER BY updated_at DESC;
102
+
71103
 -- name: SoftDeleteRepo :exec
72104
 UPDATE repos SET deleted_at = now() WHERE id = $1;
73105
 
internal/repos/sqlc/querier.gomodified
@@ -39,12 +39,17 @@ type Querier interface {
3939
 	// (they would dangle once the repos row is gone; the FK ON DELETE
4040
 	// CASCADE would handle it, but explicit is auditable).
4141
 	DeleteRedirectsForRepo(ctx context.Context, db DBTX, repoID int64) error
42
+	ExistsRepoForOwnerOrg(ctx context.Context, db DBTX, arg ExistsRepoForOwnerOrgParams) (bool, error)
4243
 	ExistsRepoForOwnerUser(ctx context.Context, db DBTX, arg ExistsRepoForOwnerUserParams) (bool, error)
4344
 	// Called by the periodic worker (transfers:expire) — flips pending
4445
 	// offers past their expires_at to the expired terminal state.
4546
 	ExpirePendingTransfers(ctx context.Context, db DBTX) (int64, error)
4647
 	GetBranchProtectionRule(ctx context.Context, db DBTX, id int64) (BranchProtectionRule, error)
4748
 	GetRepoByID(ctx context.Context, db DBTX, id int64) (Repo, error)
49
+	// S30: org-owner mirror of GetRepoByOwnerUserAndName. The (owner_org_id,
50
+	// name) partial unique index from 0017 backs this lookup with the same
51
+	// O(1) cost the user-side path enjoys.
52
+	GetRepoByOwnerOrgAndName(ctx context.Context, db DBTX, arg GetRepoByOwnerOrgAndNameParams) (Repo, error)
4853
 	GetRepoByOwnerUserAndName(ctx context.Context, db DBTX, arg GetRepoByOwnerUserAndNameParams) (Repo, error)
4954
 	// Returns the owner_username for a repo. Used by size-recalc and other
5055
 	// jobs that need to derive the bare-repo on-disk path without round-
@@ -78,6 +83,7 @@ type Querier interface {
7883
 	// destruction. The 7-day grace is hard-coded here; if we add a config
7984
 	// knob later, change this to a parameter.
8085
 	ListRepoIDsPastSoftDeleteGrace(ctx context.Context, db DBTX) ([]int64, error)
86
+	ListReposForOwnerOrg(ctx context.Context, db DBTX, ownerOrgID pgtype.Int8) ([]Repo, error)
8187
 	ListReposForOwnerUser(ctx context.Context, db DBTX, ownerUserID pgtype.Int8) ([]Repo, error)
8288
 	// S28 code-search reconciler: returns repos whose default_branch_oid
8389
 	// has advanced past last_indexed_oid (or last_indexed_oid is NULL
internal/repos/sqlc/repos.sql.gomodified
@@ -185,6 +185,25 @@ func (q *Queries) CreateRepo(ctx context.Context, db DBTX, arg CreateRepoParams)
185185
 	return i, err
186186
 }
187187
 
188
+const existsRepoForOwnerOrg = `-- name: ExistsRepoForOwnerOrg :one
189
+SELECT EXISTS(
190
+    SELECT 1 FROM repos
191
+    WHERE owner_org_id = $1 AND name = $2 AND deleted_at IS NULL
192
+)
193
+`
194
+
195
+type ExistsRepoForOwnerOrgParams struct {
196
+	OwnerOrgID pgtype.Int8
197
+	Name       string
198
+}
199
+
200
+func (q *Queries) ExistsRepoForOwnerOrg(ctx context.Context, db DBTX, arg ExistsRepoForOwnerOrgParams) (bool, error) {
201
+	row := db.QueryRow(ctx, existsRepoForOwnerOrg, arg.OwnerOrgID, arg.Name)
202
+	var exists bool
203
+	err := row.Scan(&exists)
204
+	return exists, err
205
+}
206
+
188207
 const existsRepoForOwnerUser = `-- name: ExistsRepoForOwnerUser :one
189208
 SELECT EXISTS(
190209
     SELECT 1 FROM repos
@@ -252,6 +271,62 @@ func (q *Queries) GetRepoByID(ctx context.Context, db DBTX, id int64) (Repo, err
252271
 	return i, err
253272
 }
254273
 
274
+const getRepoByOwnerOrgAndName = `-- name: GetRepoByOwnerOrgAndName :one
275
+SELECT id, owner_user_id, owner_org_id, name, description, visibility,
276
+       default_branch, is_archived, archived_at, deleted_at,
277
+       disk_used_bytes, fork_of_repo_id, license_key, primary_language,
278
+       has_issues, has_pulls, created_at, updated_at, default_branch_oid,
279
+       allow_squash_merge, allow_rebase_merge, allow_merge_commit, default_merge_method,
280
+       star_count, watcher_count, fork_count, init_status,
281
+       last_indexed_oid
282
+FROM repos
283
+WHERE owner_org_id = $1 AND name = $2 AND deleted_at IS NULL
284
+`
285
+
286
+type GetRepoByOwnerOrgAndNameParams struct {
287
+	OwnerOrgID pgtype.Int8
288
+	Name       string
289
+}
290
+
291
+// S30: org-owner mirror of GetRepoByOwnerUserAndName. The (owner_org_id,
292
+// name) partial unique index from 0017 backs this lookup with the same
293
+// O(1) cost the user-side path enjoys.
294
+func (q *Queries) GetRepoByOwnerOrgAndName(ctx context.Context, db DBTX, arg GetRepoByOwnerOrgAndNameParams) (Repo, error) {
295
+	row := db.QueryRow(ctx, getRepoByOwnerOrgAndName, arg.OwnerOrgID, arg.Name)
296
+	var i Repo
297
+	err := row.Scan(
298
+		&i.ID,
299
+		&i.OwnerUserID,
300
+		&i.OwnerOrgID,
301
+		&i.Name,
302
+		&i.Description,
303
+		&i.Visibility,
304
+		&i.DefaultBranch,
305
+		&i.IsArchived,
306
+		&i.ArchivedAt,
307
+		&i.DeletedAt,
308
+		&i.DiskUsedBytes,
309
+		&i.ForkOfRepoID,
310
+		&i.LicenseKey,
311
+		&i.PrimaryLanguage,
312
+		&i.HasIssues,
313
+		&i.HasPulls,
314
+		&i.CreatedAt,
315
+		&i.UpdatedAt,
316
+		&i.DefaultBranchOid,
317
+		&i.AllowSquashMerge,
318
+		&i.AllowRebaseMerge,
319
+		&i.AllowMergeCommit,
320
+		&i.DefaultMergeMethod,
321
+		&i.StarCount,
322
+		&i.WatcherCount,
323
+		&i.ForkCount,
324
+		&i.InitStatus,
325
+		&i.LastIndexedOid,
326
+	)
327
+	return i, err
328
+}
329
+
255330
 const getRepoByOwnerUserAndName = `-- name: GetRepoByOwnerUserAndName :one
256331
 SELECT id, owner_user_id, owner_org_id, name, description, visibility,
257332
        default_branch, is_archived, archived_at, deleted_at,
@@ -468,6 +543,68 @@ func (q *Queries) ListForksOfRepoForRepack(ctx context.Context, db DBTX, forkOfR
468543
 	return items, nil
469544
 }
470545
 
546
+const listReposForOwnerOrg = `-- name: ListReposForOwnerOrg :many
547
+SELECT id, owner_user_id, owner_org_id, name, description, visibility,
548
+       default_branch, is_archived, archived_at, deleted_at,
549
+       disk_used_bytes, fork_of_repo_id, license_key, primary_language,
550
+       has_issues, has_pulls, created_at, updated_at, default_branch_oid,
551
+       allow_squash_merge, allow_rebase_merge, allow_merge_commit, default_merge_method,
552
+       star_count, watcher_count, fork_count, init_status,
553
+       last_indexed_oid
554
+FROM repos
555
+WHERE owner_org_id = $1 AND deleted_at IS NULL
556
+ORDER BY updated_at DESC
557
+`
558
+
559
+func (q *Queries) ListReposForOwnerOrg(ctx context.Context, db DBTX, ownerOrgID pgtype.Int8) ([]Repo, error) {
560
+	rows, err := db.Query(ctx, listReposForOwnerOrg, ownerOrgID)
561
+	if err != nil {
562
+		return nil, err
563
+	}
564
+	defer rows.Close()
565
+	items := []Repo{}
566
+	for rows.Next() {
567
+		var i Repo
568
+		if err := rows.Scan(
569
+			&i.ID,
570
+			&i.OwnerUserID,
571
+			&i.OwnerOrgID,
572
+			&i.Name,
573
+			&i.Description,
574
+			&i.Visibility,
575
+			&i.DefaultBranch,
576
+			&i.IsArchived,
577
+			&i.ArchivedAt,
578
+			&i.DeletedAt,
579
+			&i.DiskUsedBytes,
580
+			&i.ForkOfRepoID,
581
+			&i.LicenseKey,
582
+			&i.PrimaryLanguage,
583
+			&i.HasIssues,
584
+			&i.HasPulls,
585
+			&i.CreatedAt,
586
+			&i.UpdatedAt,
587
+			&i.DefaultBranchOid,
588
+			&i.AllowSquashMerge,
589
+			&i.AllowRebaseMerge,
590
+			&i.AllowMergeCommit,
591
+			&i.DefaultMergeMethod,
592
+			&i.StarCount,
593
+			&i.WatcherCount,
594
+			&i.ForkCount,
595
+			&i.InitStatus,
596
+			&i.LastIndexedOid,
597
+		); err != nil {
598
+			return nil, err
599
+		}
600
+		items = append(items, i)
601
+	}
602
+	if err := rows.Err(); err != nil {
603
+		return nil, err
604
+	}
605
+	return items, nil
606
+}
607
+
471608
 const listReposForOwnerUser = `-- name: ListReposForOwnerUser :many
472609
 SELECT id, owner_user_id, owner_org_id, name, description, visibility,
473610
        default_branch, is_archived, archived_at, deleted_at,
internal/web/handlers/repo/code.gomodified
@@ -162,6 +162,9 @@ func (h *Handlers) codeTree(w http.ResponseWriter, r *http.Request) {
162162
 		"HTTPSCloneURL": h.cloneHTTPS(cc.owner, cc.row.Name),
163163
 		"SSHEnabled":    h.d.CloneURLs.SSHEnabled,
164164
 		"SSHCloneURL":   h.cloneSSH(cc.owner, cc.row.Name),
165
+		"RepoCounts":    h.subnavCounts(r.Context(), cc.row.ID, cc.row.ForkCount),
166
+		"CanSettings":   h.canViewSettings(middleware.CurrentUserFromContext(r.Context())),
167
+		"ActiveSubnav":  "code",
165168
 	})
166169
 }
167170
 
@@ -221,21 +224,24 @@ func (h *Handlers) codeBlob(w http.ResponseWriter, r *http.Request) {
221224
 	const maxReadBytes = 4 * 1024 * 1024       // never read more than 4 MiB even for highlighting
222225
 
223226
 	data := map[string]any{
224
-		"Title":      cc.subpath + " · " + cc.row.Name,
225
-		"CSRFToken":  middleware.CSRFTokenForRequest(r),
226
-		"Owner":      cc.owner,
227
-		"Repo":       cc.row,
228
-		"Ref":        cc.ref,
229
-		"Path":       cc.subpath,
230
-		"Crumbs":     breadcrumbs(cc.owner, cc.row.Name, cc.ref, cc.subpath),
231
-		"Branches":   cc.refs.Branches,
232
-		"Tags":       cc.refs.Tags,
233
-		"Size":       size,
234
-		"IsLarge":    size > largeFileThreshold,
235
-		"IsBinary":   false,
236
-		"IsImage":    false,
237
-		"IsMarkdown": false,
238
-		"Language":   highlight.LanguageGuess(cc.subpath),
227
+		"Title":        cc.subpath + " · " + cc.row.Name,
228
+		"CSRFToken":    middleware.CSRFTokenForRequest(r),
229
+		"Owner":        cc.owner,
230
+		"Repo":         cc.row,
231
+		"Ref":          cc.ref,
232
+		"Path":         cc.subpath,
233
+		"Crumbs":       breadcrumbs(cc.owner, cc.row.Name, cc.ref, cc.subpath),
234
+		"Branches":     cc.refs.Branches,
235
+		"Tags":         cc.refs.Tags,
236
+		"Size":         size,
237
+		"IsLarge":      size > largeFileThreshold,
238
+		"IsBinary":     false,
239
+		"IsImage":      false,
240
+		"IsMarkdown":   false,
241
+		"Language":     highlight.LanguageGuess(cc.subpath),
242
+		"RepoCounts":   h.subnavCounts(r.Context(), cc.row.ID, cc.row.ForkCount),
243
+		"CanSettings":  h.canViewSettings(middleware.CurrentUserFromContext(r.Context())),
244
+		"ActiveSubnav": "code",
239245
 	}
240246
 	if size > largeFileThreshold {
241247
 		h.d.Render.RenderPage(w, r, "repo/blob", data)
internal/web/handlers/repo/fork.gomodified
@@ -193,14 +193,17 @@ func (h *Handlers) forksList(w http.ResponseWriter, r *http.Request) {
193193
 		})
194194
 	}
195195
 	common := map[string]any{
196
-		"Title":   "Forks · " + row.Name,
197
-		"Owner":   ownerName,
198
-		"Repo":    row,
199
-		"Forks":   visible,
200
-		"Total":   total,
201
-		"Page":    page,
202
-		"HasNext": int64(page*pageSize) < total,
203
-		"HasPrev": page > 1,
196
+		"Title":        "Forks · " + row.Name,
197
+		"Owner":        ownerName,
198
+		"Repo":         row,
199
+		"Forks":        visible,
200
+		"Total":        total,
201
+		"Page":         page,
202
+		"HasNext":      int64(page*pageSize) < total,
203
+		"HasPrev":      page > 1,
204
+		"RepoCounts":   h.subnavCounts(r.Context(), row.ID, row.ForkCount),
205
+		"CanSettings":  h.canViewSettings(middleware.CurrentUserFromContext(r.Context())),
206
+		"ActiveSubnav": "forks",
204207
 	}
205208
 	if err := h.d.Render.RenderPage(w, r, "repo/forks", common); err != nil {
206209
 		h.d.Logger.ErrorContext(r.Context(), "forks render", "error", err)
internal/web/handlers/repo/issues.gomodified
@@ -142,17 +142,20 @@ func (h *Handlers) issuesList(w http.ResponseWriter, r *http.Request) {
142142
 
143143
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
144144
 	if err := h.d.Render.RenderPage(w, r, "repo/issues_list", map[string]any{
145
-		"Title":       "Issues · " + row.Name,
146
-		"Owner":       owner.Username,
147
-		"Repo":        row,
148
-		"Items":       items,
149
-		"State":       stateFilter,
150
-		"OpenCount":   openCount,
151
-		"ClosedCount": closedCount,
152
-		"Total":       total,
153
-		"Page":        page,
154
-		"PerPage":     perPage,
155
-		"CSRFToken":   middleware.CSRFTokenForRequest(r),
145
+		"Title":        "Issues · " + row.Name,
146
+		"Owner":        owner.Username,
147
+		"Repo":         row,
148
+		"Items":        items,
149
+		"State":        stateFilter,
150
+		"OpenCount":    openCount,
151
+		"ClosedCount":  closedCount,
152
+		"Total":        total,
153
+		"Page":         page,
154
+		"PerPage":      perPage,
155
+		"CSRFToken":    middleware.CSRFTokenForRequest(r),
156
+		"RepoCounts":   h.subnavCounts(r.Context(), row.ID, row.ForkCount),
157
+		"CanSettings":  h.canViewSettings(middleware.CurrentUserFromContext(r.Context())),
158
+		"ActiveSubnav": "issues",
156159
 	}); err != nil {
157160
 		h.d.Logger.ErrorContext(r.Context(), "issues: render list", "error", err)
158161
 	}
internal/web/handlers/repo/pulls.gomodified
@@ -115,15 +115,18 @@ func (h *Handlers) pullsList(w http.ResponseWriter, r *http.Request) {
115115
 	}
116116
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
117117
 	_ = h.d.Render.RenderPage(w, r, "repo/pulls_list", map[string]any{
118
-		"Title":       "Pull requests · " + row.Name,
119
-		"Owner":       owner.Username,
120
-		"Repo":        row,
121
-		"Items":       items,
122
-		"State":       state,
123
-		"OpenCount":   openCount,
124
-		"ClosedCount": closedCount,
125
-		"Page":        page,
126
-		"CSRFToken":   middleware.CSRFTokenForRequest(r),
118
+		"Title":        "Pull requests · " + row.Name,
119
+		"Owner":        owner.Username,
120
+		"Repo":         row,
121
+		"Items":        items,
122
+		"State":        state,
123
+		"OpenCount":    openCount,
124
+		"ClosedCount":  closedCount,
125
+		"Page":         page,
126
+		"CSRFToken":    middleware.CSRFTokenForRequest(r),
127
+		"RepoCounts":   h.subnavCounts(r.Context(), row.ID, row.ForkCount),
128
+		"CanSettings":  h.canViewSettings(middleware.CurrentUserFromContext(r.Context())),
129
+		"ActiveSubnav": "pulls",
127130
 	})
128131
 }
129132
 
internal/web/handlers/repo/repo.gomodified
@@ -23,6 +23,7 @@ import (
2323
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
2424
 	checksdb "github.com/tenseleyFlow/shithub/internal/checks/sqlc"
2525
 	issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
26
+	"github.com/tenseleyFlow/shithub/internal/orgs"
2627
 	pullsdb "github.com/tenseleyFlow/shithub/internal/pulls/sqlc"
2728
 	"github.com/tenseleyFlow/shithub/internal/repos"
2829
 	repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
@@ -235,6 +236,9 @@ func (h *Handlers) repoHome(w http.ResponseWriter, r *http.Request) {
235236
 		"HTTPSCloneURL": h.cloneHTTPS(owner, row.Name),
236237
 		"SSHEnabled":    h.d.CloneURLs.SSHEnabled,
237238
 		"SSHCloneURL":   h.cloneSSH(owner, row.Name),
239
+		"RepoCounts":    h.subnavCounts(r.Context(), row.ID, row.ForkCount),
240
+		"CanSettings":   h.canViewSettings(middleware.CurrentUserFromContext(r.Context())),
241
+		"ActiveSubnav":  "code",
238242
 	}
239243
 
240244
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -250,15 +254,31 @@ func (h *Handlers) repoHome(w http.ResponseWriter, r *http.Request) {
250254
 //   - AND the viewer is allowed to see it (public OR viewer is owner).
251255
 //
252256
 // Anything else returns ErrNoRows so the caller can 404 uniformly.
257
+//
258
+// Owner kind is dispatched via principals: ownerName resolves to
259
+// either a user_id or an org_id via the same single-source-of-truth
260
+// table that drives /{slug} routing. Both kinds resolve through the
261
+// same indexed lookup so the cost is identical.
253262
 func (h *Handlers) lookupRepoForViewer(ctx context.Context, ownerName, repoName string, viewer middleware.CurrentUser) (reposdb.Repo, error) {
254
-	owner, err := h.uq.GetUserByUsername(ctx, h.d.Pool, ownerName)
263
+	principal, err := orgs.Resolve(ctx, h.d.Pool, ownerName)
255264
 	if err != nil {
256
-		return reposdb.Repo{}, err
265
+		return reposdb.Repo{}, pgx.ErrNoRows
266
+	}
267
+	var row reposdb.Repo
268
+	switch principal.Kind {
269
+	case orgs.PrincipalUser:
270
+		row, err = h.rq.GetRepoByOwnerUserAndName(ctx, h.d.Pool, reposdb.GetRepoByOwnerUserAndNameParams{
271
+			OwnerUserID: pgtype.Int8{Int64: principal.ID, Valid: true},
272
+			Name:        repoName,
273
+		})
274
+	case orgs.PrincipalOrg:
275
+		row, err = h.rq.GetRepoByOwnerOrgAndName(ctx, h.d.Pool, reposdb.GetRepoByOwnerOrgAndNameParams{
276
+			OwnerOrgID: pgtype.Int8{Int64: principal.ID, Valid: true},
277
+			Name:       repoName,
278
+		})
279
+	default:
280
+		return reposdb.Repo{}, pgx.ErrNoRows
257281
 	}
258
-	row, err := h.rq.GetRepoByOwnerUserAndName(ctx, h.d.Pool, reposdb.GetRepoByOwnerUserAndNameParams{
259
-		OwnerUserID: pgtype.Int8{Int64: owner.ID, Valid: true},
260
-		Name:        repoName,
261
-	})
262282
 	if err != nil {
263283
 		return reposdb.Repo{}, err
264284
 	}
internal/web/handlers/repo/subnav.goadded
@@ -0,0 +1,55 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"context"
7
+
8
+	"github.com/jackc/pgx/v5/pgtype"
9
+
10
+	issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
11
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
12
+)
13
+
14
+// repoSubnavData is the shape the `repo-subnav` partial reads. Counts
15
+// are best-effort: a query failure collapses to zero so the nav still
16
+// renders. The `CanSettings` flag is a UX hint — the actual permission
17
+// check happens inside the settings handler via policy.Can.
18
+type repoSubnavData struct {
19
+	Issues int64
20
+	Pulls  int64
21
+	Forks  int64
22
+}
23
+
24
+// subnavCounts returns issue / PR / fork counts for the repo's
25
+// header subnav. Counts are visibility-aware via the underlying
26
+// queries (issues + pulls live in the same row table; the count
27
+// query honors the issue_kind discriminator so closed counts and
28
+// PR counts don't bleed into the issue badge).
29
+func (h *Handlers) subnavCounts(ctx context.Context, repoID, forkCount int64) repoSubnavData {
30
+	out := repoSubnavData{Forks: forkCount}
31
+	openText := pgtype.Text{String: "open", Valid: true}
32
+	if n, err := h.iq.CountIssues(ctx, h.d.Pool, issuesdb.CountIssuesParams{
33
+		RepoID:      repoID,
34
+		StateFilter: openText,
35
+		Kind:        issuesdb.NullIssueKind{IssueKind: issuesdb.IssueKindIssue, Valid: true},
36
+	}); err == nil {
37
+		out.Issues = n
38
+	}
39
+	if n, err := h.iq.CountIssues(ctx, h.d.Pool, issuesdb.CountIssuesParams{
40
+		RepoID:      repoID,
41
+		StateFilter: openText,
42
+		Kind:        issuesdb.NullIssueKind{IssueKind: issuesdb.IssueKindPr, Valid: true},
43
+	}); err == nil {
44
+		out.Pulls = n
45
+	}
46
+	return out
47
+}
48
+
49
+// canViewSettings reports whether the viewer should see the Settings
50
+// tab. We surface it for any logged-in user who could plausibly have
51
+// admin (the actual gate is server-side); for anonymous viewers we
52
+// hide it.
53
+func (h *Handlers) canViewSettings(viewer middleware.CurrentUser) bool {
54
+	return !viewer.IsAnonymous()
55
+}
internal/web/static/css/shithub.cssmodified
@@ -1018,6 +1018,34 @@ code {
10181018
 .shithub-repo-list-meta { color: var(--fg-muted); font-size: 0.8rem; display: flex; gap: 1rem; flex-wrap: wrap; margin: 0.4rem 0 0; }
10191019
 .shithub-pill-archived { background: #ffd35a; color: #3b2300; }
10201020
 
1021
+/* Repo subnav (S30 polish): GitHub-style Code / Issues / Pulls / Settings tabs. */
1022
+.shithub-repo-page-head {
1023
+  margin-bottom: 0.25rem;
1024
+}
1025
+.shithub-repo-page-title { font-size: 1.4rem; margin: 0.5rem 0; display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; }
1026
+.shithub-repo-page-title a { color: var(--accent-fg, #4493f8); }
1027
+.shithub-repo-subnav {
1028
+  display: flex;
1029
+  gap: 0.25rem;
1030
+  margin: 0 0 1.25rem;
1031
+  border-bottom: 1px solid var(--border-default);
1032
+  flex-wrap: wrap;
1033
+}
1034
+.shithub-repo-subnav-tab {
1035
+  display: inline-flex;
1036
+  align-items: center;
1037
+  gap: 0.4rem;
1038
+  padding: 0.55rem 0.85rem;
1039
+  color: var(--fg-default);
1040
+  border-bottom: 2px solid transparent;
1041
+  font-size: 0.9rem;
1042
+  text-decoration: none;
1043
+  position: relative;
1044
+  bottom: -1px;
1045
+}
1046
+.shithub-repo-subnav-tab:hover { background: var(--canvas-subtle); border-radius: 6px 6px 0 0; }
1047
+.shithub-repo-subnav-tab.is-active { border-bottom-color: var(--accent-emphasis, #fd8c73); font-weight: 600; }
1048
+
10211049
 .shithub-tree {
10221050
   width: 100%;
10231051
   border-collapse: collapse;
internal/web/templates/_repo_subnav.htmladded
@@ -0,0 +1,24 @@
1
+{{ define "repo-subnav" -}}
2
+<nav class="shithub-repo-subnav" aria-label="Repository sections">
3
+  <a href="/{{ .Owner }}/{{ .Repo.Name }}" class="shithub-repo-subnav-tab{{ if eq .ActiveSubnav "code" }} is-active{{ end }}">
4
+    {{ octicon "directory" }} Code
5
+  </a>
6
+  <a href="/{{ .Owner }}/{{ .Repo.Name }}/issues" class="shithub-repo-subnav-tab{{ if eq .ActiveSubnav "issues" }} is-active{{ end }}">
7
+    {{ octicon "alert" }} Issues
8
+    {{ with .RepoCounts.Issues }}<span class="shithub-tab-count">{{ . }}</span>{{ end }}
9
+  </a>
10
+  <a href="/{{ .Owner }}/{{ .Repo.Name }}/pulls" class="shithub-repo-subnav-tab{{ if eq .ActiveSubnav "pulls" }} is-active{{ end }}">
11
+    {{ octicon "submodule" }} Pull requests
12
+    {{ with .RepoCounts.Pulls }}<span class="shithub-tab-count">{{ . }}</span>{{ end }}
13
+  </a>
14
+  <a href="/{{ .Owner }}/{{ .Repo.Name }}/forks" class="shithub-repo-subnav-tab{{ if eq .ActiveSubnav "forks" }} is-active{{ end }}">
15
+    {{ octicon "submodule" }} Forks
16
+    {{ with .RepoCounts.Forks }}<span class="shithub-tab-count">{{ . }}</span>{{ end }}
17
+  </a>
18
+  {{ if .CanSettings }}
19
+  <a href="/{{ .Owner }}/{{ .Repo.Name }}/settings" class="shithub-repo-subnav-tab{{ if eq .ActiveSubnav "settings" }} is-active{{ end }}">
20
+    {{ octicon "directory" }} Settings
21
+  </a>
22
+  {{ end }}
23
+</nav>
24
+{{- end }}
internal/web/templates/repo/blob.htmlmodified
@@ -1,4 +1,14 @@
11
 {{ define "page" -}}
2
+<section class="shithub-repo-page">
3
+  <header class="shithub-repo-page-head">
4
+    <h1 class="shithub-repo-page-title">
5
+      <a href="/{{ .Owner }}">{{ .Owner }}</a>
6
+      <span class="shithub-code-sep">/</span>
7
+      <a href="/{{ .Owner }}/{{ .Repo.Name }}">{{ .Repo.Name }}</a>
8
+      {{ if eq (printf "%s" .Repo.Visibility) "private" }}<span class="shithub-pill shithub-pill-private">private</span>{{ else }}<span class="shithub-pill">public</span>{{ end }}
9
+    </h1>
10
+  </header>
11
+  {{ template "repo-subnav" . }}
212
 <section class="shithub-blob">
313
   <header class="shithub-code-head">
414
     <nav class="shithub-code-crumbs" aria-label="Breadcrumb">
@@ -41,4 +51,5 @@
4151
     <div class="shithub-blob-source">{{ .HighlightedHTML }}</div>
4252
   {{ end }}
4353
 </section>
54
+</section>
4455
 {{- end }}
internal/web/templates/repo/issues_list.htmlmodified
@@ -1,11 +1,17 @@
11
 {{ define "page" -}}
2
-<section class="shithub-issues">
3
-  <header class="shithub-issues-head">
4
-    <h1>
5
-      <a href="/{{ .Owner }}/{{ .Repo.Name }}">{{ .Owner }}/{{ .Repo.Name }}</a>
2
+<section class="shithub-repo-page">
3
+  <header class="shithub-repo-page-head">
4
+    <h1 class="shithub-repo-page-title">
5
+      <a href="/{{ .Owner }}">{{ .Owner }}</a>
66
       <span class="shithub-code-sep">/</span>
7
-      Issues
7
+      <a href="/{{ .Owner }}/{{ .Repo.Name }}">{{ .Repo.Name }}</a>
8
+      {{ if eq (printf "%s" .Repo.Visibility) "private" }}<span class="shithub-pill shithub-pill-private">private</span>{{ else }}<span class="shithub-pill">public</span>{{ end }}
89
     </h1>
10
+  </header>
11
+  {{ template "repo-subnav" . }}
12
+<section class="shithub-issues">
13
+  <header class="shithub-issues-head">
14
+    <h1>Issues</h1>
915
     <div class="shithub-issues-actions">
1016
       <a href="/{{ .Owner }}/{{ .Repo.Name }}/labels" class="shithub-button">Labels</a>
1117
       <a href="/{{ .Owner }}/{{ .Repo.Name }}/milestones" class="shithub-button">Milestones</a>
@@ -56,4 +62,5 @@
5662
   <p class="shithub-issues-empty">No {{ .State }} issues. <a href="/{{ .Owner }}/{{ .Repo.Name }}/issues/new">Open one</a>.</p>
5763
   {{ end }}
5864
 </section>
65
+</section>
5966
 {{- end }}
internal/web/templates/repo/pulls_list.htmlmodified
@@ -1,11 +1,17 @@
11
 {{ define "page" -}}
2
-<section class="shithub-pulls">
3
-  <header class="shithub-issues-head">
4
-    <h1>
5
-      <a href="/{{ .Owner }}/{{ .Repo.Name }}">{{ .Owner }}/{{ .Repo.Name }}</a>
2
+<section class="shithub-repo-page">
3
+  <header class="shithub-repo-page-head">
4
+    <h1 class="shithub-repo-page-title">
5
+      <a href="/{{ .Owner }}">{{ .Owner }}</a>
66
       <span class="shithub-code-sep">/</span>
7
-      Pull requests
7
+      <a href="/{{ .Owner }}/{{ .Repo.Name }}">{{ .Repo.Name }}</a>
8
+      {{ if eq (printf "%s" .Repo.Visibility) "private" }}<span class="shithub-pill shithub-pill-private">private</span>{{ else }}<span class="shithub-pill">public</span>{{ end }}
89
     </h1>
10
+  </header>
11
+  {{ template "repo-subnav" . }}
12
+<section class="shithub-pulls">
13
+  <header class="shithub-issues-head">
14
+    <h1>Pull requests</h1>
915
     <div class="shithub-issues-actions">
1016
       <a href="/{{ .Owner }}/{{ .Repo.Name }}/compare/{{ .Repo.DefaultBranch }}...{{ .Repo.DefaultBranch }}" class="shithub-button shithub-button-primary">New pull request</a>
1117
     </div>
@@ -49,4 +55,5 @@
4955
   <p class="shithub-issues-empty">No {{ .State }} pull requests.</p>
5056
   {{ end }}
5157
 </section>
58
+</section>
5259
 {{- end }}
internal/web/templates/repo/tree.htmlmodified
@@ -1,4 +1,15 @@
11
 {{ define "page" -}}
2
+<section class="shithub-repo-page">
3
+  <header class="shithub-repo-page-head">
4
+    <h1 class="shithub-repo-page-title">
5
+      <a href="/{{ .Owner }}">{{ .Owner }}</a>
6
+      <span class="shithub-code-sep">/</span>
7
+      <a href="/{{ .Owner }}/{{ .Repo.Name }}">{{ .Repo.Name }}</a>
8
+      {{ if eq (printf "%s" .Repo.Visibility) "private" }}<span class="shithub-pill shithub-pill-private">private</span>{{ else }}<span class="shithub-pill">public</span>{{ end }}
9
+    </h1>
10
+  </header>
11
+  {{ template "repo-subnav" . }}
12
+
213
 <section class="shithub-code">
314
   <header class="shithub-code-head">
415
     <nav class="shithub-code-crumbs" aria-label="Breadcrumb">
@@ -78,6 +89,7 @@
7889
     </tbody>
7990
   </table>
8091
 
92
+  </section>
8193
   {{ if .README }}
8294
   <section class="shithub-readme" aria-label="README">
8395
     {{ .README }}