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/
4848
   trigram index on the raw content for camelCase / snake_case
4949
   substring matches the FTS tokenizer mangles. `repos.last_indexed_oid`
5050
   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.
5156
 
5257
 ## Visibility predicate
5358
 
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
4343
 		ownerPos := len(args) + 1
4444
 		namePos := len(args) + 2
4545
 		args = append(args, q.RepoFilter.Owner, q.RepoFilter.Name)
46
-		repoFilter = fmt.Sprintf(
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
-		)
46
+		repoFilter = repoFilterByOwnerName("r", ownerPos, namePos)
5147
 	}
5248
 
5349
 	limPos := len(args) + 1
@@ -82,13 +78,13 @@ func SearchCode(ctx context.Context, deps Deps, actor policy.Actor, q ParsedQuer
8278
 		    UNION ALL
8379
 		    SELECT * FROM content_hits
8480
 		)
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
8682
 		FROM all_hits h
8783
 		JOIN repos r ON r.id = h.repo_id
88
-		JOIN users u ON u.id = r.owner_user_id
84
+		%[7]s
8985
 		ORDER BY h.rank DESC, h.path
9086
 		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"))
9288
 
9389
 	rows, err := deps.Pool.Query(ctx, queryStr, args...)
9490
 	if err != nil {
internal/search/issues.gomodified
@@ -46,11 +46,7 @@ func SearchIssues(ctx context.Context, deps Deps, actor policy.Actor, q ParsedQu
4646
 		ownerPos := len(args) + 1
4747
 		namePos := len(args) + 2
4848
 		args = append(args, q.RepoFilter.Owner, q.RepoFilter.Name)
49
-		whereExtras += fmt.Sprintf(
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
-		)
49
+		whereExtras += repoFilterByOwnerName("r", ownerPos, namePos)
5450
 	}
5551
 	if q.StateFilter != "" {
5652
 		statePos := len(args) + 1
@@ -83,7 +79,7 @@ func SearchIssues(ctx context.Context, deps Deps, actor policy.Actor, q ParsedQu
8379
 	args = append(args, limit, offset)
8480
 
8581
 	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,
8783
 		       i.state::text, i.kind::text,
8884
 		       coalesce(au.username, '') AS author_name,
8985
 		       i.updated_at,
@@ -91,14 +87,14 @@ func SearchIssues(ctx context.Context, deps Deps, actor policy.Actor, q ParsedQu
9187
 		FROM issues_search s
9288
 		JOIN issues i  ON i.id = s.issue_id
9389
 		JOIN repos r   ON r.id = s.repo_id
94
-		JOIN users u   ON u.id = r.owner_user_id
90
+		%[8]s
9591
 		LEFT JOIN users au ON au.id = s.author_user_id
9692
 		WHERE %[2]s
9793
 		  AND %[3]s
9894
 		  %[4]s
9995
 		ORDER BY rank DESC, i.updated_at DESC
10096
 		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"))
10298
 
10399
 	rows, err := deps.Pool.Query(ctx, queryStr, args...)
104100
 	if err != nil {
@@ -124,7 +120,6 @@ func SearchIssues(ctx context.Context, deps Deps, actor policy.Actor, q ParsedQu
124120
 		FROM issues_search s
125121
 		JOIN issues i  ON i.id = s.issue_id
126122
 		JOIN repos r   ON r.id = s.repo_id
127
-		JOIN users u   ON u.id = r.owner_user_id
128123
 		WHERE %[1]s AND %[2]s %[3]s
129124
 	`, whereFTS, visClause, whereExtras)
130125
 	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
4444
 		ownerPos := len(args) + 1
4545
 		namePos := len(args) + 2
4646
 		args = append(args, q.RepoFilter.Owner, q.RepoFilter.Name)
47
-		repoFilter = fmt.Sprintf(
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
-		)
47
+		repoFilter = repoFilterByOwnerName("r", ownerPos, namePos)
5248
 	}
5349
 
5450
 	whereFTS := "TRUE"
@@ -63,7 +59,7 @@ func SearchRepos(ctx context.Context, deps Deps, actor policy.Actor, q ParsedQue
6359
 	args = append(args, limit, offset)
6460
 
6561
 	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,
6763
 		       r.star_count, r.updated_at,
6864
 		       %[1]s
6965
 		           * (1.0 + ln(1.0 + r.star_count))
@@ -71,13 +67,13 @@ func SearchRepos(ctx context.Context, deps Deps, actor policy.Actor, q ParsedQue
7167
 		       AS rank
7268
 		FROM repos_search rs
7369
 		JOIN repos r  ON r.id = rs.repo_id
74
-		JOIN users u  ON u.id = r.owner_user_id
70
+		%[8]s
7571
 		WHERE %[2]s
7672
 		  AND %[3]s
7773
 		  %[4]s
7874
 		ORDER BY rank DESC, r.updated_at DESC
7975
 		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"))
8177
 
8278
 	rows, err := deps.Pool.Query(ctx, queryStr, args...)
8379
 	if err != nil {
@@ -103,7 +99,6 @@ func SearchRepos(ctx context.Context, deps Deps, actor policy.Actor, q ParsedQue
10399
 		SELECT count(*)
104100
 		FROM repos_search rs
105101
 		JOIN repos r  ON r.id = rs.repo_id
106
-		JOIN users u  ON u.id = r.owner_user_id
107102
 		WHERE %[1]s AND %[2]s %[3]s
108103
 	`, whereFTS, visClause, repoFilter)
109104
 	var total int64
internal/search/search_test.gomodified
@@ -16,6 +16,7 @@ import (
1616
 	policydb "github.com/tenseleyFlow/shithub/internal/auth/policy/sqlc"
1717
 	"github.com/tenseleyFlow/shithub/internal/issues"
1818
 	issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
19
+	"github.com/tenseleyFlow/shithub/internal/orgs"
1920
 	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
2021
 	"github.com/tenseleyFlow/shithub/internal/search"
2122
 	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
@@ -83,6 +84,7 @@ type fxs struct {
8384
 	bob     usersdb.User
8485
 	pubRepo reposdb.Repo
8586
 	prvRepo reposdb.Repo
87
+	orgRepo reposdb.Repo
8688
 }
8789
 
8890
 func setup(t *testing.T) fxs {
@@ -104,6 +106,20 @@ func setup(t *testing.T) fxs {
104106
 		t.Fatalf("CreateUser bob: %v", err)
105107
 	}
106108
 
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
+
107123
 	rq := reposdb.New()
108124
 	pubRepo, err := rq.CreateRepo(ctx, pool, reposdb.CreateRepoParams{
109125
 		OwnerUserID:   pgtype.Int8{Int64: alice.ID, Valid: true},
@@ -125,9 +141,19 @@ func setup(t *testing.T) fxs {
125141
 	if err != nil {
126142
 		t.Fatalf("CreateRepo private: %v", err)
127143
 	}
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
+	}
128154
 
129155
 	iq := issuesdb.New()
130
-	for _, r := range []reposdb.Repo{pubRepo, prvRepo} {
156
+	for _, r := range []reposdb.Repo{pubRepo, prvRepo, orgRepo} {
131157
 		if err := iq.EnsureRepoIssueCounter(ctx, pool, r.ID); err != nil {
132158
 			t.Fatalf("EnsureRepoIssueCounter: %v", err)
133159
 		}
@@ -145,13 +171,25 @@ func setup(t *testing.T) fxs {
145171
 	}); err != nil {
146172
 		t.Fatalf("Create issue prv: %v", err)
147173
 	}
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
+	}
148186
 
149187
 	return fxs{
150188
 		deps: search.Deps{
151189
 			Pool:   pool,
152190
 			Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
153191
 		},
154
-		alice: alice, bob: bob, pubRepo: pubRepo, prvRepo: prvRepo,
192
+		alice: alice, bob: bob, pubRepo: pubRepo, prvRepo: prvRepo, orgRepo: orgRepo,
155193
 	}
156194
 }
157195
 
@@ -235,6 +273,46 @@ func TestSearchRepos_CollabSeesPrivate(t *testing.T) {
235273
 	}
236274
 }
237275
 
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
+
238316
 // TestSearchIssues_AnonymousSeesOnlyPublic mirrors the repo test
239317
 // for the issue surface — issues inherit visibility from their repo.
240318
 func TestSearchIssues_AnonymousSeesOnlyPublic(t *testing.T) {
@@ -297,6 +375,43 @@ func TestSearchIssues_RepoFilter(t *testing.T) {
297375
 	}
298376
 }
299377
 
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
+
300415
 func TestSearchUsers_ExcludesSuspended(t *testing.T) {
301416
 	f := setup(t)
302417
 	ctx := context.Background()