Filter AI review suggestions by read path scope

swadhinbiswas committed 8 months ago
f41974f
1 parent 08b9d18
commit f41974f
4 files changed
+121 -2
▼doc/feature_audit.md
+1-1
36
36
| Branch protection rules | ✅ | Schema + UI + API in place |
37
37
| Repository templates | ⚠️ | Template creation and listing are implemented with access-aware discovery filters (`/repos/templates?q=&owner=&visibility=`) including collaborator-visible private templates; dedicated template governance UX remains partial |
38
38
| Repository Wiki | ✅ | Implemented (Phase 8) |
39
-| File-level permissions | ⚠️ | Path-scoped enforcement now covers PR comment create/edit/delete/list filtering, PR review list comment filtering, single-review inline comments, batch-review comment submission, suggestion-apply, partial file-approval create/revoke and read-filter flows, PR rewrite operations, PR impact scan execution checks, and PR impact read-result file-path filtering; broader endpoint coverage still remains |
39
+| File-level permissions | ⚠️ | Path-scoped enforcement now covers PR comment create/edit/delete/list filtering, PR review list comment filtering, AI review suggestion read filtering, single-review inline comments, batch-review comment submission, suggestion-apply, partial file-approval create/revoke and read-filter flows, PR rewrite operations, PR impact scan execution checks, and PR impact read-result file-path filtering; broader endpoint coverage still remains |
40
40
| Commit signing (GPG) | ✅ | GPG key management UI exists |
41
41
| Commit signing (SSH) | ✅ | SSH key management UI exists |
42
42
43
43
▼src/lib/openapi.ts
+1-0
847
847
get: {
848
848
tags: ["Pull Requests"],
849
849
summary: "Get latest AI review for pull request",
850
+ description: "Returns latest AI review suggestions filtered by path-scoped read permissions, including `hiddenSuggestions` metadata.",
850
851
security: [{ bearerAuth: [] }],
851
852
parameters: [
852
853
{ name: "owner", in: "path", required: true, schema: { type: "string" } },
853
854
▼src/pages/api/repos/[owner]/[repo]/pulls/[number]/ai-review.ts
+14-1
11
11
import { logger } from "@/lib/logger";
12
12
import { parseAIConfigFromStorage } from "@/lib/ai-config";
13
13
import { canReadRepo } from "@/lib/permissions";
14
+import { checkPathPermissions } from "@/lib/path-scoping";
14
15
15
16
export const GET: APIRoute = withErrorHandler(async ({ params, locals }) => {
16
17
const { owner, repo: repoName, number } = params;
60
61
where: eq(schema.aiReviewSuggestions.aiReviewId, review.id),
61
62
});
62
63
63
- return success({ review, suggestions });
64
+ const scopedPaths = Array.from(new Set(suggestions.map((item) => item.path).filter(Boolean)));
65
+ const deniedPaths = new Set<string>();
66
+ if (scopedPaths.length > 0) {
67
+ const permission = await checkPathPermissions(user?.id || "__anonymous__", repository.id, scopedPaths, "read");
68
+ for (const path of permission.deniedPaths || []) deniedPaths.add(path);
69
+ }
70
+ const visibleSuggestions = suggestions.filter((item) => !deniedPaths.has(item.path));
71
+
72
+ return success({
73
+ review,
74
+ suggestions: visibleSuggestions,
75
+ hiddenSuggestions: suggestions.length - visibleSuggestions.length,
76
+ });
64
77
});
65
78
66
79
export const POST: APIRoute = withErrorHandler(async ({ request, params }) => {
67
80
▼tests/integration/ai-review-route-path-permissions.test.ts
+105-0
1
+import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+const {
4
+ canReadRepoMock,
5
+ checkPathPermissionsMock,
6
+ fakeSchema,
7
+} = vi.hoisted(() => ({
8
+ canReadRepoMock: vi.fn(async () => true),
9
+ checkPathPermissionsMock: vi.fn(async () => ({ allowed: true, deniedPaths: [] })),
10
+ fakeSchema: {
11
+ users: { username: {}, id: {} },
12
+ repositories: { ownerId: {}, name: {}, id: {} },
13
+ pullRequests: { repositoryId: {}, number: {}, id: {} },
14
+ aiReviews: { pullRequestId: {}, createdAt: {} },
15
+ aiReviewSuggestions: { aiReviewId: {}, path: {} },
16
+ } as any,
17
+}));
18
+
19
+let mockDb: any;
20
+
21
+vi.mock("@/db", () => ({
22
+ getDatabase: () => mockDb,
23
+ schema: fakeSchema,
24
+}));
25
+
26
+vi.mock("@/lib/permissions", () => ({
27
+ canReadRepo: canReadRepoMock,
28
+}));
29
+
30
+vi.mock("@/lib/path-scoping", () => ({
31
+ checkPathPermissions: checkPathPermissionsMock,
32
+}));
33
+
34
+vi.mock("@/lib/auth", () => ({
35
+ getUserFromRequest: vi.fn(async () => ({ userId: "user-1", isAdmin: false })),
36
+}));
37
+
38
+vi.mock("@/lib/ai-review", () => ({
39
+ triggerAIReview: vi.fn(async () => ({ id: "review-1", status: "pending" })),
40
+}));
41
+
42
+vi.mock("@/lib/ai-config", () => ({
43
+ parseAIConfigFromStorage: vi.fn(() => ({ provider: "openai", model: "gpt-4-turbo", apiKeys: {} })),
44
+}));
45
+
46
+import { GET as aiReviewGet } from "@/pages/api/repos/[owner]/[repo]/pulls/[number]/ai-review";
47
+
48
+function makeDb() {
49
+ return {
50
+ query: {
51
+ users: {
52
+ findFirst: vi.fn(async () => ({ id: "owner-1" })),
53
+ },
54
+ repositories: {
55
+ findFirst: vi.fn(async () => ({ id: "repo-1", ownerId: "owner-1", name: "demo", owner: { username: "owner-1" } })),
56
+ },
57
+ pullRequests: {
58
+ findFirst: vi.fn(async () => ({ id: "pr-1", repositoryId: "repo-1", number: 42 })),
59
+ },
60
+ aiReviews: {
61
+ findFirst: vi.fn(async () => ({ id: "review-1", pullRequestId: "pr-1" })),
62
+ },
63
+ aiReviewSuggestions: {
64
+ findMany: vi.fn(async () => ([
65
+ { id: "s1", aiReviewId: "review-1", path: "src/app.ts", message: "ok" },
66
+ { id: "s2", aiReviewId: "review-1", path: "secure/secret.ts", message: "sensitive" },
67
+ ])),
68
+ },
69
+ },
70
+ };
71
+}
72
+
73
+describe("AI review route path permissions", () => {
74
+ beforeEach(() => {
75
+ vi.clearAllMocks();
76
+ mockDb = makeDb();
77
+ canReadRepoMock.mockResolvedValue(true);
78
+ checkPathPermissionsMock.mockResolvedValue({ allowed: true, deniedPaths: [] });
79
+ });
80
+
81
+ it("filters suggestion paths denied by read permissions", async () => {
82
+ checkPathPermissionsMock.mockResolvedValue({
83
+ allowed: false,
84
+ deniedPaths: ["secure/secret.ts"],
85
+ reason: "denied",
86
+ });
87
+
88
+ const response = await aiReviewGet({
89
+ params: { owner: "owner-1", repo: "demo", number: "42" },
90
+ locals: { user: { id: "user-1", isAdmin: false } },
91
+ } as any);
92
+
93
+ const body = await response.json();
94
+ expect(response.status).toBe(200);
95
+ expect(body?.data?.suggestions).toHaveLength(1);
96
+ expect(body?.data?.suggestions?.[0]?.path).toBe("src/app.ts");
97
+ expect(body?.data?.hiddenSuggestions).toBe(1);
98
+ expect(checkPathPermissionsMock).toHaveBeenCalledWith(
99
+ "user-1",
100
+ "repo-1",
101
+ ["src/app.ts", "secure/secret.ts"],
102
+ "read"
103
+ );
104
+ });
105
+});
0
106