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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ migrations/
These are the misses Hare was tightened against:

1. **False Major on `session.user.id` as tenant** — many apps *are* that mapping. Hare now loads auth helpers before filing a Major, and demotes hypotheticals.
2. **Prisma `String` vs Postgres `UUID`** — a `TEXT` FK against `tenants.id UUID` ships and then dies at `prisma migrate deploy`. Hare now always loads `prisma/schema.prisma` when a migration is in the diff and treats type mismatch as Major.
2. **Prisma `String` vs Postgres `UUID`** — a `TEXT` FK against `tenants.id UUID` ships and then dies at `prisma migrate deploy`. Hare now **parses the SQL + `schema.prisma` itself** (not only the LLM): TEXT/VARCHAR referencing UUID is a Major, same for SET NOT NULL without a default, DROP COLUMN, and DROP TABLE.
3. **404 on PR files** — fine-grained PAT missing Contents, or resource owner set to the bot user instead of the org. GitHub returns 404 on private repos instead of 403.

Hare will still miss things. The bar is “no false Majors, catch schema/auth defects that are in the diff + context,” not 100%.
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@
"preview:stop": "node scripts/preview.mjs stop",
"typecheck": "tsc --noEmit",
"check:auth": "node scripts/check-auth-invariant.mjs",
"test": "node --test 'scripts/**/*.test.mjs' && node --experimental-strip-types --test src/lib/app-data/app-data.test.ts src/lib/app-data/readiness-schedule.test.ts src/lib/auth/gate-identity.test.ts src/lib/auth/sign-in-gate.test.ts",
"test:ci": "node --experimental-strip-types --test src/lib/app-data/app-data.test.ts src/lib/app-data/readiness-schedule.test.ts src/lib/auth/gate-identity.test.ts src/lib/auth/sign-in-gate.test.ts",
"test": "node --test 'scripts/**/*.test.mjs' && node --experimental-strip-types --test src/lib/app-data/app-data.test.ts src/lib/app-data/readiness-schedule.test.ts src/lib/auth/gate-identity.test.ts src/lib/auth/sign-in-gate.test.ts src/lib/hare/migrations.test.ts",
"test:ci": "node --experimental-strip-types --test src/lib/app-data/app-data.test.ts src/lib/app-data/readiness-schedule.test.ts src/lib/auth/gate-identity.test.ts src/lib/auth/sign-in-gate.test.ts src/lib/hare/migrations.test.ts",
"lint": "eslint .",
"format": "prettier --write ."
},
Expand Down
12 changes: 9 additions & 3 deletions src/lib/hare/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
runGrokReview,
suggestContextPaths,
} from "./reviewer";
import { mergeMigrationFindings, scanMigrationIssues } from "./migrations";
import type { ChangedFile, ReviewerOutput } from "./types";

export function newSecret(): string {
Expand Down Expand Up @@ -359,10 +360,12 @@ export async function reviewPullForUser(input: {
const previous = await getPreviousCompleteReview(pr.id, headSha);
let contextFiles: Array<{ path: string; content: string }> = [];
if (token) {
const wanted = suggestContextPaths(files);
const wanted = suggestContextPaths(files).sort(
(a, b) => Number(/schema\.prisma$/i.test(b)) - Number(/schema\.prisma$/i.test(a)),
);
const loaded: Array<{ path: string; content: string }> = [];
for (const path of wanted) {
if (loaded.length >= 6) break;
if (loaded.length >= 8) break;
const content = await getFileAtRef(token, owner, repo, path, headSha);
if (content && content.length > 40) {
loaded.push({ path, content });
Expand All @@ -387,7 +390,10 @@ export async function reviewPullForUser(input: {
});

const raw = await runGrokReview(prompt);
const output = normalizeReviewerOutput(raw, files, diff);
const output = mergeMigrationFindings(
normalizeReviewerOutput(raw, files, diff),
scanMigrationIssues({ files, diff, contextFiles }),
);

let posted = false;
let postError: string | null = null;
Expand Down
127 changes: 127 additions & 0 deletions src/lib/hare/migrations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
parsePrismaSchema,
parseSqlTables,
scanMigrationIssues,
} from "./migrations.ts";

const SCHEMA = `
model Tenant {
id String @id @default(uuid()) @db.Uuid
chatLeads ChatLead[]
@@map("tenants")
}

model ChatLead {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id")
tenant Tenant @relation(fields: [tenantId], references: [id])
@@map("chat_leads")
}
`;

const BAD_SQL = `-- CreateTable
CREATE TABLE "chat_leads" (
"id" UUID NOT NULL,
"tenant_id" TEXT NOT NULL,
"email" TEXT NOT NULL,

CONSTRAINT "chat_leads_pkey" PRIMARY KEY ("id")
);

ALTER TABLE "chat_leads" ADD CONSTRAINT "chat_leads_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
`;

const GOOD_SQL = `CREATE TABLE "chat_leads" (
"id" UUID NOT NULL,
"tenant_id" UUID NOT NULL,
CONSTRAINT "chat_leads_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "chat_leads" ADD CONSTRAINT "chat_leads_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id");
`;

function diffFor(path: string, body: string): string {
const lines = body.split("\n");
return [
`diff --git a/${path} b/${path}`,
`--- /dev/null`,
`+++ b/${path}`,
`@@ -0,0 +1,${lines.length} @@`,
...lines.map((l) => `+${l}`),
].join("\n");
}

describe("parsePrismaSchema", () => {
it("reads @@map and @db.Uuid", () => {
const models = parsePrismaSchema(SCHEMA);
const tenants = models.get("tenants");
assert.ok(tenants);
const id = tenants.fields.get("id");
assert.equal(id?.native, "uuid");
const leads = models.get("chat_leads");
const tenantId = [...leads!.fields.values()].find((f) => f.column === "tenant_id");
assert.equal(tenantId?.native, "text");
});
});

describe("parseSqlTables", () => {
it("extracts columns and FKs", () => {
const tables = parseSqlTables(BAD_SQL);
const leads = tables.find((t) => t.name === "chat_leads" && t.columns.size > 0);
assert.ok(leads);
assert.equal(leads.columns.get("tenant_id")?.sqlType, "text");
const fk = tables.flatMap((t) => t.fks).find((f) => f.column === "tenant_id");
assert.equal(fk?.refTable, "tenants");
});
});

describe("scanMigrationIssues", () => {
it("flags TEXT FK to Prisma UUID id (JOSIE chat_leads case)", () => {
const path = "prisma/migrations/20260911000001_add_chat_leads/migration.sql";
const diff = diffFor(path, BAD_SQL);
const findings = scanMigrationIssues({
files: [{ path, status: "added", additions: 12, deletions: 0, patch: undefined }],
diff,
contextFiles: [{ path: "prisma/schema.prisma", content: SCHEMA }],
});
assert.ok(
findings.some((f) => /foreign key type mismatch/i.test(f.title) && f.severity === "major"),
JSON.stringify(findings.map((f) => f.title)),
);
});

it("is silent when SQL UUID matches Prisma @db.Uuid", () => {
const path = "prisma/migrations/20260911000001_add_chat_leads/migration.sql";
const findings = scanMigrationIssues({
files: [{ path, status: "added", additions: 8, deletions: 0 }],
diff: diffFor(path, GOOD_SQL),
contextFiles: [{ path: "prisma/schema.prisma", content: SCHEMA }],
});
assert.equal(
findings.filter((f) => /mismatch/i.test(f.title)).length,
0,
JSON.stringify(findings),
);
});

it("flags DROP TABLE as critical", () => {
const path = "prisma/migrations/20260101_drop/migration.sql";
const sql = `DROP TABLE "old_events";`;
const findings = scanMigrationIssues({
files: [{ path, status: "added", additions: 1, deletions: 0 }],
diff: diffFor(path, sql),
});
assert.equal(findings[0]?.severity, "critical");
});

it("flags SET NOT NULL without default", () => {
const path = "prisma/migrations/20260102_nn/migration.sql";
const sql = `ALTER TABLE "users" ALTER COLUMN "email" SET NOT NULL;`;
const findings = scanMigrationIssues({
files: [{ path, status: "added", additions: 1, deletions: 0 }],
diff: diffFor(path, sql),
});
assert.ok(findings.some((f) => /NOT NULL/i.test(f.title)));
});
});
Loading
Loading