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
72 changes: 63 additions & 9 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,44 @@ async function recordRouteProductUsage(
}).catch(() => undefined);
}

const QUEUE_INTELLIGENCE_MAX_BODY_BYTES = 1024 * 1024;
const QUEUE_INTELLIGENCE_MAX_PULL_REQUESTS = 250;
const QUEUE_INTELLIGENCE_MAX_AUTHOR_LENGTH = 100;
const QUEUE_INTELLIGENCE_MAX_TITLE_LENGTH = 300;
const QUEUE_INTELLIGENCE_MAX_BODY_LENGTH = 4000;
const QUEUE_INTELLIGENCE_MAX_DUPLICATE_CANDIDATES = 25;

function parsePositiveInt(value: string | null | undefined): number | null {
if (!value) return null;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) return null;
return parsed;
}

async function readRequestBodyWithLimit(request: Request, maxBytes: number): Promise<string | null> {
const stream = request.body;
if (!stream) return "";
const reader = stream.getReader();
const decoder = new TextDecoder();
const chunks: string[] = [];
let total = 0;

while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
total += value.byteLength;
if (total > maxBytes) {
await reader.cancel().catch(() => undefined);
return null;
}
chunks.push(decoder.decode(value, { stream: true }));
}

chunks.push(decoder.decode());
return chunks.join("");
}

const MAX_LOCAL_BRANCH_REF_CHARS = 256;
const MAX_LOCAL_BRANCH_TEXT_CHARS = 4000;
const PR_VISIBILITY_SKIP_REASONS = [
Expand Down Expand Up @@ -2225,23 +2263,39 @@ export function createApp() {
});

app.post("/v1/internal/queue-intelligence", async (c) => {
const body = await c.req.json().catch(() => null);
if (!body || !Array.isArray(body.pullRequests)) {
const contentLength = parsePositiveInt(c.req.header("content-length"));
if (contentLength !== null && contentLength > QUEUE_INTELLIGENCE_MAX_BODY_BYTES) {
return c.json({ error: "payload_too_large", maxBytes: QUEUE_INTELLIGENCE_MAX_BODY_BYTES }, 413);
}

const rawBody = await readRequestBodyWithLimit(c.req.raw, QUEUE_INTELLIGENCE_MAX_BODY_BYTES);
if (rawBody === null) {
return c.json({ error: "payload_too_large", maxBytes: QUEUE_INTELLIGENCE_MAX_BODY_BYTES }, 413);
}

let body: unknown;
try {
body = JSON.parse(rawBody);
} catch {
body = null;
}
if (!body || typeof body !== "object" || !Array.isArray((body as { pullRequests?: unknown }).pullRequests)) {
return c.json({ error: "invalid_request", detail: "pullRequests array required" }, 400);
}
const queueBody = body as { pullRequests: unknown[]; repoContext?: unknown };
const prSchema = z.object({
number: z.number().int().positive(),
author: z.string(),
author: z.string().max(QUEUE_INTELLIGENCE_MAX_AUTHOR_LENGTH),
authorRole: z.enum(["first-time", "contributor", "maintainer"] as [AuthorRole, ...AuthorRole[]]),
isConfirmedMiner: z.boolean(),
linkedIssue: z.object({ qualityScore: z.number().min(0).max(1) }).nullable(),
checksStatus: z.enum(["passing", "failing", "pending"] as [ChecksStatus, ...ChecksStatus[]]),
isStale: z.boolean(),
additions: z.number().int().nonnegative(),
deletions: z.number().int().nonnegative(),
title: z.string(),
body: z.string(),
duplicateCandidates: z.array(z.number().int().positive()),
title: z.string().max(QUEUE_INTELLIGENCE_MAX_TITLE_LENGTH),
body: z.string().max(QUEUE_INTELLIGENCE_MAX_BODY_LENGTH),
duplicateCandidates: z.array(z.number().int().positive()).max(QUEUE_INTELLIGENCE_MAX_DUPLICATE_CANDIDATES),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
});
Expand All @@ -2250,10 +2304,10 @@ export function createApp() {
avgReviewTimeDays: z.number().nonnegative(),
maintainerWorkload: z.number().min(0).max(1),
});
const prsResult = z.array(prSchema).safeParse(body.pullRequests);
const prsResult = z.array(prSchema).max(QUEUE_INTELLIGENCE_MAX_PULL_REQUESTS).safeParse(queueBody.pullRequests);
if (!prsResult.success) return c.json({ error: "invalid_request", issues: prsResult.error.issues }, 400);
const repoContext = repoContextSchema.safeParse(body.repoContext).success
? repoContextSchema.parse(body.repoContext)
const repoContext = repoContextSchema.safeParse(queueBody.repoContext).success
? repoContextSchema.parse(queueBody.repoContext)
: { totalOpenPRs: 0, avgReviewTimeDays: 0, maintainerWorkload: 0 };
const result = await analyzePRQueue(prsResult.data, repoContext);
const recommendations: Record<number, string> = {};
Expand Down
3 changes: 2 additions & 1 deletion src/auth/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ export function routeClassForPath(path: string): RateLimitClass {
path.includes("/internal/jobs/generate-signal-snapshots") ||
path.includes("/internal/jobs/build-contributor-decision-packs") ||
path.includes("/internal/jobs/refresh-upstream-drift") ||
path.includes("/internal/jobs/file-upstream-drift-issues")
path.includes("/internal/jobs/file-upstream-drift-issues") ||
path.includes("/internal/queue-intelligence")
) {
return "expensive";
}
Expand Down
94 changes: 94 additions & 0 deletions test/integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -988,6 +988,100 @@ describe("api routes", () => {
);
expect(invalidRepoContext.status).toBe(200);

const boundedQueuePr = {
number: 1,
author: "alice",
authorRole: "contributor",
isConfirmedMiner: true,
linkedIssue: { qualityScore: 0.9 },
checksStatus: "passing",
isStale: false,
additions: 50,
deletions: 10,
title: "Fix cache",
body: "Fixes #1",
duplicateCandidates: [],
createdAt: new Date(Date.now() - 5 * 86400000).toISOString(),
lastUpdatedAt: new Date(Date.now() - 3600000).toISOString(),
};

const tooManyQueuePRs = await app.request(
"/v1/internal/queue-intelligence",
{
method: "POST",
headers: internalHeaders(env),
body: JSON.stringify({ pullRequests: Array.from({ length: 251 }, (_, index) => ({ ...boundedQueuePr, number: index + 1 })) }),
},
env,
);
expect(tooManyQueuePRs.status).toBe(400);

const oversizedQueueFields = await app.request(
"/v1/internal/queue-intelligence",
{
method: "POST",
headers: internalHeaders(env),
body: JSON.stringify({
pullRequests: [
{
...boundedQueuePr,
author: "a".repeat(101),
title: "t".repeat(301),
body: "b".repeat(4001),
duplicateCandidates: Array.from({ length: 26 }, (_, index) => index + 1),
},
],
}),
},
env,
);
expect(oversizedQueueFields.status).toBe(400);

const oversizedQueuePayload = await app.request(
"/v1/internal/queue-intelligence",
{
method: "POST",
headers: { ...internalHeaders(env), "content-length": "1048577" },
body: JSON.stringify({}),
},
env,
);
expect(oversizedQueuePayload.status).toBe(413);
await expect(oversizedQueuePayload.json()).resolves.toMatchObject({ error: "payload_too_large", maxBytes: 1048576 });

const oversizedQueueStream = await app.request(
"/v1/internal/queue-intelligence",
{
method: "POST",
headers: internalHeaders(env),
body: "x".repeat(1048577),
},
env,
);
expect(oversizedQueueStream.status).toBe(413);
await expect(oversizedQueueStream.json()).resolves.toMatchObject({ error: "payload_too_large", maxBytes: 1048576 });

const invalidQueueContentLength = await app.request(
"/v1/internal/queue-intelligence",
{
method: "POST",
headers: { ...internalHeaders(env), "content-length": "not-a-number" },
body: JSON.stringify({ pullRequests: [boundedQueuePr] }),
},
env,
);
expect(invalidQueueContentLength.status).toBe(200);

const missingQueueBody = await app.request(
"/v1/internal/queue-intelligence",
{
method: "POST",
headers: internalHeaders(env),
},
env,
);
expect(missingQueueBody.status).toBe(400);

const localBranchAnalysis = await app.request(
"/v1/local/branch-analysis",
{
Expand Down
1 change: 1 addition & 0 deletions test/unit/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ describe("private-beta auth and rate limiting", () => {
expect(routeClassForPath("/v1/internal/jobs/generate-signal-snapshots")).toBe("expensive");
expect(routeClassForPath("/v1/internal/jobs/build-contributor-decision-packs")).toBe("expensive");
expect(routeClassForPath("/v1/internal/jobs/refresh-upstream-drift")).toBe("expensive");
expect(routeClassForPath("/v1/internal/queue-intelligence")).toBe("expensive");
expect(routeClassForPath("/v1/repos")).toBe("normal");
});

Expand Down