Skip to content
Draft
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .cursor/rules/component-folder-structure.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
description: Component folder structure — each component in its own folder with barrel export
globs: apps/web/**/components/**/*.tsx
alwaysApply: false
---

# Component Folder Structure

Every React component must live in its own folder with a barrel `index.ts` file.

```
components/
my-component/
my-component.tsx ← component implementation
index.ts ← re-exports the component
```

## Rules

- Never place component files directly inside a `components/` directory — always create a subfolder.
- The subfolder name must match the component file name (kebab-case).
- The `index.ts` file re-exports the component's public API:

```ts
export { MyComponent } from "./my-component";
```

- Consumers import from the folder, not the file:

```ts
// ✅ Good
import { MyComponent } from "./components/my-component";

// ❌ Bad
import { MyComponent } from "./components/my-component/my-component";
```

- Co-located files (hooks, types, utils, styles) go in the same folder when they are specific to that component.
- **One component per file.** Never define more than one React component in a single `.tsx` file. If a component needs a helper component, that helper gets its own folder and file (either co-located or in a shared `components/` directory).
79 changes: 79 additions & 0 deletions apps/api/src/app/metrics/resolvers/pr-flow-metrics.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
export default /* GraphQL */ `
extend type Metrics {
prFlow: PullRequestFlowMetrics!
}

type PullRequestFlowMetrics {
throughput(input: PullRequestFlowInput!): NumericSeriesChartData
cycleTime(input: PullRequestFlowInput!): NumericChartData
timeToCode(input: PullRequestFlowInput!): NumericChartData
timeToMerge(input: PullRequestFlowInput!): NumericChartData
timeToFirstReview(input: PullRequestFlowInput!): NumericChartData
timeToApproval(input: PullRequestFlowInput!): NumericChartData
cycleTimeBreakdown(input: PullRequestFlowInput!): CycleTimeBreakdownChartData
pullRequestSizeDistribution(
input: PullRequestFlowInput!
): PullRequestSizeDistributionChartData
sizeCycleTimeCorrelation(
input: PullRequestFlowInput!
): ScatterChartData
teamOverview(input: PullRequestFlowInput!): [TeamPrFlowOverviewRow!]
codeReviewDistribution(input: PullRequestFlowInput!): CodeReviewDistributionChartData
}

input PullRequestFlowInput {
"The date range."
dateRange: DateTimeRange!

"The period to group by."
period: Period!

"The team ids to filter by."
teamIds: [SweetID!]

"The repository ids to filter by."
repositoryIds: [SweetID!]
}

type PullRequestSizeDistributionChartData {
columns: [DateTime!]!
series: [ChartNumericSeries!]!
averageLinesChanged: [Float!]!
}

type ScatterChartData {
series: [ScatterChartSeries!]!
}

type ScatterChartSeries {
name: String!
color: HexColorCode
data: [ScatterPoint!]!
}

type ScatterPoint {
x: Float!
y: Float!
title: String
url: String
}

type TeamPrFlowOverviewRow {
teamId: SweetID
teamName: String!
teamIcon: String!
medianCycleTime: BigInt!
mergedCount: Int!
avgLinesChanged: Float!
pctBigPrs: Float!
}

type CycleTimeBreakdownChartData {
columns: [DateTime!]!
cycleTime: [BigInt!]!
timeToCode: [BigInt!]!
timeToFirstReview: [BigInt!]!
timeToApproval: [BigInt!]!
timeToMerge: [BigInt!]!
}
`;
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ export const metricsQuery = createFieldResolver("Workspace", {

await protectWithPaywall(workspace.id);

return { dora: {} };
return { dora: {}, prFlow: {} };
},
});
213 changes: 213 additions & 0 deletions apps/api/src/app/metrics/resolvers/queries/pr-flow-metrics.query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { thirtyDaysAgo } from "../../../../lib/date";
import { createFieldResolver } from "../../../../lib/graphql";
import { logger } from "../../../../lib/logger";
import { ResourceNotFoundException } from "../../../errors/exceptions/resource-not-found.exception";
import {
getWorkspaceCycleTimeBreakdownChartData,
getWorkspaceCycleTimeChartData,
getWorkspacePullRequestSizeDistributionChartData,
getWorkspaceSizeCycleTimeCorrelation,
getWorkspaceTeamOverview,
getWorkspaceThroughputChartData,
getWorkspaceTimeForApprovalChartData,
getWorkspaceTimeForFirstReviewChartData,
getWorkspaceTimeToCodeChartData,
getWorkspaceTimeToMergeChartData,
} from "../../services/chart-pull-request.service";
import { getWorkspaceCodeReviewDistributionChartData } from "../../services/chart-code-review.service";
import { PullRequestFlowChartFilters } from "../../services/chart-pull-request.types";

const buildFilters = (
input: Record<string, any>,
workspaceId: number
): PullRequestFlowChartFilters => ({
workspaceId,
startDate: input.dateRange.from ?? thirtyDaysAgo().toISOString(),
endDate: input.dateRange.to ?? new Date().toISOString(),
period: input.period,
teamIds: input.teamIds ?? undefined,
repositoryIds: input.repositoryIds ?? undefined,
});

export const prFlowMetricsQuery = createFieldResolver(
"PullRequestFlowMetrics",
{
throughput: async (_, { input }, context) => {
logger.info("query.metrics.prFlow.throughput", {
workspaceId: context.workspaceId,
input,
});

if (!context.workspaceId) {
throw new ResourceNotFoundException("Workspace not found");
}

const filters = buildFilters(input, context.workspaceId);
return getWorkspaceThroughputChartData(filters);
},
timeToCode: async (_, { input }, context) => {
logger.info("query.metrics.prFlow.timeToCode", {
workspaceId: context.workspaceId,
input,
});

if (!context.workspaceId) {
throw new ResourceNotFoundException("Workspace not found");
}

const filters = buildFilters(input, context.workspaceId);
const result = await getWorkspaceTimeToCodeChartData(filters);

return {
columns: result.map((r) => r.period),
data: result.map((r) => r.value),
};
},
cycleTime: async (_, { input }, context) => {
logger.info("query.metrics.prFlow.cycleTime", {
workspaceId: context.workspaceId,
input,
});

if (!context.workspaceId) {
throw new ResourceNotFoundException("Workspace not found");
}

const filters = buildFilters(input, context.workspaceId);
const result = await getWorkspaceCycleTimeChartData(filters);

return {
columns: result.map((r) => r.period),
data: result.map((r) => r.value),
};
},
timeToMerge: async (_, { input }, context) => {
logger.info("query.metrics.prFlow.timeToMerge", {
workspaceId: context.workspaceId,
input,
});

if (!context.workspaceId) {
throw new ResourceNotFoundException("Workspace not found");
}

const filters = buildFilters(input, context.workspaceId);
const result = await getWorkspaceTimeToMergeChartData(filters);

return {
columns: result.map((r) => r.period),
data: result.map((r) => r.value),
};
},
timeToFirstReview: async (_, { input }, context) => {
logger.info("query.metrics.prFlow.timeToFirstReview", {
workspaceId: context.workspaceId,
input,
});

if (!context.workspaceId) {
throw new ResourceNotFoundException("Workspace not found");
}

const filters = buildFilters(input, context.workspaceId);
const result = await getWorkspaceTimeForFirstReviewChartData(filters);

return {
columns: result.map((r) => r.period),
data: result.map((r) => r.value),
};
},
timeToApproval: async (_, { input }, context) => {
logger.info("query.metrics.prFlow.timeToApproval", {
workspaceId: context.workspaceId,
input,
});

if (!context.workspaceId) {
throw new ResourceNotFoundException("Workspace not found");
}

const filters = buildFilters(input, context.workspaceId);
const result = await getWorkspaceTimeForApprovalChartData(filters);

return {
columns: result.map((r) => r.period),
data: result.map((r) => r.value),
};
},
cycleTimeBreakdown: async (_, { input }, context) => {
logger.info("query.metrics.prFlow.cycleTimeBreakdown", {
workspaceId: context.workspaceId,
input,
});

if (!context.workspaceId) {
throw new ResourceNotFoundException("Workspace not found");
}

const filters = buildFilters(input, context.workspaceId);
const result = await getWorkspaceCycleTimeBreakdownChartData(filters);

return {
columns: result.map((r) => r.period),
cycleTime: result.map((r) => r.cycleTime),
timeToCode: result.map((r) => r.timeToCode),
timeToFirstReview: result.map((r) => r.timeToFirstReview),
timeToApproval: result.map((r) => r.timeToApproval),
timeToMerge: result.map((r) => r.timeToMerge),
};
},
pullRequestSizeDistribution: async (_, { input }, context) => {
logger.info("query.metrics.prFlow.pullRequestSizeDistribution", {
workspaceId: context.workspaceId,
input,
});

if (!context.workspaceId) {
throw new ResourceNotFoundException("Workspace not found");
}

const filters = buildFilters(input, context.workspaceId);
return getWorkspacePullRequestSizeDistributionChartData(filters);
},
sizeCycleTimeCorrelation: async (_, { input }, context) => {
logger.info("query.metrics.prFlow.sizeCycleTimeCorrelation", {
workspaceId: context.workspaceId,
input,
});

if (!context.workspaceId) {
throw new ResourceNotFoundException("Workspace not found");
}

const filters = buildFilters(input, context.workspaceId);
return getWorkspaceSizeCycleTimeCorrelation(filters);
},
teamOverview: async (_, { input }, context) => {
logger.info("query.metrics.prFlow.teamOverview", {
workspaceId: context.workspaceId,
input,
});

if (!context.workspaceId) {
throw new ResourceNotFoundException("Workspace not found");
}

const filters = buildFilters(input, context.workspaceId);
return getWorkspaceTeamOverview(filters);
},
codeReviewDistribution: async (_, { input }, context) => {
logger.info("query.metrics.prFlow.codeReviewDistribution", {
workspaceId: context.workspaceId,
input,
});

if (!context.workspaceId) {
throw new ResourceNotFoundException("Workspace not found");
}

const filters = buildFilters(input, context.workspaceId);
return getWorkspaceCodeReviewDistributionChartData(filters);
},
}
);
Loading
Loading