Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
31 changes: 16 additions & 15 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,17 @@ source .env.local && psql "postgresql://postgres.dkrxtcbaqzrodvsagwwn:$SUPABASE_
source .env.local && psql "postgresql://postgres.awegqusxzsmlgxaxyyrq:$SUPABASE_DB_PASSWORD_PRD@aws-0-us-west-1.pooler.supabase.com:6543/postgres"
```

**CRITICAL: Always test queries on production database before making changes**
**CRITICAL**: Always test queries on production database before making changes

When investigating performance issues or timeouts:

1. **Test the actual query on production database first** using psql with `\timing` enabled
2. **Measure the actual execution time** - don't guess or assume what the problem is
3. **Only after confirming the root cause** should you make code changes
4. **Never make blind fixes** based on assumptions - always verify the problem first

Example workflow for investigating slow queries:

```bash
# Connect to production database
source .env.local && psql "postgresql://postgres.awegqusxzsmlgxaxyyrq:$SUPABASE_DB_PASSWORD_PRD@aws-0-us-west-1.pooler.supabase.com:6543/postgres"
Expand Down Expand Up @@ -115,7 +117,7 @@ source .env.local && curl -sS -H "Authorization: Bearer $SENTRY_PERSONAL_TOKEN"

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

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

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

1. **API Routes Organization** (`/app/api/`):

- `/auth/[...nextauth]` - Authentication handling
- `/github/*` - GitHub App integration (issues, repos, branches)
- `/stripe/*` - Subscription management
- `/jira/*` - Jira OAuth and project linking
- `/supabase/*` - Database operations

2. **Context Architecture**:

- `AccountContext` - Global user/installation state, repository selection
- Authentication flows through NextAuth session provider
- PostHog analytics wrapper

3. **Database Schema** (key tables):

- `users` - GitHub users
- `installations` - GitHub App installations
- `repositories` - Repository configurations and rules
Expand All @@ -204,7 +203,6 @@ This is a Next.js 15 application using App Router for GitAuto - a SaaS platform
- `oauth_tokens` - Third-party integrations

4. **External Service Integration**:

- **GitHub**: Octokit with App authentication, GraphQL for issue creation
- **Stripe**: Customer portal, checkout sessions, webhook handling
- **AWS**: EventBridge Scheduler for cron triggers
Expand Down Expand Up @@ -247,20 +245,23 @@ GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO service_role;

**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.

**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.

**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.

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

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

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

Expand Down
77 changes: 77 additions & 0 deletions app/actions/supabase/repo-coverage/get-repo-coverage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { getRepoCoverage } from "./get-repo-coverage";
import { supabaseAdmin } from "@/lib/supabase/server";

jest.mock("@/lib/supabase/server", () => ({
supabaseAdmin: {
from: jest.fn(),
},
}));

describe("getRepoCoverage", () => {
const mockFrom = supabaseAdmin.from as jest.Mock;

beforeEach(() => {
jest.clearAllMocks();
});

it("returns repo coverage data for owner and repo", async () => {
const mockData = [
{
id: 1,
owner_id: 123,
repo_id: 456,
statement_coverage: 80,
lines_covered: 800,
lines_total: 1000,
created_at: "2024-01-01T00:00:00Z",
},
];

mockFrom.mockReturnValue({
select: jest.fn().mockReturnValue({
eq: jest.fn().mockReturnValue({
eq: jest.fn().mockReturnValue({
order: jest.fn().mockResolvedValue({ data: mockData, error: null }),
}),
}),
}),
});

const result = await getRepoCoverage(123, 456);

expect(mockFrom).toHaveBeenCalledWith("repo_coverage");
expect(result).toEqual(mockData);
});

it("returns empty array when no data", async () => {
mockFrom.mockReturnValue({
select: jest.fn().mockReturnValue({
eq: jest.fn().mockReturnValue({
eq: jest.fn().mockReturnValue({
order: jest.fn().mockResolvedValue({ data: null, error: null }),
}),
}),
}),
});

const result = await getRepoCoverage(123, 456);

expect(result).toEqual([]);
});

it("throws error when query fails", async () => {
const mockError = { message: "DB error" };

mockFrom.mockReturnValue({
select: jest.fn().mockReturnValue({
eq: jest.fn().mockReturnValue({
eq: jest.fn().mockReturnValue({
order: jest.fn().mockResolvedValue({ data: null, error: mockError }),
}),
}),
}),
});

await expect(getRepoCoverage(123, 456)).rejects.toEqual(mockError);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
import { supabaseAdmin } from "@/lib/supabase/server";
import { Tables } from "@/types/supabase";

export async function getRepoCoverage(ownerId: number, repoId: number) {
export const getRepoCoverage = async (
ownerId: number,
repoId: number
): Promise<Tables<"repo_coverage">[]> => {
const { data, error } = await supabaseAdmin
.from("repo_coverage")
.select("*")
Expand All @@ -16,5 +19,5 @@ export async function getRepoCoverage(ownerId: number, repoId: number) {
throw error;
}

return data as Tables<"repo_coverage">[];
}
return data || [];
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { getTotalCoverage } from "./get-total-coverage";
import { supabaseAdmin } from "@/lib/supabase/server";

jest.mock("@/lib/supabase/server", () => ({
supabaseAdmin: {
from: jest.fn(),
},
}));

describe("getTotalCoverage", () => {
const mockFrom = supabaseAdmin.from as jest.Mock;

beforeEach(() => {
jest.clearAllMocks();
});

it("returns total coverage data for an owner", async () => {
const mockData = [
{
owner_id: 123,
coverage_date: "2024-01-01",
lines_covered: 800,
lines_total: 1000,
statement_coverage: 80,
},
];

mockFrom.mockReturnValue({
select: jest.fn().mockReturnValue({
eq: jest.fn().mockReturnValue({
order: jest.fn().mockResolvedValue({ data: mockData, error: null }),
}),
}),
});

const result = await getTotalCoverage(123);

expect(mockFrom).toHaveBeenCalledWith("total_repo_coverage");
expect(result).toEqual(mockData);
});

it("returns empty array when no data", async () => {
mockFrom.mockReturnValue({
select: jest.fn().mockReturnValue({
eq: jest.fn().mockReturnValue({
order: jest.fn().mockResolvedValue({ data: null, error: null }),
}),
}),
});

const result = await getTotalCoverage(123);

expect(result).toEqual([]);
});

it("throws error when query fails", async () => {
mockFrom.mockReturnValue({
select: jest.fn().mockReturnValue({
eq: jest.fn().mockReturnValue({
order: jest.fn().mockResolvedValue({ data: null, error: { message: "DB error" } }),
}),
}),
});

await expect(getTotalCoverage(123)).rejects.toThrow("DB error");
});
});
18 changes: 18 additions & 0 deletions app/actions/supabase/total-repo-coverage/get-total-coverage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"use server";

import { supabaseAdmin } from "@/lib/supabase/server";
import { Tables } from "@/types/supabase";

export const getTotalCoverage = async (
ownerId: number
): Promise<Tables<"total_repo_coverage">[]> => {
const { data, error } = await supabaseAdmin
.from("total_repo_coverage")
.select("*")
.eq("owner_id", ownerId)
.order("coverage_date", { ascending: true });

if (error) throw new Error(error.message);

return data || [];
};
22 changes: 22 additions & 0 deletions app/dashboard/charts/components/ChartLegend.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { render, screen } from "@testing-library/react";
import ChartLegend from "./ChartLegend";

describe("ChartLegend", () => {
it("renders all three coverage types in correct order", () => {
render(<ChartLegend />);

const items = screen.getAllByRole("listitem");
expect(items).toHaveLength(3);
expect(items[0]).toHaveTextContent("Statement Coverage");
expect(items[1]).toHaveTextContent("Function Coverage");
expect(items[2]).toHaveTextContent("Branch Coverage");
});

it("renders with correct colors", () => {
render(<ChartLegend />);

expect(screen.getByText("Statement Coverage")).toHaveStyle({ color: "#8884d8" });
expect(screen.getByText("Function Coverage")).toHaveStyle({ color: "#82ca9d" });
expect(screen.getByText("Branch Coverage")).toHaveStyle({ color: "#ffc658" });
});
});
46 changes: 46 additions & 0 deletions app/dashboard/charts/components/ChartLegend.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
interface LegendItem {
name: string;
color: string;
}

const COVERAGE_LEGEND_ITEMS: LegendItem[] = [
{ name: "Statement Coverage", color: "#8884d8" },
{ name: "Function Coverage", color: "#82ca9d" },
{ name: "Branch Coverage", color: "#ffc658" },
];

const LegendIcon = ({ color }: { color: string }) => (
<svg
className="recharts-surface"
width="14"
height="14"
viewBox="0 0 32 32"
style={{ display: "inline-block", verticalAlign: "middle", marginRight: 4 }}
>
<path
strokeWidth="4"
fill="none"
stroke={color}
d="M0,16h10.666666666666666A5.333333333333333,5.333333333333333,0,1,1,21.333333333333332,16H32M21.333333333333332,16A5.333333333333333,5.333333333333333,0,1,1,10.666666666666666,16"
/>
</svg>
);

export default function ChartLegend() {
return (
<ul className="recharts-default-legend" style={{ padding: 0, margin: 0, textAlign: "center" }}>
{COVERAGE_LEGEND_ITEMS.map((item) => (
<li
key={item.name}
className="recharts-legend-item"
style={{ display: "inline-block", marginRight: 10 }}
>
<LegendIcon color={item.color} />
<span className="recharts-legend-item-text" style={{ color: item.color }}>
{item.name}
</span>
</li>
))}
</ul>
);
}
3 changes: 2 additions & 1 deletion app/dashboard/charts/components/CoverageChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
ResponsiveContainer,
} from "recharts";
import { Tables } from "@/types/supabase";
import ChartLegend from "./ChartLegend";

interface CoverageChartProps {
data: Tables<"repo_coverage">[];
Expand Down Expand Up @@ -124,7 +125,7 @@ export default function CoverageChart({
/>
<YAxis domain={[0, 100]} tick={{ fontSize: 12 }} />
<Tooltip formatter={(value: number) => [`${value}%`, ""]} labelFormatter={formatXAxis} />
<Legend />
<Legend content={ChartLegend} />
<Line
type="monotone"
dataKey="statement"
Expand Down
Loading
Loading