Skip to content

Commit cacb567

Browse files
Merge pull request #598 from gitautoai/wes
Add total coverage aggregation with weighted average and forward-fill
2 parents 05d8d22 + ed6cdcb commit cacb567

File tree

17 files changed

+1478
-1038
lines changed

17 files changed

+1478
-1038
lines changed

CLAUDE.md

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,17 @@ source .env.local && psql "postgresql://postgres.dkrxtcbaqzrodvsagwwn:$SUPABASE_
1717
source .env.local && psql "postgresql://postgres.awegqusxzsmlgxaxyyrq:$SUPABASE_DB_PASSWORD_PRD@aws-0-us-west-1.pooler.supabase.com:6543/postgres"
1818
```
1919

20-
**CRITICAL: Always test queries on production database before making changes**
20+
**CRITICAL**: Always test queries on production database before making changes
2121

2222
When investigating performance issues or timeouts:
23+
2324
1. **Test the actual query on production database first** using psql with `\timing` enabled
2425
2. **Measure the actual execution time** - don't guess or assume what the problem is
2526
3. **Only after confirming the root cause** should you make code changes
2627
4. **Never make blind fixes** based on assumptions - always verify the problem first
2728

2829
Example workflow for investigating slow queries:
30+
2931
```bash
3032
# Connect to production database
3133
source .env.local && psql "postgresql://postgres.awegqusxzsmlgxaxyyrq:$SUPABASE_DB_PASSWORD_PRD@aws-0-us-west-1.pooler.supabase.com:6543/postgres"
@@ -115,7 +117,7 @@ source .env.local && curl -sS -H "Authorization: Bearer $SENTRY_PERSONAL_TOKEN"
115117

116118
The following variables must be set in .env.local file:
117119

118-
- `SENTRY_PERSONAL_TOKEN`: Personal auth token with project:read permissions (get from https://gitauto-ai.sentry.io/settings/auth-tokens/)
120+
- `SENTRY_PERSONAL_TOKEN`: Personal auth token with project:read permissions (get from <https://gitauto-ai.sentry.io/settings/auth-tokens/>)
119121
- `SENTRY_ORG_SLUG`: Organization slug (gitauto-ai)
120122
- `SENTRY_PROJECT_ID`: Project ID (4506827829346304)
121123

@@ -181,21 +183,18 @@ This is a Next.js 15 application using App Router for GitAuto - a SaaS platform
181183
### Key Architectural Patterns
182184

183185
1. **API Routes Organization** (`/app/api/`):
184-
185186
- `/auth/[...nextauth]` - Authentication handling
186187
- `/github/*` - GitHub App integration (issues, repos, branches)
187188
- `/stripe/*` - Subscription management
188189
- `/jira/*` - Jira OAuth and project linking
189190
- `/supabase/*` - Database operations
190191

191192
2. **Context Architecture**:
192-
193193
- `AccountContext` - Global user/installation state, repository selection
194194
- Authentication flows through NextAuth session provider
195195
- PostHog analytics wrapper
196196

197197
3. **Database Schema** (key tables):
198-
199198
- `users` - GitHub users
200199
- `installations` - GitHub App installations
201200
- `repositories` - Repository configurations and rules
@@ -204,7 +203,6 @@ This is a Next.js 15 application using App Router for GitAuto - a SaaS platform
204203
- `oauth_tokens` - Third-party integrations
205204

206205
4. **External Service Integration**:
207-
208206
- **GitHub**: Octokit with App authentication, GraphQL for issue creation
209207
- **Stripe**: Customer portal, checkout sessions, webhook handling
210208
- **AWS**: EventBridge Scheduler for cron triggers
@@ -247,20 +245,23 @@ GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO service_role;
247245

248246
**CRITICAL**: Never proceed with git add/commit/push unless ALL tests pass 100%. There is no "mostly passed" - either all tests pass or the task is incomplete.
249247

248+
**CRITICAL**: Fix ALL errors and warnings before proceeding to the next step. Do not continue running commands if there are errors or warnings - fix them first. Moving on without fixing is a waste of time.
249+
250250
**EXCEPTION**: For blog-only changes (adding/editing blog posts in `app/blog/posts/`), tests can be skipped since blog content doesn't affect application functionality.
251251

252252
When the user says "LGTM", execute these commands in order:
253253

254254
1. `npm run types:generate` - Generate TypeScript types
255-
2. `npm run lint` - Run linting
256-
3. `npx tsc --noEmit` - Type-check ALL files including tests (use this to catch TypeScript errors)
257-
4. `npm test` - Run unit tests (must pass 100%, skip for blog-only changes)
258-
5. `npm run build` - Build the project
259-
6. **STOP if any test fails** - Fix all failures before proceeding (unless blog-only)
260-
7. `git fetch origin main && git merge origin/main` - Pull and merge latest main branch changes
261-
8. `git add <specific-file-paths>` - Stage specific changed files (NEVER use `git add .`, always specify exact file paths)
262-
9. Create a descriptive commit message based on changes (do NOT include Claude Code attribution)
263-
10. `git push` - Push to remote
255+
2. `npm run lint` - Run linting. **Fix any errors/warnings before proceeding.**
256+
3. `npx markdownlint-cli2 "**/*.md" "#node_modules"` - Lint markdown files. **Fix any errors before proceeding.**
257+
4. `npx tsc --noEmit` - Type-check ALL files including tests. **Fix any errors before proceeding.**
258+
5. `npm test` - Run unit tests (must pass 100%, skip for blog-only changes). **Fix any failures before proceeding.**
259+
6. `npm run build` - Build the project
260+
7. **STOP if any step fails** - Fix all failures before proceeding (unless blog-only)
261+
8. `git fetch origin main && git merge origin/main` - Pull and merge latest main branch changes
262+
9. `git add <specific-file-paths>` - Stage specific changed files including updated/created test files (NEVER use `git add .`, always specify exact file paths)
263+
10. Create a descriptive commit message based on changes (do NOT include Claude Code attribution)
264+
11. `git push` - Push to remote
264265

265266
**Note**: E2E tests (`npx playwright test`) are skipped during LGTM to save time. Run them manually when needed.
266267

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { getRepoCoverage } from "./get-repo-coverage";
2+
import { supabaseAdmin } from "@/lib/supabase/server";
3+
4+
jest.mock("@/lib/supabase/server", () => ({
5+
supabaseAdmin: {
6+
from: jest.fn(),
7+
},
8+
}));
9+
10+
describe("getRepoCoverage", () => {
11+
const mockFrom = supabaseAdmin.from as jest.Mock;
12+
13+
beforeEach(() => {
14+
jest.clearAllMocks();
15+
});
16+
17+
it("returns repo coverage data for owner and repo", async () => {
18+
const mockData = [
19+
{
20+
id: 1,
21+
owner_id: 123,
22+
repo_id: 456,
23+
statement_coverage: 80,
24+
lines_covered: 800,
25+
lines_total: 1000,
26+
created_at: "2024-01-01T00:00:00Z",
27+
},
28+
];
29+
30+
mockFrom.mockReturnValue({
31+
select: jest.fn().mockReturnValue({
32+
eq: jest.fn().mockReturnValue({
33+
eq: jest.fn().mockReturnValue({
34+
order: jest.fn().mockResolvedValue({ data: mockData, error: null }),
35+
}),
36+
}),
37+
}),
38+
});
39+
40+
const result = await getRepoCoverage(123, 456);
41+
42+
expect(mockFrom).toHaveBeenCalledWith("repo_coverage");
43+
expect(result).toEqual(mockData);
44+
});
45+
46+
it("returns empty array when no data", async () => {
47+
mockFrom.mockReturnValue({
48+
select: jest.fn().mockReturnValue({
49+
eq: jest.fn().mockReturnValue({
50+
eq: jest.fn().mockReturnValue({
51+
order: jest.fn().mockResolvedValue({ data: null, error: null }),
52+
}),
53+
}),
54+
}),
55+
});
56+
57+
const result = await getRepoCoverage(123, 456);
58+
59+
expect(result).toEqual([]);
60+
});
61+
62+
it("throws error when query fails", async () => {
63+
const mockError = { message: "DB error" };
64+
65+
mockFrom.mockReturnValue({
66+
select: jest.fn().mockReturnValue({
67+
eq: jest.fn().mockReturnValue({
68+
eq: jest.fn().mockReturnValue({
69+
order: jest.fn().mockResolvedValue({ data: null, error: mockError }),
70+
}),
71+
}),
72+
}),
73+
});
74+
75+
await expect(getRepoCoverage(123, 456)).rejects.toEqual(mockError);
76+
});
77+
});

app/actions/supabase/coverage/get-repo-coverage.ts renamed to app/actions/supabase/repo-coverage/get-repo-coverage.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
import { supabaseAdmin } from "@/lib/supabase/server";
44
import { Tables } from "@/types/supabase";
55

6-
export async function getRepoCoverage(ownerId: number, repoId: number) {
6+
export const getRepoCoverage = async (
7+
ownerId: number,
8+
repoId: number
9+
): Promise<Tables<"repo_coverage">[]> => {
710
const { data, error } = await supabaseAdmin
811
.from("repo_coverage")
912
.select("*")
@@ -16,5 +19,5 @@ export async function getRepoCoverage(ownerId: number, repoId: number) {
1619
throw error;
1720
}
1821

19-
return data as Tables<"repo_coverage">[];
20-
}
22+
return data || [];
23+
};
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { getTotalCoverage } from "./get-total-coverage";
2+
import { supabaseAdmin } from "@/lib/supabase/server";
3+
4+
jest.mock("@/lib/supabase/server", () => ({
5+
supabaseAdmin: {
6+
from: jest.fn(),
7+
},
8+
}));
9+
10+
describe("getTotalCoverage", () => {
11+
const mockFrom = supabaseAdmin.from as jest.Mock;
12+
13+
beforeEach(() => {
14+
jest.clearAllMocks();
15+
});
16+
17+
it("returns total coverage data for an owner", async () => {
18+
const mockData = [
19+
{
20+
owner_id: 123,
21+
coverage_date: "2024-01-01",
22+
lines_covered: 800,
23+
lines_total: 1000,
24+
statement_coverage: 80,
25+
},
26+
];
27+
28+
mockFrom.mockReturnValue({
29+
select: jest.fn().mockReturnValue({
30+
eq: jest.fn().mockReturnValue({
31+
order: jest.fn().mockResolvedValue({ data: mockData, error: null }),
32+
}),
33+
}),
34+
});
35+
36+
const result = await getTotalCoverage(123);
37+
38+
expect(mockFrom).toHaveBeenCalledWith("total_repo_coverage");
39+
expect(result).toEqual(mockData);
40+
});
41+
42+
it("returns empty array when no data", async () => {
43+
mockFrom.mockReturnValue({
44+
select: jest.fn().mockReturnValue({
45+
eq: jest.fn().mockReturnValue({
46+
order: jest.fn().mockResolvedValue({ data: null, error: null }),
47+
}),
48+
}),
49+
});
50+
51+
const result = await getTotalCoverage(123);
52+
53+
expect(result).toEqual([]);
54+
});
55+
56+
it("throws error when query fails", async () => {
57+
mockFrom.mockReturnValue({
58+
select: jest.fn().mockReturnValue({
59+
eq: jest.fn().mockReturnValue({
60+
order: jest.fn().mockResolvedValue({ data: null, error: { message: "DB error" } }),
61+
}),
62+
}),
63+
});
64+
65+
await expect(getTotalCoverage(123)).rejects.toThrow("DB error");
66+
});
67+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"use server";
2+
3+
import { supabaseAdmin } from "@/lib/supabase/server";
4+
import { Tables } from "@/types/supabase";
5+
6+
export const getTotalCoverage = async (
7+
ownerId: number
8+
): Promise<Tables<"total_repo_coverage">[]> => {
9+
const { data, error } = await supabaseAdmin
10+
.from("total_repo_coverage")
11+
.select("*")
12+
.eq("owner_id", ownerId)
13+
.order("coverage_date", { ascending: true });
14+
15+
if (error) throw new Error(error.message);
16+
17+
return data || [];
18+
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { render, screen } from "@testing-library/react";
2+
import ChartLegend from "./ChartLegend";
3+
4+
describe("ChartLegend", () => {
5+
it("renders all three coverage types in correct order", () => {
6+
render(<ChartLegend />);
7+
8+
const items = screen.getAllByRole("listitem");
9+
expect(items).toHaveLength(3);
10+
expect(items[0]).toHaveTextContent("Statement Coverage");
11+
expect(items[1]).toHaveTextContent("Function Coverage");
12+
expect(items[2]).toHaveTextContent("Branch Coverage");
13+
});
14+
15+
it("renders with correct colors", () => {
16+
render(<ChartLegend />);
17+
18+
expect(screen.getByText("Statement Coverage")).toHaveStyle({ color: "#8884d8" });
19+
expect(screen.getByText("Function Coverage")).toHaveStyle({ color: "#82ca9d" });
20+
expect(screen.getByText("Branch Coverage")).toHaveStyle({ color: "#ffc658" });
21+
});
22+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
interface LegendItem {
2+
name: string;
3+
color: string;
4+
}
5+
6+
const COVERAGE_LEGEND_ITEMS: LegendItem[] = [
7+
{ name: "Statement Coverage", color: "#8884d8" },
8+
{ name: "Function Coverage", color: "#82ca9d" },
9+
{ name: "Branch Coverage", color: "#ffc658" },
10+
];
11+
12+
const LegendIcon = ({ color }: { color: string }) => (
13+
<svg
14+
className="recharts-surface"
15+
width="14"
16+
height="14"
17+
viewBox="0 0 32 32"
18+
style={{ display: "inline-block", verticalAlign: "middle", marginRight: 4 }}
19+
>
20+
<path
21+
strokeWidth="4"
22+
fill="none"
23+
stroke={color}
24+
d="M0,16h10.666666666666666A5.333333333333333,5.333333333333333,0,1,1,21.333333333333332,16H32M21.333333333333332,16A5.333333333333333,5.333333333333333,0,1,1,10.666666666666666,16"
25+
/>
26+
</svg>
27+
);
28+
29+
export default function ChartLegend() {
30+
return (
31+
<ul className="recharts-default-legend" style={{ padding: 0, margin: 0, textAlign: "center" }}>
32+
{COVERAGE_LEGEND_ITEMS.map((item) => (
33+
<li
34+
key={item.name}
35+
className="recharts-legend-item"
36+
style={{ display: "inline-block", marginRight: 10 }}
37+
>
38+
<LegendIcon color={item.color} />
39+
<span className="recharts-legend-item-text" style={{ color: item.color }}>
40+
{item.name}
41+
</span>
42+
</li>
43+
))}
44+
</ul>
45+
);
46+
}

app/dashboard/charts/components/CoverageChart.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
ResponsiveContainer,
1212
} from "recharts";
1313
import { Tables } from "@/types/supabase";
14+
import ChartLegend from "./ChartLegend";
1415

1516
interface CoverageChartProps {
1617
data: Tables<"repo_coverage">[];
@@ -124,7 +125,7 @@ export default function CoverageChart({
124125
/>
125126
<YAxis domain={[0, 100]} tick={{ fontSize: 12 }} />
126127
<Tooltip formatter={(value: number) => [`${value}%`, ""]} labelFormatter={formatXAxis} />
127-
<Legend />
128+
<Legend content={ChartLegend} />
128129
<Line
129130
type="monotone"
130131
dataKey="statement"

0 commit comments

Comments
 (0)