tenseleyflow/shithub / 7f720ee

Browse files

Fix org-owned repositories in search

Authored by espadonne
SHA
7f720eefce227f02724400581e2180afa0a84838
Parents
e8dcd6c
Tree
5b4d150

7 changed files

StatusFile+-
M docs/internal/search.md 5 0
A internal/migrationsfs/migrations/0041_search_repo_owner_terms.sql 146 0
M internal/search/code.go 4 8
M internal/search/issues.go 4 9
A internal/search/repo_owner_sql.go 28 0
M internal/search/repos.go 4 9
M internal/search/search_test.go 117 2
docs/internal/search.mdmodified
@@ -48,6 +48,11 @@ internal/worker/jobs/
48
   trigram index on the raw content for camelCase / snake_case
48
   trigram index on the raw content for camelCase / snake_case
49
   substring matches the FTS tokenizer mangles. `repos.last_indexed_oid`
49
   substring matches the FTS tokenizer mangles. `repos.last_indexed_oid`
50
   added so the reconciler can detect drift.
50
   added so the reconciler can detect drift.
51
+* **0041 — repo owner terms**: repo search documents include the
52
+  owning user/org handle plus display name, and result queries
53
+  resolve owners through either `users` or `orgs`. This keeps public
54
+  org repositories searchable by both `owner/repo` and owner-only
55
+  text queries.
51
 
56
 
52
 ## Visibility predicate
57
 ## Visibility predicate
53
 
58
 
internal/migrationsfs/migrations/0041_search_repo_owner_terms.sqladded
@@ -0,0 +1,146 @@
1
+-- SPDX-License-Identifier: AGPL-3.0-or-later
2
+--
3
+-- Include the owning user/org handle in repo search documents and
4
+-- keep those documents fresh when owner display fields change.
5
+
6
+-- +goose Up
7
+
8
+-- +goose StatementBegin
9
+CREATE OR REPLACE FUNCTION repos_search_tsv(
10
+    repo_name citext,
11
+    repo_description text,
12
+    repo_owner_user_id bigint,
13
+    repo_owner_org_id bigint
14
+) RETURNS tsvector
15
+    LANGUAGE plpgsql AS $$
16
+DECLARE
17
+    owner_login text := '';
18
+    owner_display text := '';
19
+BEGIN
20
+    IF repo_owner_user_id IS NOT NULL THEN
21
+        SELECT username::text, display_name
22
+          INTO owner_login, owner_display
23
+          FROM users
24
+         WHERE id = repo_owner_user_id;
25
+    ELSIF repo_owner_org_id IS NOT NULL THEN
26
+        SELECT slug::text, display_name
27
+          INTO owner_login, owner_display
28
+          FROM orgs
29
+         WHERE id = repo_owner_org_id;
30
+    END IF;
31
+
32
+    RETURN
33
+        setweight(to_tsvector('shithub_search', coalesce(repo_name::text, '')), 'A') ||
34
+        setweight(to_tsvector('shithub_search', coalesce(owner_login, '')), 'A') ||
35
+        setweight(to_tsvector('shithub_search', coalesce(repo_description, '')), 'B') ||
36
+        setweight(to_tsvector('shithub_search', coalesce(owner_display, '')), 'C');
37
+END;
38
+$$;
39
+-- +goose StatementEnd
40
+
41
+-- +goose StatementBegin
42
+CREATE OR REPLACE FUNCTION tg_repos_search_upsert() RETURNS trigger
43
+    LANGUAGE plpgsql AS $$
44
+BEGIN
45
+    INSERT INTO repos_search (repo_id, tsv) VALUES (
46
+        NEW.id,
47
+        repos_search_tsv(NEW.name, NEW.description, NEW.owner_user_id, NEW.owner_org_id)
48
+    )
49
+    ON CONFLICT (repo_id) DO UPDATE
50
+        SET tsv = EXCLUDED.tsv;
51
+    RETURN NEW;
52
+END;
53
+$$;
54
+-- +goose StatementEnd
55
+
56
+DROP TRIGGER IF EXISTS repos_search_upsert ON repos;
57
+CREATE TRIGGER repos_search_upsert
58
+    AFTER INSERT OR UPDATE OF name, description, owner_user_id, owner_org_id ON repos
59
+    FOR EACH ROW EXECUTE FUNCTION tg_repos_search_upsert();
60
+
61
+-- +goose StatementBegin
62
+CREATE OR REPLACE FUNCTION tg_repos_search_user_owner_update() RETURNS trigger
63
+    LANGUAGE plpgsql AS $$
64
+BEGIN
65
+    INSERT INTO repos_search (repo_id, tsv)
66
+    SELECT r.id, repos_search_tsv(r.name, r.description, r.owner_user_id, r.owner_org_id)
67
+      FROM repos r
68
+     WHERE r.owner_user_id = NEW.id
69
+    ON CONFLICT (repo_id) DO UPDATE
70
+        SET tsv = EXCLUDED.tsv;
71
+    RETURN NEW;
72
+END;
73
+$$;
74
+-- +goose StatementEnd
75
+
76
+CREATE TRIGGER repos_search_user_owner_update
77
+    AFTER UPDATE OF username, display_name ON users
78
+    FOR EACH ROW
79
+    WHEN (OLD.username IS DISTINCT FROM NEW.username OR OLD.display_name IS DISTINCT FROM NEW.display_name)
80
+    EXECUTE FUNCTION tg_repos_search_user_owner_update();
81
+
82
+-- +goose StatementBegin
83
+CREATE OR REPLACE FUNCTION tg_repos_search_org_owner_update() RETURNS trigger
84
+    LANGUAGE plpgsql AS $$
85
+BEGIN
86
+    INSERT INTO repos_search (repo_id, tsv)
87
+    SELECT r.id, repos_search_tsv(r.name, r.description, r.owner_user_id, r.owner_org_id)
88
+      FROM repos r
89
+     WHERE r.owner_org_id = NEW.id
90
+    ON CONFLICT (repo_id) DO UPDATE
91
+        SET tsv = EXCLUDED.tsv;
92
+    RETURN NEW;
93
+END;
94
+$$;
95
+-- +goose StatementEnd
96
+
97
+CREATE TRIGGER repos_search_org_owner_update
98
+    AFTER UPDATE OF slug, display_name ON orgs
99
+    FOR EACH ROW
100
+    WHEN (OLD.slug IS DISTINCT FROM NEW.slug OR OLD.display_name IS DISTINCT FROM NEW.display_name)
101
+    EXECUTE FUNCTION tg_repos_search_org_owner_update();
102
+
103
+INSERT INTO repos_search (repo_id, tsv)
104
+SELECT r.id, repos_search_tsv(r.name, r.description, r.owner_user_id, r.owner_org_id)
105
+  FROM repos r
106
+ON CONFLICT (repo_id) DO UPDATE
107
+    SET tsv = EXCLUDED.tsv;
108
+
109
+-- +goose Down
110
+
111
+DROP TRIGGER IF EXISTS repos_search_org_owner_update ON orgs;
112
+DROP FUNCTION IF EXISTS tg_repos_search_org_owner_update();
113
+DROP TRIGGER IF EXISTS repos_search_user_owner_update ON users;
114
+DROP FUNCTION IF EXISTS tg_repos_search_user_owner_update();
115
+
116
+DROP TRIGGER IF EXISTS repos_search_upsert ON repos;
117
+DROP FUNCTION IF EXISTS tg_repos_search_upsert();
118
+DROP FUNCTION IF EXISTS repos_search_tsv(citext, text, bigint, bigint);
119
+
120
+-- +goose StatementBegin
121
+CREATE OR REPLACE FUNCTION tg_repos_search_upsert() RETURNS trigger
122
+    LANGUAGE plpgsql AS $$
123
+BEGIN
124
+    INSERT INTO repos_search (repo_id, tsv) VALUES (
125
+        NEW.id,
126
+        setweight(to_tsvector('shithub_search', coalesce(NEW.name::text, '')), 'A') ||
127
+        setweight(to_tsvector('shithub_search', coalesce(NEW.description, '')), 'B')
128
+    )
129
+    ON CONFLICT (repo_id) DO UPDATE
130
+        SET tsv = EXCLUDED.tsv;
131
+    RETURN NEW;
132
+END;
133
+$$;
134
+-- +goose StatementEnd
135
+
136
+CREATE TRIGGER repos_search_upsert
137
+    AFTER INSERT OR UPDATE OF name, description ON repos
138
+    FOR EACH ROW EXECUTE FUNCTION tg_repos_search_upsert();
139
+
140
+INSERT INTO repos_search (repo_id, tsv)
141
+SELECT id,
142
+       setweight(to_tsvector('shithub_search', coalesce(name::text, '')), 'A') ||
143
+       setweight(to_tsvector('shithub_search', coalesce(description, '')), 'B')
144
+  FROM repos
145
+ON CONFLICT (repo_id) DO UPDATE
146
+    SET tsv = EXCLUDED.tsv;
internal/search/code.gomodified
@@ -43,11 +43,7 @@ func SearchCode(ctx context.Context, deps Deps, actor policy.Actor, q ParsedQuer
43
 		ownerPos := len(args) + 1
43
 		ownerPos := len(args) + 1
44
 		namePos := len(args) + 2
44
 		namePos := len(args) + 2
45
 		args = append(args, q.RepoFilter.Owner, q.RepoFilter.Name)
45
 		args = append(args, q.RepoFilter.Owner, q.RepoFilter.Name)
46
-		repoFilter = fmt.Sprintf(
46
+		repoFilter = repoFilterByOwnerName("r", ownerPos, namePos)
47
-			" AND r.id = (SELECT r2.id FROM repos r2 JOIN users u2 ON u2.id = r2.owner_user_id "+
48
-				"WHERE u2.username = $%d AND r2.name = $%d AND r2.deleted_at IS NULL)",
49
-			ownerPos, namePos,
50
-		)
51
 	}
47
 	}
52
 
48
 
53
 	limPos := len(args) + 1
49
 	limPos := len(args) + 1
@@ -82,13 +78,13 @@ func SearchCode(ctx context.Context, deps Deps, actor policy.Actor, q ParsedQuer
82
 		    UNION ALL
78
 		    UNION ALL
83
 		    SELECT * FROM content_hits
79
 		    SELECT * FROM content_hits
84
 		)
80
 		)
85
-		SELECT h.repo_id, u.username, r.name, h.ref_name, h.path, h.preview, h.rank
81
+		SELECT h.repo_id, %[6]s, r.name, h.ref_name, h.path, h.preview, h.rank
86
 		FROM all_hits h
82
 		FROM all_hits h
87
 		JOIN repos r ON r.id = h.repo_id
83
 		JOIN repos r ON r.id = h.repo_id
88
-		JOIN users u ON u.id = r.owner_user_id
84
+		%[7]s
89
 		ORDER BY h.rank DESC, h.path
85
 		ORDER BY h.rank DESC, h.path
90
 		LIMIT $%[4]d OFFSET $%[5]d
86
 		LIMIT $%[4]d OFFSET $%[5]d
91
-	`, tsCtor, visClause, repoFilter, limPos, offPos)
87
+	`, tsCtor, visClause, repoFilter, limPos, offPos, repoOwnerNameExpr("u", "o"), repoOwnerJoin("r", "u", "o"))
92
 
88
 
93
 	rows, err := deps.Pool.Query(ctx, queryStr, args...)
89
 	rows, err := deps.Pool.Query(ctx, queryStr, args...)
94
 	if err != nil {
90
 	if err != nil {
internal/search/issues.gomodified
@@ -46,11 +46,7 @@ func SearchIssues(ctx context.Context, deps Deps, actor policy.Actor, q ParsedQu
46
 		ownerPos := len(args) + 1
46
 		ownerPos := len(args) + 1
47
 		namePos := len(args) + 2
47
 		namePos := len(args) + 2
48
 		args = append(args, q.RepoFilter.Owner, q.RepoFilter.Name)
48
 		args = append(args, q.RepoFilter.Owner, q.RepoFilter.Name)
49
-		whereExtras += fmt.Sprintf(
49
+		whereExtras += repoFilterByOwnerName("r", ownerPos, namePos)
50
-			" AND r.id = (SELECT r2.id FROM repos r2 JOIN users u2 ON u2.id = r2.owner_user_id "+
51
-				"WHERE u2.username = $%d AND r2.name = $%d AND r2.deleted_at IS NULL)",
52
-			ownerPos, namePos,
53
-		)
54
 	}
50
 	}
55
 	if q.StateFilter != "" {
51
 	if q.StateFilter != "" {
56
 		statePos := len(args) + 1
52
 		statePos := len(args) + 1
@@ -83,7 +79,7 @@ func SearchIssues(ctx context.Context, deps Deps, actor policy.Actor, q ParsedQu
83
 	args = append(args, limit, offset)
79
 	args = append(args, limit, offset)
84
 
80
 
85
 	queryStr := fmt.Sprintf(`
81
 	queryStr := fmt.Sprintf(`
86
-		SELECT i.id, r.id, u.username, r.name, i.number, i.title,
82
+		SELECT i.id, r.id, %[7]s, r.name, i.number, i.title,
87
 		       i.state::text, i.kind::text,
83
 		       i.state::text, i.kind::text,
88
 		       coalesce(au.username, '') AS author_name,
84
 		       coalesce(au.username, '') AS author_name,
89
 		       i.updated_at,
85
 		       i.updated_at,
@@ -91,14 +87,14 @@ func SearchIssues(ctx context.Context, deps Deps, actor policy.Actor, q ParsedQu
91
 		FROM issues_search s
87
 		FROM issues_search s
92
 		JOIN issues i  ON i.id = s.issue_id
88
 		JOIN issues i  ON i.id = s.issue_id
93
 		JOIN repos r   ON r.id = s.repo_id
89
 		JOIN repos r   ON r.id = s.repo_id
94
-		JOIN users u   ON u.id = r.owner_user_id
90
+		%[8]s
95
 		LEFT JOIN users au ON au.id = s.author_user_id
91
 		LEFT JOIN users au ON au.id = s.author_user_id
96
 		WHERE %[2]s
92
 		WHERE %[2]s
97
 		  AND %[3]s
93
 		  AND %[3]s
98
 		  %[4]s
94
 		  %[4]s
99
 		ORDER BY rank DESC, i.updated_at DESC
95
 		ORDER BY rank DESC, i.updated_at DESC
100
 		LIMIT $%[5]d OFFSET $%[6]d
96
 		LIMIT $%[5]d OFFSET $%[6]d
101
-	`, rankExpr, whereFTS, visClause, whereExtras, limPos, offPos)
97
+	`, rankExpr, whereFTS, visClause, whereExtras, limPos, offPos, repoOwnerNameExpr("u", "o"), repoOwnerJoin("r", "u", "o"))
102
 
98
 
103
 	rows, err := deps.Pool.Query(ctx, queryStr, args...)
99
 	rows, err := deps.Pool.Query(ctx, queryStr, args...)
104
 	if err != nil {
100
 	if err != nil {
@@ -124,7 +120,6 @@ func SearchIssues(ctx context.Context, deps Deps, actor policy.Actor, q ParsedQu
124
 		FROM issues_search s
120
 		FROM issues_search s
125
 		JOIN issues i  ON i.id = s.issue_id
121
 		JOIN issues i  ON i.id = s.issue_id
126
 		JOIN repos r   ON r.id = s.repo_id
122
 		JOIN repos r   ON r.id = s.repo_id
127
-		JOIN users u   ON u.id = r.owner_user_id
128
 		WHERE %[1]s AND %[2]s %[3]s
123
 		WHERE %[1]s AND %[2]s %[3]s
129
 	`, whereFTS, visClause, whereExtras)
124
 	`, whereFTS, visClause, whereExtras)
130
 	var total int64
125
 	var total int64
internal/search/repo_owner_sql.goadded
@@ -0,0 +1,28 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package search
4
+
5
+import "fmt"
6
+
7
+func repoOwnerJoin(repoAlias, userAlias, orgAlias string) string {
8
+	return fmt.Sprintf(
9
+		"LEFT JOIN users %s ON %s.id = %s.owner_user_id LEFT JOIN orgs %s ON %s.id = %s.owner_org_id",
10
+		userAlias, userAlias, repoAlias, orgAlias, orgAlias, repoAlias,
11
+	)
12
+}
13
+
14
+func repoOwnerNameExpr(userAlias, orgAlias string) string {
15
+	return fmt.Sprintf("coalesce(%s.username, %s.slug)", userAlias, orgAlias)
16
+}
17
+
18
+func repoFilterByOwnerName(repoAlias string, ownerPos, namePos int) string {
19
+	return fmt.Sprintf(
20
+		" AND %s.id = (SELECT r2.id FROM repos r2 %s "+
21
+			"WHERE %s = $%d AND r2.name = $%d AND r2.deleted_at IS NULL)",
22
+		repoAlias,
23
+		repoOwnerJoin("r2", "u2", "o2"),
24
+		repoOwnerNameExpr("u2", "o2"),
25
+		ownerPos,
26
+		namePos,
27
+	)
28
+}
internal/search/repos.gomodified
@@ -44,11 +44,7 @@ func SearchRepos(ctx context.Context, deps Deps, actor policy.Actor, q ParsedQue
44
 		ownerPos := len(args) + 1
44
 		ownerPos := len(args) + 1
45
 		namePos := len(args) + 2
45
 		namePos := len(args) + 2
46
 		args = append(args, q.RepoFilter.Owner, q.RepoFilter.Name)
46
 		args = append(args, q.RepoFilter.Owner, q.RepoFilter.Name)
47
-		repoFilter = fmt.Sprintf(
47
+		repoFilter = repoFilterByOwnerName("r", ownerPos, namePos)
48
-			" AND r.id = (SELECT r2.id FROM repos r2 JOIN users u2 ON u2.id = r2.owner_user_id "+
49
-				"WHERE u2.username = $%d AND r2.name = $%d AND r2.deleted_at IS NULL)",
50
-			ownerPos, namePos,
51
-		)
52
 	}
48
 	}
53
 
49
 
54
 	whereFTS := "TRUE"
50
 	whereFTS := "TRUE"
@@ -63,7 +59,7 @@ func SearchRepos(ctx context.Context, deps Deps, actor policy.Actor, q ParsedQue
63
 	args = append(args, limit, offset)
59
 	args = append(args, limit, offset)
64
 
60
 
65
 	queryStr := fmt.Sprintf(`
61
 	queryStr := fmt.Sprintf(`
66
-		SELECT r.id, u.username, r.name, r.description, r.visibility::text,
62
+		SELECT r.id, %[7]s, r.name, r.description, r.visibility::text,
67
 		       r.star_count, r.updated_at,
63
 		       r.star_count, r.updated_at,
68
 		       %[1]s
64
 		       %[1]s
69
 		           * (1.0 + ln(1.0 + r.star_count))
65
 		           * (1.0 + ln(1.0 + r.star_count))
@@ -71,13 +67,13 @@ func SearchRepos(ctx context.Context, deps Deps, actor policy.Actor, q ParsedQue
71
 		       AS rank
67
 		       AS rank
72
 		FROM repos_search rs
68
 		FROM repos_search rs
73
 		JOIN repos r  ON r.id = rs.repo_id
69
 		JOIN repos r  ON r.id = rs.repo_id
74
-		JOIN users u  ON u.id = r.owner_user_id
70
+		%[8]s
75
 		WHERE %[2]s
71
 		WHERE %[2]s
76
 		  AND %[3]s
72
 		  AND %[3]s
77
 		  %[4]s
73
 		  %[4]s
78
 		ORDER BY rank DESC, r.updated_at DESC
74
 		ORDER BY rank DESC, r.updated_at DESC
79
 		LIMIT $%[5]d OFFSET $%[6]d
75
 		LIMIT $%[5]d OFFSET $%[6]d
80
-	`, rankExpr, whereFTS, visClause, repoFilter, limPos, offPos)
76
+	`, rankExpr, whereFTS, visClause, repoFilter, limPos, offPos, repoOwnerNameExpr("u", "o"), repoOwnerJoin("r", "u", "o"))
81
 
77
 
82
 	rows, err := deps.Pool.Query(ctx, queryStr, args...)
78
 	rows, err := deps.Pool.Query(ctx, queryStr, args...)
83
 	if err != nil {
79
 	if err != nil {
@@ -103,7 +99,6 @@ func SearchRepos(ctx context.Context, deps Deps, actor policy.Actor, q ParsedQue
103
 		SELECT count(*)
99
 		SELECT count(*)
104
 		FROM repos_search rs
100
 		FROM repos_search rs
105
 		JOIN repos r  ON r.id = rs.repo_id
101
 		JOIN repos r  ON r.id = rs.repo_id
106
-		JOIN users u  ON u.id = r.owner_user_id
107
 		WHERE %[1]s AND %[2]s %[3]s
102
 		WHERE %[1]s AND %[2]s %[3]s
108
 	`, whereFTS, visClause, repoFilter)
103
 	`, whereFTS, visClause, repoFilter)
109
 	var total int64
104
 	var total int64
internal/search/search_test.gomodified
@@ -16,6 +16,7 @@ import (
16
 	policydb "github.com/tenseleyFlow/shithub/internal/auth/policy/sqlc"
16
 	policydb "github.com/tenseleyFlow/shithub/internal/auth/policy/sqlc"
17
 	"github.com/tenseleyFlow/shithub/internal/issues"
17
 	"github.com/tenseleyFlow/shithub/internal/issues"
18
 	issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
18
 	issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
19
+	"github.com/tenseleyFlow/shithub/internal/orgs"
19
 	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
20
 	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
20
 	"github.com/tenseleyFlow/shithub/internal/search"
21
 	"github.com/tenseleyFlow/shithub/internal/search"
21
 	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
22
 	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
@@ -83,6 +84,7 @@ type fxs struct {
83
 	bob     usersdb.User
84
 	bob     usersdb.User
84
 	pubRepo reposdb.Repo
85
 	pubRepo reposdb.Repo
85
 	prvRepo reposdb.Repo
86
 	prvRepo reposdb.Repo
87
+	orgRepo reposdb.Repo
86
 }
88
 }
87
 
89
 
88
 func setup(t *testing.T) fxs {
90
 func setup(t *testing.T) fxs {
@@ -104,6 +106,20 @@ func setup(t *testing.T) fxs {
104
 		t.Fatalf("CreateUser bob: %v", err)
106
 		t.Fatalf("CreateUser bob: %v", err)
105
 	}
107
 	}
106
 
108
 
109
+	org, err := orgs.Create(ctx,
110
+		orgs.Deps{Pool: pool, Logger: slog.New(slog.NewTextHandler(io.Discard, nil))},
111
+		orgs.CreateParams{
112
+			Slug:            "tenseleyflow",
113
+			DisplayName:     "tenseleyFlow",
114
+			Description:     "workflow things",
115
+			BillingEmail:    "org@example.test",
116
+			CreatedByUserID: alice.ID,
117
+		},
118
+	)
119
+	if err != nil {
120
+		t.Fatalf("CreateOrg tenseleyflow: %v", err)
121
+	}
122
+
107
 	rq := reposdb.New()
123
 	rq := reposdb.New()
108
 	pubRepo, err := rq.CreateRepo(ctx, pool, reposdb.CreateRepoParams{
124
 	pubRepo, err := rq.CreateRepo(ctx, pool, reposdb.CreateRepoParams{
109
 		OwnerUserID:   pgtype.Int8{Int64: alice.ID, Valid: true},
125
 		OwnerUserID:   pgtype.Int8{Int64: alice.ID, Valid: true},
@@ -125,9 +141,19 @@ func setup(t *testing.T) fxs {
125
 	if err != nil {
141
 	if err != nil {
126
 		t.Fatalf("CreateRepo private: %v", err)
142
 		t.Fatalf("CreateRepo private: %v", err)
127
 	}
143
 	}
144
+	orgRepo, err := rq.CreateRepo(ctx, pool, reposdb.CreateRepoParams{
145
+		OwnerOrgID:    pgtype.Int8{Int64: org.ID, Valid: true},
146
+		Name:          "shithub",
147
+		Description:   "A 1:1 reverse-engineering of GitHub. AGPLv3. Without Copilot.",
148
+		DefaultBranch: "trunk",
149
+		Visibility:    reposdb.RepoVisibilityPublic,
150
+	})
151
+	if err != nil {
152
+		t.Fatalf("CreateRepo org public: %v", err)
153
+	}
128
 
154
 
129
 	iq := issuesdb.New()
155
 	iq := issuesdb.New()
130
-	for _, r := range []reposdb.Repo{pubRepo, prvRepo} {
156
+	for _, r := range []reposdb.Repo{pubRepo, prvRepo, orgRepo} {
131
 		if err := iq.EnsureRepoIssueCounter(ctx, pool, r.ID); err != nil {
157
 		if err := iq.EnsureRepoIssueCounter(ctx, pool, r.ID); err != nil {
132
 			t.Fatalf("EnsureRepoIssueCounter: %v", err)
158
 			t.Fatalf("EnsureRepoIssueCounter: %v", err)
133
 		}
159
 		}
@@ -145,13 +171,25 @@ func setup(t *testing.T) fxs {
145
 	}); err != nil {
171
 	}); err != nil {
146
 		t.Fatalf("Create issue prv: %v", err)
172
 		t.Fatalf("Create issue prv: %v", err)
147
 	}
173
 	}
174
+	if _, err := issues.Create(ctx, idep, issues.CreateParams{
175
+		RepoID: orgRepo.ID, AuthorUserID: alice.ID,
176
+		Title: "org public bug report", Body: "shithub project issue",
177
+	}); err != nil {
178
+		t.Fatalf("Create issue org: %v", err)
179
+	}
180
+	if _, err := pool.Exec(ctx, `
181
+		INSERT INTO code_search_paths (repo_id, ref_name, path, tsv)
182
+		VALUES ($1, 'trunk', 'README.md', to_tsvector('shithub_search', 'README shithub'))
183
+	`, orgRepo.ID); err != nil {
184
+		t.Fatalf("seed org code path: %v", err)
185
+	}
148
 
186
 
149
 	return fxs{
187
 	return fxs{
150
 		deps: search.Deps{
188
 		deps: search.Deps{
151
 			Pool:   pool,
189
 			Pool:   pool,
152
 			Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
190
 			Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
153
 		},
191
 		},
154
-		alice: alice, bob: bob, pubRepo: pubRepo, prvRepo: prvRepo,
192
+		alice: alice, bob: bob, pubRepo: pubRepo, prvRepo: prvRepo, orgRepo: orgRepo,
155
 	}
193
 	}
156
 }
194
 }
157
 
195
 
@@ -235,6 +273,46 @@ func TestSearchRepos_CollabSeesPrivate(t *testing.T) {
235
 	}
273
 	}
236
 }
274
 }
237
 
275
 
276
+func TestSearchRepos_AnonymousFindsPublicOrgRepoByName(t *testing.T) {
277
+	f := setup(t)
278
+	got, total, err := search.SearchRepos(context.Background(), f.deps,
279
+		policy.AnonymousActor(),
280
+		search.ParseQuery("shithub"),
281
+		20, 0)
282
+	if err != nil {
283
+		t.Fatalf("SearchRepos: %v", err)
284
+	}
285
+	if total == 0 {
286
+		t.Fatalf("SearchRepos total = 0, want org-owned shithub")
287
+	}
288
+	found := false
289
+	for _, r := range got {
290
+		if r.ID == f.orgRepo.ID && r.OwnerUsername == "tenseleyflow" && r.Name == "shithub" {
291
+			found = true
292
+		}
293
+	}
294
+	if !found {
295
+		t.Fatalf("org-owned tenseleyflow/shithub missing from %d repo results", len(got))
296
+	}
297
+}
298
+
299
+func TestSearchRepos_AnonymousFindsPublicOrgRepoByOwner(t *testing.T) {
300
+	f := setup(t)
301
+	got, _, err := search.SearchRepos(context.Background(), f.deps,
302
+		policy.AnonymousActor(),
303
+		search.ParseQuery("tenseleyFlow"),
304
+		20, 0)
305
+	if err != nil {
306
+		t.Fatalf("SearchRepos: %v", err)
307
+	}
308
+	for _, r := range got {
309
+		if r.ID == f.orgRepo.ID && r.OwnerUsername == "tenseleyflow" && r.Name == "shithub" {
310
+			return
311
+		}
312
+	}
313
+	t.Fatalf("owner query did not return org-owned tenseleyflow/shithub; got %d rows", len(got))
314
+}
315
+
238
 // TestSearchIssues_AnonymousSeesOnlyPublic mirrors the repo test
316
 // TestSearchIssues_AnonymousSeesOnlyPublic mirrors the repo test
239
 // for the issue surface — issues inherit visibility from their repo.
317
 // for the issue surface — issues inherit visibility from their repo.
240
 func TestSearchIssues_AnonymousSeesOnlyPublic(t *testing.T) {
318
 func TestSearchIssues_AnonymousSeesOnlyPublic(t *testing.T) {
@@ -297,6 +375,43 @@ func TestSearchIssues_RepoFilter(t *testing.T) {
297
 	}
375
 	}
298
 }
376
 }
299
 
377
 
378
+func TestSearchIssues_RepoFilterMatchesOrgOwner(t *testing.T) {
379
+	f := setup(t)
380
+	got, _, err := search.SearchIssues(context.Background(), f.deps,
381
+		policy.AnonymousActor(),
382
+		search.ParseQuery("repo:tenseleyFlow/shithub bug"), "", 20, 0)
383
+	if err != nil {
384
+		t.Fatalf("SearchIssues: %v", err)
385
+	}
386
+	if len(got) == 0 {
387
+		t.Fatalf("expected org repo issue results")
388
+	}
389
+	for _, h := range got {
390
+		if h.OwnerUsername != "tenseleyflow" || h.RepoName != "shithub" {
391
+			t.Errorf("repo: filter let through %s/%s", h.OwnerUsername, h.RepoName)
392
+		}
393
+	}
394
+}
395
+
396
+func TestSearchCode_RepoFilterMatchesOrgOwner(t *testing.T) {
397
+	f := setup(t)
398
+	got, total, err := search.SearchCode(context.Background(), f.deps,
399
+		policy.AnonymousActor(),
400
+		search.ParseQuery("repo:tenseleyFlow/shithub README"), 20, 0)
401
+	if err != nil {
402
+		t.Fatalf("SearchCode: %v", err)
403
+	}
404
+	if total == 0 {
405
+		t.Fatalf("SearchCode total = 0, want org-owned path hit")
406
+	}
407
+	for _, h := range got {
408
+		if h.RepoID == f.orgRepo.ID && h.OwnerUsername == "tenseleyflow" && h.RepoName == "shithub" {
409
+			return
410
+		}
411
+	}
412
+	t.Fatalf("org-owned code hit missing from %d results", len(got))
413
+}
414
+
300
 func TestSearchUsers_ExcludesSuspended(t *testing.T) {
415
 func TestSearchUsers_ExcludesSuspended(t *testing.T) {
301
 	f := setup(t)
416
 	f := setup(t)
302
 	ctx := context.Background()
417
 	ctx := context.Background()