Skip to content

Commit 4339a3d

Browse files
committed
feat: code review efficiency (wip)
1 parent 0188516 commit 4339a3d

32 files changed

Lines changed: 2586 additions & 262 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
description: Ensure all database queries in API service files are scoped to the current workspace (tenant)
3+
globs: apps/api/src/**/services/*.service.ts
4+
alwaysApply: false
5+
---
6+
7+
# Workspace-scoped queries
8+
9+
All database queries MUST be scoped to the workspace (tenant). Failing to do so leaks data across workspaces.
10+
11+
## How to scope
12+
13+
- `PullRequest`, `PullRequestTracking`, `Repository`, and `Team` all have a direct `workspaceId` column. Filter on it directly:
14+
15+
```sql
16+
WHERE p."workspaceId" = ${workspaceId}
17+
```
18+
19+
- Do NOT filter workspace through `WorkspaceMembership` joins. A `GitProfile` can belong to multiple workspaces, so filtering via membership causes cross-workspace data leaks:
20+
21+
```sql
22+
-- ❌ BAD: leaks data when a user belongs to multiple workspaces
23+
INNER JOIN "GitProfile" gp ON p."authorId" = gp."id"
24+
INNER JOIN "WorkspaceMembership" wm ON gp."id" = wm."gitProfileId"
25+
WHERE wm."workspaceId" = ${workspaceId}
26+
27+
-- ✅ GOOD: uses the direct workspace column on the entity
28+
WHERE p."workspaceId" = ${workspaceId}
29+
```
30+
31+
## Checklist for new queries
32+
33+
1. Identify which table owns the data — use its `workspaceId` column.
34+
2. Never rely solely on user-membership joins for tenant isolation.
35+
3. When using `getPrisma(workspaceId)`, remember RLS only applies to Prisma transactions, not `$queryRaw`. Raw queries must filter explicitly.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
export default /* GraphQL */ `
2+
extend type Metrics {
3+
codeReviewEfficiency: CodeReviewEfficiencyMetrics!
4+
}
5+
6+
type CodeReviewEfficiencyMetrics {
7+
reviewTurnaroundTime(input: PullRequestFlowInput!): NumericChartData
8+
timeToApproval(input: PullRequestFlowInput!): NumericChartData
9+
prsWithoutApproval(input: PullRequestFlowInput!): Int
10+
sizeCommentCorrelation(input: PullRequestFlowInput!): ScatterChartData
11+
codeReviewDistribution(input: PullRequestFlowInput!): CodeReviewDistributionChartData
12+
teamOverview(input: PullRequestFlowInput!): [CodeReviewTeamOverviewRow!]
13+
kpiTimeToFirstReview(input: PullRequestFlowInput!): CodeReviewDurationKpi
14+
kpiTimeToApproval(input: PullRequestFlowInput!): CodeReviewDurationKpi
15+
kpiAvgCommentsPerPr(input: PullRequestFlowInput!): CodeReviewFloatKpi
16+
kpiPrsWithoutApproval(input: PullRequestFlowInput!): CodeReviewCountKpi
17+
}
18+
19+
type CodeReviewDurationKpi {
20+
currentAmount: BigInt!
21+
previousAmount: BigInt!
22+
change: Int!
23+
currentPeriod: DateTimeRangeValue!
24+
previousPeriod: DateTimeRangeValue!
25+
}
26+
27+
type CodeReviewCountKpi {
28+
currentAmount: Int!
29+
previousAmount: Int!
30+
change: Int!
31+
currentPeriod: DateTimeRangeValue!
32+
previousPeriod: DateTimeRangeValue!
33+
}
34+
35+
type CodeReviewFloatKpi {
36+
currentAmount: Float!
37+
previousAmount: Float!
38+
change: Int!
39+
currentPeriod: DateTimeRangeValue!
40+
previousPeriod: DateTimeRangeValue!
41+
}
42+
43+
type CodeReviewTeamOverviewRow {
44+
teamId: SweetID
45+
teamName: String!
46+
teamIcon: String!
47+
avgTimeToFirstReview: BigInt!
48+
avgTimeToApproval: BigInt!
49+
prsWithoutApproval: Int!
50+
}
51+
`;

apps/api/src/app/metrics/resolvers/pr-flow-metrics.schema.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ export default /* GraphQL */ `
1818
input: PullRequestFlowInput!
1919
): ScatterChartData
2020
teamOverview(input: PullRequestFlowInput!): [TeamPrFlowOverviewRow!]
21-
codeReviewDistribution(input: PullRequestFlowInput!): CodeReviewDistributionChartData
2221
}
2322
2423
input PullRequestFlowInput {
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { thirtyDaysAgo } from "../../../../lib/date";
2+
import { createFieldResolver } from "../../../../lib/graphql";
3+
import { logger } from "../../../../lib/logger";
4+
import { ResourceNotFoundException } from "../../../errors/exceptions/resource-not-found.exception";
5+
import {
6+
getWorkspaceReviewTurnaroundTime,
7+
getWorkspaceTimeToApprovalChart,
8+
getWorkspacePrsWithoutApproval,
9+
getWorkspaceSizeCommentCorrelation,
10+
getWorkspaceCodeReviewDistributionChartData,
11+
getCodeReviewTeamOverview,
12+
getKpiTimeToFirstReview,
13+
getKpiTimeToApproval,
14+
getKpiAvgCommentsPerPr,
15+
getKpiPrsWithoutApproval,
16+
} from "../../services/chart-code-review-efficiency.service";
17+
import { PullRequestFlowChartFilters } from "../../services/chart-pull-request.types";
18+
19+
const buildFilters = (
20+
input: Record<string, any>,
21+
workspaceId: number
22+
): PullRequestFlowChartFilters => ({
23+
workspaceId,
24+
startDate: input.dateRange.from ?? thirtyDaysAgo().toISOString(),
25+
endDate: input.dateRange.to ?? new Date().toISOString(),
26+
period: input.period,
27+
teamIds: input.teamIds ?? undefined,
28+
repositoryIds: input.repositoryIds ?? undefined,
29+
});
30+
31+
export const codeReviewEfficiencyMetricsQuery = createFieldResolver(
32+
"CodeReviewEfficiencyMetrics",
33+
{
34+
reviewTurnaroundTime: async (_, { input }, context) => {
35+
logger.info("query.metrics.codeReviewEfficiency.reviewTurnaroundTime", {
36+
workspaceId: context.workspaceId,
37+
input,
38+
});
39+
40+
if (!context.workspaceId) {
41+
throw new ResourceNotFoundException("Workspace not found");
42+
}
43+
44+
const filters = buildFilters(input, context.workspaceId);
45+
return getWorkspaceReviewTurnaroundTime(filters);
46+
},
47+
timeToApproval: async (_, { input }, context) => {
48+
logger.info("query.metrics.codeReviewEfficiency.timeToApproval", {
49+
workspaceId: context.workspaceId,
50+
input,
51+
});
52+
53+
if (!context.workspaceId) {
54+
throw new ResourceNotFoundException("Workspace not found");
55+
}
56+
57+
const filters = buildFilters(input, context.workspaceId);
58+
return getWorkspaceTimeToApprovalChart(filters);
59+
},
60+
prsWithoutApproval: async (_, { input }, context) => {
61+
logger.info("query.metrics.codeReviewEfficiency.prsWithoutApproval", {
62+
workspaceId: context.workspaceId,
63+
input,
64+
});
65+
66+
if (!context.workspaceId) {
67+
throw new ResourceNotFoundException("Workspace not found");
68+
}
69+
70+
const filters = buildFilters(input, context.workspaceId);
71+
return getWorkspacePrsWithoutApproval(filters);
72+
},
73+
sizeCommentCorrelation: async (_, { input }, context) => {
74+
logger.info(
75+
"query.metrics.codeReviewEfficiency.sizeCommentCorrelation",
76+
{
77+
workspaceId: context.workspaceId,
78+
input,
79+
}
80+
);
81+
82+
if (!context.workspaceId) {
83+
throw new ResourceNotFoundException("Workspace not found");
84+
}
85+
86+
const filters = buildFilters(input, context.workspaceId);
87+
return getWorkspaceSizeCommentCorrelation(filters);
88+
},
89+
codeReviewDistribution: async (_, { input }, context) => {
90+
logger.info(
91+
"query.metrics.codeReviewEfficiency.codeReviewDistribution",
92+
{
93+
workspaceId: context.workspaceId,
94+
input,
95+
}
96+
);
97+
98+
if (!context.workspaceId) {
99+
throw new ResourceNotFoundException("Workspace not found");
100+
}
101+
102+
const filters = buildFilters(input, context.workspaceId);
103+
return getWorkspaceCodeReviewDistributionChartData(filters);
104+
},
105+
teamOverview: async (_, { input }, context) => {
106+
logger.info("query.metrics.codeReviewEfficiency.teamOverview", {
107+
workspaceId: context.workspaceId,
108+
input,
109+
});
110+
111+
if (!context.workspaceId) {
112+
throw new ResourceNotFoundException("Workspace not found");
113+
}
114+
115+
const filters = buildFilters(input, context.workspaceId);
116+
return getCodeReviewTeamOverview(filters);
117+
},
118+
kpiTimeToFirstReview: async (_, { input }, context) => {
119+
logger.info("query.metrics.codeReviewEfficiency.kpiTimeToFirstReview", {
120+
workspaceId: context.workspaceId,
121+
input,
122+
});
123+
124+
if (!context.workspaceId) {
125+
throw new ResourceNotFoundException("Workspace not found");
126+
}
127+
128+
const filters = buildFilters(input, context.workspaceId);
129+
return getKpiTimeToFirstReview(filters);
130+
},
131+
kpiTimeToApproval: async (_, { input }, context) => {
132+
logger.info("query.metrics.codeReviewEfficiency.kpiTimeToApproval", {
133+
workspaceId: context.workspaceId,
134+
input,
135+
});
136+
137+
if (!context.workspaceId) {
138+
throw new ResourceNotFoundException("Workspace not found");
139+
}
140+
141+
const filters = buildFilters(input, context.workspaceId);
142+
return getKpiTimeToApproval(filters);
143+
},
144+
kpiAvgCommentsPerPr: async (_, { input }, context) => {
145+
logger.info("query.metrics.codeReviewEfficiency.kpiAvgCommentsPerPr", {
146+
workspaceId: context.workspaceId,
147+
input,
148+
});
149+
150+
if (!context.workspaceId) {
151+
throw new ResourceNotFoundException("Workspace not found");
152+
}
153+
154+
const filters = buildFilters(input, context.workspaceId);
155+
return getKpiAvgCommentsPerPr(filters);
156+
},
157+
kpiPrsWithoutApproval: async (_, { input }, context) => {
158+
logger.info("query.metrics.codeReviewEfficiency.kpiPrsWithoutApproval", {
159+
workspaceId: context.workspaceId,
160+
input,
161+
});
162+
163+
if (!context.workspaceId) {
164+
throw new ResourceNotFoundException("Workspace not found");
165+
}
166+
167+
const filters = buildFilters(input, context.workspaceId);
168+
return getKpiPrsWithoutApproval(filters);
169+
},
170+
}
171+
);

apps/api/src/app/metrics/resolvers/queries/metrics.query.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,6 @@ export const metricsQuery = createFieldResolver("Workspace", {
1313

1414
await protectWithPaywall(workspace.id);
1515

16-
return { dora: {}, prFlow: {} };
16+
return { dora: {}, prFlow: {}, codeReviewEfficiency: {} };
1717
},
1818
});

apps/api/src/app/metrics/resolvers/queries/pr-flow-metrics.query.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
getWorkspaceTimeToCodeChartData,
1515
getWorkspaceTimeToMergeChartData,
1616
} from "../../services/chart-pull-request.service";
17-
import { getWorkspaceCodeReviewDistributionChartData } from "../../services/chart-code-review.service";
1817
import { PullRequestFlowChartFilters } from "../../services/chart-pull-request.types";
1918

2019
const buildFilters = (
@@ -196,18 +195,5 @@ export const prFlowMetricsQuery = createFieldResolver(
196195
const filters = buildFilters(input, context.workspaceId);
197196
return getWorkspaceTeamOverview(filters);
198197
},
199-
codeReviewDistribution: async (_, { input }, context) => {
200-
logger.info("query.metrics.prFlow.codeReviewDistribution", {
201-
workspaceId: context.workspaceId,
202-
input,
203-
});
204-
205-
if (!context.workspaceId) {
206-
throw new ResourceNotFoundException("Workspace not found");
207-
}
208-
209-
const filters = buildFilters(input, context.workspaceId);
210-
return getWorkspaceCodeReviewDistributionChartData(filters);
211-
},
212198
}
213199
);

0 commit comments

Comments
 (0)