Add path-scoped filtering for PR review listings

swadhinbiswas committed 8 months ago
193a0f2
1 parent 611aaa9
commit 193a0f2
4 files changed
+154 -3
▼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, single-review inline comments, batch-review comment submission, suggestion-apply, partial file-approval create/revoke and read-filter flows, PR rewrite operations, and PR impact scans; 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, single-review inline comments, batch-review comment submission, suggestion-apply, partial file-approval create/revoke and read-filter flows, PR rewrite operations, and PR impact scans; 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
+16-0
780
780
},
781
781
},
782
782
"/repos/{owner}/{repo}/pulls/{number}/reviews": {
783
+ get: {
784
+ tags: ["Pull Requests"],
785
+ summary: "List pull request reviews",
786
+ description: "Returns PR reviews with inline comments filtered by path-scoped read permissions, including `hiddenCommentCount` metadata.",
787
+ security: [{ bearerAuth: [] }],
788
+ parameters: [
789
+ { name: "owner", in: "path", required: true, schema: { type: "string" } },
790
+ { name: "repo", in: "path", required: true, schema: { type: "string" } },
791
+ { name: "number", in: "path", required: true, schema: { type: "integer" } },
792
+ ],
793
+ responses: {
794
+ 200: { description: "Reviews returned" },
795
+ 401: { description: "Unauthorized", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
796
+ 404: { description: "Repository or pull request not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
797
+ },
798
+ },
783
799
post: {
784
800
tags: ["Pull Requests"],
785
801
summary: "Submit pull request review",
786
802
▼src/pages/api/repos/[owner]/[repo]/pulls/[number]/reviews.ts
+85-0
33
33
.optional(),
34
34
});
35
35
36
+export const GET: APIRoute = withErrorHandler(async ({ params, locals }) => {
37
+ const { owner: ownerName, repo: repoName, number } = params;
38
+ const user = locals.user;
39
+
40
+ if (!user) return unauthorized();
41
+ if (!ownerName || !repoName || !number) return badRequest("Missing parameters");
42
+
43
+ const db = getDatabase() as NodePgDatabase<typeof schema>;
44
+ const repoOwner = await db.query.users.findFirst({
45
+ where: eq(schema.users.username, ownerName),
46
+ });
47
+ if (!repoOwner) return notFound("Repository not found");
48
+
49
+ const repo = await db.query.repositories.findFirst({
50
+ where: and(
51
+ eq(schema.repositories.ownerId, repoOwner.id),
52
+ eq(schema.repositories.name, repoName)
53
+ ),
54
+ });
55
+ if (!repo) return notFound("Repository not found");
56
+ if (!(await canReadRepo(user.id, repo, { isAdmin: user.isAdmin }))) return notFound("Repository not found");
57
+
58
+ const pr = await db.query.pullRequests.findFirst({
59
+ where: and(
60
+ eq(schema.pullRequests.repositoryId, repo.id),
61
+ eq(schema.pullRequests.number, parseInt(number))
62
+ )
63
+ });
64
+ if (!pr) return notFound("Pull request not found");
65
+
66
+ const reviews = await db.query.pullRequestReviews.findMany({
67
+ where: eq(schema.pullRequestReviews.pullRequestId, pr.id),
68
+ with: {
69
+ reviewer: {
70
+ columns: {
71
+ id: true,
72
+ username: true,
73
+ displayName: true,
74
+ },
75
+ },
76
+ comments: {
77
+ with: {
78
+ author: {
79
+ columns: {
80
+ id: true,
81
+ username: true,
82
+ displayName: true,
83
+ },
84
+ },
85
+ },
86
+ },
87
+ },
88
+ });
89
+
90
+ const scopedPaths = Array.from(
91
+ new Set(
92
+ reviews
93
+ .flatMap((review) => review.comments || [])
94
+ .map((comment) => comment.path)
95
+ .filter((path): path is string => Boolean(path))
96
+ )
97
+ );
98
+ const deniedPaths = new Set<string>();
99
+ if (scopedPaths.length > 0) {
100
+ const permission = await checkPathPermissions(user.id, repo.id, scopedPaths, "read");
101
+ for (const path of permission.deniedPaths || []) deniedPaths.add(path);
102
+ }
103
+
104
+ let hiddenCommentCount = 0;
105
+ const filteredReviews = reviews.map((review) => {
106
+ const comments = (review.comments || []).filter((comment) => {
107
+ if (!comment.path) return true;
108
+ const visible = !deniedPaths.has(comment.path);
109
+ if (!visible) hiddenCommentCount += 1;
110
+ return visible;
111
+ });
112
+ return {
113
+ ...review,
114
+ comments,
115
+ };
116
+ });
117
+
118
+ return success({ reviews: filteredReviews, hiddenCommentCount });
119
+});
120
+
36
121
// POST: Submit a review
37
122
export const POST: APIRoute = withErrorHandler(async ({ params, request, locals }) => {
38
123
const { owner: ownerName, repo: repoName, number } = params;
39
124
▼tests/integration/reviews-route-path-permissions.test.ts
+52-2
33
33
triggerAutomation: vi.fn(async () => undefined),
34
34
}));
35
35
36
-import { POST as reviewPost } from "@/pages/api/repos/[owner]/[repo]/pulls/[number]/reviews";
36
+import { GET as reviewGet, POST as reviewPost } from "@/pages/api/repos/[owner]/[repo]/pulls/[number]/reviews";
37
37
38
38
function makeDb() {
39
39
const owner = { id: "owner-1", username: "acme" };
40
40
const repo = { id: "repo-1", ownerId: "owner-1", name: "demo" };
41
41
const pr = { id: "pr-1", repositoryId: "repo-1", number: 42, authorId: "author-1" };
42
+ const reviews = [
43
+ {
44
+ id: "rev-1",
45
+ pullRequestId: "pr-1",
46
+ state: "COMMENTED",
47
+ reviewer: { id: "reviewer-1", username: "reviewer-1", displayName: "Reviewer One" },
48
+ comments: [
49
+ {
50
+ id: "comment-1",
51
+ path: "src/app.ts",
52
+ body: "visible",
53
+ author: { id: "reviewer-1", username: "reviewer-1", displayName: "Reviewer One" },
54
+ },
55
+ {
56
+ id: "comment-2",
57
+ path: "secure/secret.ts",
58
+ body: "hidden",
59
+ author: { id: "reviewer-2", username: "reviewer-2", displayName: "Reviewer Two" },
60
+ },
61
+ ],
62
+ },
63
+ ];
42
64
43
65
const insertCalls: Array<{ table: unknown; value: unknown }> = [];
44
66
53
75
pullRequests: {
54
76
findFirst: vi.fn(async () => pr),
55
77
},
78
+ pullRequestReviews: {
79
+ findMany: vi.fn(async () => reviews),
80
+ },
56
81
},
57
82
insert: vi.fn((table: unknown) => ({
58
83
values: vi.fn(async (value: unknown) => {
103
128
expect(mockDb.__state.insertCalls).toHaveLength(0);
104
129
});
105
130
131
+ it("filters inline review comments for denied read paths", async () => {
132
+ checkPathPermissionsMock.mockResolvedValue({
133
+ allowed: false,
134
+ deniedPaths: ["secure/secret.ts"],
135
+ reason: "Insufficient permissions for paths: secure/secret.ts",
136
+ });
137
+
138
+ const response = await reviewGet({
139
+ params: { owner: "acme", repo: "demo", number: "42" },
140
+ locals: { user: { id: "reviewer-1", isAdmin: false } },
141
+ } as any);
142
+
143
+ const body = await readJson(response);
144
+ expect(response.status).toBe(200);
145
+ expect(body?.data?.reviews).toHaveLength(1);
146
+ expect(body?.data?.reviews?.[0]?.comments).toHaveLength(1);
147
+ expect(body?.data?.reviews?.[0]?.comments?.[0]?.path).toBe("src/app.ts");
148
+ expect(body?.data?.hiddenCommentCount).toBe(1);
149
+ expect(checkPathPermissionsMock).toHaveBeenCalledWith(
150
+ "reviewer-1",
151
+ "repo-1",
152
+ ["src/app.ts", "secure/secret.ts"],
153
+ "read"
154
+ );
155
+ });
156
+
106
157
it("creates review and inline comment when path permissions pass", async () => {
107
158
const response = await reviewPost({
108
159
params: { owner: "acme", repo: "demo", number: "42" },
125
176
expect(mockDb.__state.insertCalls.length).toBe(2);
126
177
});
127
178
});
128
-
129
179