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
16 changes: 12 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@
"@libpg-query/parser": "^17.6.3",
"@opentelemetry/api": "^1.9.0",
"@pgsql/types": "^17.6.2",
"@query-doctor/core": "^0.8.9",
"@query-doctor/core": "^0.9.0",
"async-sema": "^3.1.1",
"capnweb": "^0.7.0",
"dedent": "^1.7.1",
"fast-csv": "^5.0.5",
"fastify": "^5.7.4",
Expand Down
94 changes: 36 additions & 58 deletions src/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,79 +1,57 @@
import { test, expect, vi, afterEach } from "vitest";
import { fetchAnalyzerConfig, DEFAULT_CONFIG } from "./config.ts";

afterEach(() => {
vi.restoreAllMocks();
});

test("returns parsed config from successful response", async () => {
import { test, expect, vi } from "vitest";
import { DEFAULT_CONFIG } from "./config.ts";
import type { ServerApi } from "@query-doctor/core";
import type { RpcStub } from "capnweb";

function makeApi(overrides: Partial<RpcStub<ServerApi>> = {}): RpcStub<ServerApi> {
return overrides as RpcStub<ServerApi>;
}

async function resolveConfig(
api: RpcStub<ServerApi>,
repo: string | undefined,
branch: string,
) {
if (!repo) return DEFAULT_CONFIG;
return api.getRepoConfig(repo, branch).catch(() => DEFAULT_CONFIG);
}

test("returns config from successful getRepoConfig call", async () => {
const config = {
minimumCost: 100,
regressionThreshold: 0.5,
ignoredQueryHashes: ["abc123"],
lastSeenQueryHashes: [],
acknowledgedQueryHashes: [],
comparisonBranch: undefined,
};
vi.spyOn(globalThis, "fetch").mockResolvedValue(
Response.json(config, { status: 200 }),
);
const api = makeApi({ getRepoConfig: vi.fn().mockResolvedValue(config) });

const result = await fetchAnalyzerConfig("https://api.example.com", "my/repo");
const result = await resolveConfig(api, "my/repo", "main");
expect(result).toEqual(config);
});

test("returns defaults when response is not ok", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("Not Found", { status: 404 }),
);
test("returns defaults when getRepoConfig throws", async () => {
const api = makeApi({
getRepoConfig: vi.fn().mockRejectedValue(new Error("rpc error")),
});

const result = await fetchAnalyzerConfig("https://api.example.com", "my/repo");
const result = await resolveConfig(api, "my/repo", "main");
expect(result).toEqual(DEFAULT_CONFIG);
});

test("returns defaults when fetch throws", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network error"));
test("returns defaults when repo is undefined", async () => {
const api = makeApi({ getRepoConfig: vi.fn() });

const result = await fetchAnalyzerConfig("https://api.example.com", "my/repo");
const result = await resolveConfig(api, undefined, "main");
expect(result).toEqual(DEFAULT_CONFIG);
expect(api.getRepoConfig).not.toHaveBeenCalled();
});

test("constructs correct URL with trailing slash stripped", async () => {
const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValue(
Response.json(DEFAULT_CONFIG, { status: 200 }),
);

await fetchAnalyzerConfig("https://api.example.com/", "org/repo");
expect(mockFetch).toHaveBeenCalledWith(
"https://api.example.com/ci/repos/org%2Frepo/config",
expect.any(Object),
);
});

test("encodes repo name in URL", async () => {
const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValue(
Response.json(DEFAULT_CONFIG, { status: 200 }),
);

await fetchAnalyzerConfig("https://api.example.com", "org/repo with spaces");
expect(mockFetch).toHaveBeenCalledWith(
"https://api.example.com/ci/repos/org%2Frepo%20with%20spaces/config",
expect.any(Object),
);
});

test("passes through partial response with missing optional fields", async () => {
const partial = {
minimumCost: 50,
regressionThreshold: 0.1,
ignoredQueryHashes: [],
// all required fields present
};
vi.spyOn(globalThis, "fetch").mockResolvedValue(
Response.json(partial, { status: 200 }),
);
test("passes repo and branch to getRepoConfig", async () => {
const getRepoConfig = vi.fn().mockResolvedValue(DEFAULT_CONFIG);
const api = makeApi({ getRepoConfig });

const result = await fetchAnalyzerConfig("https://api.example.com", "my/repo");
expect(result.minimumCost).toBe(50);
expect(result.regressionThreshold).toBe(0.1);
expect(result.ignoredQueryHashes).toEqual([]);
await resolveConfig(api, "org/repo", "feat/my-branch");
expect(getRepoConfig).toHaveBeenCalledWith("org/repo", "feat/my-branch");
});
31 changes: 0 additions & 31 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,34 +12,3 @@ export const DEFAULT_CONFIG: AnalyzerConfig = {
ignoredQueryHashes: [],
acknowledgedQueryHashes: [],
};

export async function fetchAnalyzerConfig(
endpoint: string,
repo: string,
): Promise<AnalyzerConfig> {
const url = `${endpoint.replace(/\/$/, "")}/ci/repos/${encodeURIComponent(repo)}/config`;
console.log(`Fetching config from ${url}`);
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
console.warn(`Config fetch returned ${response.status}, using defaults`);
return DEFAULT_CONFIG;
}
const data = (await response.json()) as Partial<AnalyzerConfig>;
console.log(
`Config loaded: minimumCost=${data.minimumCost}, regressionThreshold=${data.regressionThreshold}, ignoredHashes=${data.ignoredQueryHashes?.length ?? 0}, acknowledgedHashes=${data.acknowledgedQueryHashes?.length ?? 0}, comparisonBranch=${data.comparisonBranch ?? "(same branch)"}`,
);
return {
minimumCost: data.minimumCost ?? 0,
regressionThreshold: data.regressionThreshold ?? 0,
ignoredQueryHashes: data.ignoredQueryHashes ?? [],
acknowledgedQueryHashes: data.acknowledgedQueryHashes ?? [],
comparisonBranch: data.comparisonBranch,
};
} catch (err) {
console.warn(`Failed to fetch config: ${err}. Using defaults`);
return DEFAULT_CONFIG;
}
}
8 changes: 6 additions & 2 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@ const envSchema = z.object({
GITHUB_TOKEN: z.string().optional(),
LOG_PATH: z.string().optional(),
POSTGRES_URL: z.string().optional(),
SOURCE_DATABASE_URL: z.string().optional(),
SOURCE_DATABASE_URL: z.string({
error:
"SOURCE_DATABASE_URL is required. Set it to the connection string of the database you want to analyze.",
}),
DEBUG: z.stringbool().default(false),
STATISTICS_PATH: z.string().optional(),

SITE_API_ENDPOINT: z.url().optional(),
SITE_API_ENDPOINT: z.url().default("https://api.querydoctor.com"),
TOKEN: z.string().optional(),
GITHUB_REPOSITORY: z.string().optional(),
});

Expand Down
58 changes: 45 additions & 13 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,14 @@ import {
postToSiteApi,
} from "./reporters/site-api.ts";
import { formatCost, queryPreview } from "./reporters/github/github.ts";
import { DEFAULT_CONFIG, fetchAnalyzerConfig } from "./config.ts";
import { DEFAULT_CONFIG, type AnalyzerConfig } from "./config.ts";
import { ApiClient, hookUpApiReporter } from "./remote/api-client.ts";
import { Remote } from "./remote/remote.ts";
import { ConnectionManager } from "./sync/connection-manager.ts";
import type { RpcStub } from "capnweb";
import type { ServerApi } from "@query-doctor/core";

const INVALID_TOKEN_ERROR = "Unauthorized"

async function runInCI(
targetPostgresUrl: Connectable,
Expand All @@ -25,17 +32,35 @@ async function runInCI(
const branch =
process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || "";

const config =
siteApiEndpoint && repo
? await fetchAnalyzerConfig(siteApiEndpoint, repo)
: DEFAULT_CONFIG;
const remote = new Remote(
targetPostgresUrl,
ConnectionManager.forLocalDatabase(),
ConnectionManager.forRemoteDatabase(),
{ disableQueryLoader: true },
);

if (!env.TOKEN) {
throw new Error("CI mode cannot be run without a TOKEN variable provided")
}

let api = await ApiClient.connect(siteApiEndpoint, env.TOKEN, { kind: "ci", branch, sha: "" }, remote);

const config = repo
? await api.getRepoConfig(repo, branch).catch(
(err) => {
log.warn(`Failed to fetch repo config via RPC: ${err}. Using defaults`, "main");
return DEFAULT_CONFIG;
},
)
: DEFAULT_CONFIG;

const runner = await Runner.build({
targetPostgresUrl,
sourcePostgresUrl,
logPath,
maxCost,
ignoredQueryHashes: config.ignoredQueryHashes,
remote,
});
let allResults: QueryProcessResult[];
let reportContext;
Expand Down Expand Up @@ -136,14 +161,25 @@ async function runOutsideCI() {
"main",
);
if (!env.POSTGRES_URL) {
core.setFailed("POSTGRES_URL environment variable is not set");
process.exit(1);
throw new Error("POSTGRES_URL environment variable is not set. If you're seeing this inside Docker something has gone wrong");
}
if (!env.TOKEN) {
throw new Error("TOKEN environment variable is not set\nYou probably forgot to pass a `-e TOKEN=...` parameter to the docker container");
}
const sourceDb = Connectable.fromString(env.SOURCE_DATABASE_URL)
const remote = new Remote(
Connectable.fromString(env.POSTGRES_URL),
ConnectionManager.forLocalDatabase(),
ConnectionManager.forRemoteDatabase(),
{ disableQueryLoader: false },
sourceDb,
);
ApiClient.connectWithReconnect(env.SITE_API_ENDPOINT, env.TOKEN, { kind: "persistent" }, remote);
const server = await createServer(
env.HOST,
env.PORT,
Connectable.fromString(env.POSTGRES_URL),
env.SOURCE_DATABASE_URL ? Connectable.fromString(env.SOURCE_DATABASE_URL) : undefined,
remote,
sourceDb
);

const shutdown = async () => {
Expand All @@ -162,10 +198,6 @@ async function main() {
core.setFailed("POSTGRES_URL environment variable is not set");
process.exit(1);
}
if (!env.SOURCE_DATABASE_URL) {
core.setFailed("SOURCE_DATABASE_URL environment variable is not set");
process.exit(1);
}
if (!env.LOG_PATH) {
core.setFailed("LOG_PATH environment variable is not set");
process.exit(1);
Expand Down
Loading
Loading