Initial curated projectbrain source from 8a7b1e0651dbdab97d744cbd2e653ab3970ac6c5
This commit is contained in:
commit
5aa3e8d685
700 files changed
+132801
No files matched your search
@@ -0,0 +1,465 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
applyCheckFilters,
|
||||
applyReviewAction,
|
||||
checkFilterValues,
|
||||
convertCheck,
|
||||
findCheck,
|
||||
listChecksForProject,
|
||||
toReviewBoard,
|
||||
} from "../fixtures/checkFixtures.js";
|
||||
import { paginate, type PaginationInput } from "../fixtures/featureFixtures.js";
|
||||
import { findProject } from "../fixtures/projectFixtures.js";
|
||||
import { getRequestId } from "../plugins/requestId.js";
|
||||
import type {
|
||||
ApprovalState,
|
||||
CheckCategory,
|
||||
CheckItemDto,
|
||||
CheckListFilters,
|
||||
CheckPipelineStage,
|
||||
CheckSeverity,
|
||||
CheckStatus,
|
||||
ConvertTarget,
|
||||
ReviewAction,
|
||||
ReviewMutationResponseDto,
|
||||
} from "../types/api.js";
|
||||
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
||||
import {
|
||||
getIdempotencyKey,
|
||||
getIdempotentResponse,
|
||||
setIdempotentResponse,
|
||||
} from "../utils/idempotency.js";
|
||||
|
||||
const idSchema = z.string().min(1).regex(/^[a-z0-9-]+$/);
|
||||
|
||||
const projectParamsSchema = z.object({
|
||||
projectId: idSchema,
|
||||
});
|
||||
|
||||
const checkParamsSchema = z.object({
|
||||
projectId: idSchema,
|
||||
checkId: idSchema,
|
||||
});
|
||||
|
||||
const sourceParamsSchema = z.object({
|
||||
projectId: idSchema,
|
||||
sourceId: idSchema,
|
||||
});
|
||||
|
||||
const paginationQuerySchema = z.object({
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
pageSize: z.coerce.number().int().positive().max(100).default(25),
|
||||
cursor: z.string().optional(),
|
||||
sort: z.string().optional(),
|
||||
});
|
||||
|
||||
const checksQuerySchema = paginationQuerySchema.extend({
|
||||
status: z.string().optional(),
|
||||
severity: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
confidence: z.coerce.number().int().min(0).max(100).optional(),
|
||||
approvalState: z.enum(["pending", "approved", "rejected", "blocked"]).optional(),
|
||||
category: z.string().optional(),
|
||||
});
|
||||
|
||||
const reviewBodySchema = z.object({
|
||||
action: z.string().optional(),
|
||||
reason: z.string().optional(),
|
||||
targetEntityId: z.string().optional(),
|
||||
});
|
||||
|
||||
const convertBodySchema = z.object({
|
||||
target: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
reason: z.string().optional(),
|
||||
});
|
||||
|
||||
const reviewActions = ["accept", "reject", "ignore", "merge"] as const;
|
||||
const convertTargets = ["bug", "task", "memory_rule", "decision"] as const;
|
||||
|
||||
const sendValidationError = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
validation: z.ZodIssue[],
|
||||
) =>
|
||||
reply.status(400).send(
|
||||
errorEnvelope(
|
||||
"VALIDATION_ERROR",
|
||||
"Request validation failed",
|
||||
getRequestId(request),
|
||||
validation.map((issue) => ({
|
||||
field: issue.path.join("."),
|
||||
message: issue.message,
|
||||
code: issue.code,
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
const parseParams = <TSchema extends z.ZodTypeAny>(
|
||||
schema: TSchema,
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): z.output<TSchema> | undefined => {
|
||||
const parsed = schema.safeParse(request.params);
|
||||
|
||||
if (!parsed.success) {
|
||||
sendValidationError(request, reply, parsed.error.issues);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
};
|
||||
|
||||
const parseQuery = <TSchema extends z.ZodTypeAny>(
|
||||
schema: TSchema,
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): z.output<TSchema> | undefined => {
|
||||
const parsed = schema.safeParse(request.query);
|
||||
|
||||
if (!parsed.success) {
|
||||
sendValidationError(request, reply, parsed.error.issues);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
};
|
||||
|
||||
const sendProjectNotFound = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
projectId: string,
|
||||
) =>
|
||||
reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"PROJECT_NOT_FOUND",
|
||||
`Project '${projectId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
|
||||
const ensureProject = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
projectId: string,
|
||||
) => {
|
||||
const project = findProject(projectId);
|
||||
if (!project) {
|
||||
sendProjectNotFound(request, reply, projectId);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return project;
|
||||
};
|
||||
|
||||
const splitCsv = (value: string | undefined): string[] =>
|
||||
value
|
||||
?.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean) ?? [];
|
||||
|
||||
const filterKnownValues = <T extends string>(
|
||||
values: string[],
|
||||
allowed: readonly T[],
|
||||
): T[] => values.filter((value): value is T => allowed.includes(value as T));
|
||||
|
||||
const sortByField = <T extends Record<string, unknown>>(
|
||||
items: T[],
|
||||
sort: string | undefined,
|
||||
): T[] => {
|
||||
if (!sort) return items;
|
||||
const [field, direction = "asc"] = sort.split(":");
|
||||
if (!field) return items;
|
||||
|
||||
return [...items].sort((left, right) => {
|
||||
const leftValue =
|
||||
field === "confidence"
|
||||
? (left.trust as { confidence?: number })?.confidence
|
||||
: left[field];
|
||||
const rightValue =
|
||||
field === "confidence"
|
||||
? (right.trust as { confidence?: number })?.confidence
|
||||
: right[field];
|
||||
if (leftValue === rightValue) return 0;
|
||||
const comparison = String(leftValue ?? "").localeCompare(
|
||||
String(rightValue ?? ""),
|
||||
undefined,
|
||||
{ numeric: true },
|
||||
);
|
||||
return direction === "desc" ? -comparison : comparison;
|
||||
});
|
||||
};
|
||||
|
||||
type ChecksQuery = z.output<typeof checksQuerySchema>;
|
||||
type PaginationQuery = z.output<typeof paginationQuerySchema>;
|
||||
|
||||
const toFilters = (query: ChecksQuery): CheckListFilters => ({
|
||||
...(query.status
|
||||
? {
|
||||
status: filterKnownValues(
|
||||
splitCsv(query.status),
|
||||
checkFilterValues.status,
|
||||
) as CheckStatus[],
|
||||
}
|
||||
: {}),
|
||||
...(query.severity
|
||||
? {
|
||||
severity: filterKnownValues(
|
||||
splitCsv(query.severity),
|
||||
checkFilterValues.severity,
|
||||
) as CheckSeverity[],
|
||||
}
|
||||
: {}),
|
||||
...(query.source
|
||||
? {
|
||||
source: filterKnownValues(
|
||||
splitCsv(query.source),
|
||||
checkFilterValues.source,
|
||||
) as CheckPipelineStage[],
|
||||
}
|
||||
: {}),
|
||||
...(query.category
|
||||
? {
|
||||
category: filterKnownValues(
|
||||
splitCsv(query.category),
|
||||
checkFilterValues.category,
|
||||
) as CheckCategory[],
|
||||
}
|
||||
: {}),
|
||||
...(query.confidence !== undefined ? { confidence: query.confidence } : {}),
|
||||
...(query.approvalState
|
||||
? { approvalState: query.approvalState as ApprovalState }
|
||||
: {}),
|
||||
...(query.cursor ? { cursor: query.cursor } : {}),
|
||||
});
|
||||
|
||||
const toPaginationInput = (
|
||||
query: PaginationQuery,
|
||||
filters: PaginationInput["filters"],
|
||||
): PaginationInput => ({
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
cursor: query.cursor,
|
||||
sort: query.sort,
|
||||
filters,
|
||||
});
|
||||
|
||||
const sendCheckNotFound = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
checkId: string,
|
||||
) =>
|
||||
reply.status(404).send(
|
||||
errorEnvelope(
|
||||
"CHECK_NOT_FOUND",
|
||||
`Check '${checkId}' was not found.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
|
||||
const getRequiredIdempotencyKey = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): string | undefined => {
|
||||
const rawKey = request.headers["idempotency-key"];
|
||||
const idempotencyKey = Array.isArray(rawKey) ? rawKey[0] : rawKey;
|
||||
|
||||
if (!idempotencyKey?.trim()) {
|
||||
reply.status(400).send(
|
||||
errorEnvelope(
|
||||
"IDEMPOTENCY_KEY_REQUIRED",
|
||||
"Idempotency-Key header is required.",
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return idempotencyKey;
|
||||
};
|
||||
|
||||
const sendReplayOrUndefined = (
|
||||
reply: FastifyReply,
|
||||
key: string,
|
||||
): boolean => {
|
||||
const replay = getIdempotentResponse<unknown>(key);
|
||||
if (!replay) return false;
|
||||
|
||||
reply.status(replay.statusCode).send(replay.body);
|
||||
return true;
|
||||
};
|
||||
|
||||
export const registerCheckRoutes = async (app: FastifyInstance) => {
|
||||
app.get("/api/projects/:projectId/checks", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
|
||||
const query = parseQuery(checksQuerySchema, request, reply);
|
||||
if (!query) return reply;
|
||||
|
||||
const filters = toFilters(query);
|
||||
const checks = sortByField(
|
||||
applyCheckFilters(listChecksForProject(params.projectId), filters),
|
||||
query.sort,
|
||||
);
|
||||
|
||||
return successEnvelope(
|
||||
paginate(checks, toPaginationInput(query, filters)),
|
||||
getRequestId(request),
|
||||
);
|
||||
});
|
||||
|
||||
app.get("/api/projects/:projectId/review-board", async (request, reply) => {
|
||||
const params = parseParams(projectParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
|
||||
return successEnvelope(
|
||||
toReviewBoard(listChecksForProject(params.projectId)),
|
||||
getRequestId(request),
|
||||
);
|
||||
});
|
||||
|
||||
app.get("/api/projects/:projectId/checks/:checkId", async (request, reply) => {
|
||||
const params = parseParams(checkParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
|
||||
const check = findCheck(params.projectId, params.checkId);
|
||||
if (!check) return sendCheckNotFound(request, reply, params.checkId);
|
||||
|
||||
return successEnvelope(check, getRequestId(request));
|
||||
});
|
||||
|
||||
app.patch(
|
||||
"/api/projects/:projectId/checks/:sourceId/review",
|
||||
async (request, reply) => {
|
||||
const params = parseParams(sourceParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
|
||||
const idempotencyKey = getRequiredIdempotencyKey(request, reply);
|
||||
if (!idempotencyKey) return reply;
|
||||
|
||||
const cacheKey = getIdempotencyKey(
|
||||
request.method,
|
||||
request.url,
|
||||
idempotencyKey,
|
||||
);
|
||||
if (sendReplayOrUndefined(reply, cacheKey)) return reply;
|
||||
|
||||
const parsedBody = reviewBodySchema.safeParse(request.body);
|
||||
if (!parsedBody.success) {
|
||||
sendValidationError(request, reply, parsedBody.error.issues);
|
||||
return reply;
|
||||
}
|
||||
|
||||
const action = parsedBody.data.action as ReviewAction | undefined;
|
||||
if (!action || !reviewActions.includes(action)) {
|
||||
return reply.status(400).send(
|
||||
errorEnvelope(
|
||||
"REVIEW_ACTION_INVALID",
|
||||
"Review action must be accept, reject, ignore, or merge.",
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "merge" && !parsedBody.data.targetEntityId?.trim()) {
|
||||
return reply.status(409).send(
|
||||
errorEnvelope(
|
||||
"REVIEW_ACTION_CONFLICT",
|
||||
"targetEntityId is required when action is merge.",
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!findCheck(params.projectId, params.sourceId)) {
|
||||
return sendCheckNotFound(request, reply, params.sourceId);
|
||||
}
|
||||
|
||||
const response = applyReviewAction(
|
||||
params.projectId,
|
||||
params.sourceId,
|
||||
action,
|
||||
parsedBody.data.reason,
|
||||
parsedBody.data.targetEntityId,
|
||||
);
|
||||
if (!response) return sendCheckNotFound(request, reply, params.sourceId);
|
||||
|
||||
const body = successEnvelope<ReviewMutationResponseDto>(
|
||||
response,
|
||||
getRequestId(request),
|
||||
);
|
||||
setIdempotentResponse(cacheKey, { statusCode: 200, body });
|
||||
return reply.status(200).send(body);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/projects/:projectId/checks/:sourceId/convert",
|
||||
async (request, reply) => {
|
||||
const params = parseParams(sourceParamsSchema, request, reply);
|
||||
if (!params) return reply;
|
||||
if (!ensureProject(request, reply, params.projectId)) return reply;
|
||||
|
||||
const idempotencyKey = getRequiredIdempotencyKey(request, reply);
|
||||
if (!idempotencyKey) return reply;
|
||||
|
||||
const cacheKey = getIdempotencyKey(
|
||||
request.method,
|
||||
request.url,
|
||||
idempotencyKey,
|
||||
);
|
||||
if (sendReplayOrUndefined(reply, cacheKey)) return reply;
|
||||
|
||||
const parsedBody = convertBodySchema.safeParse(request.body);
|
||||
if (!parsedBody.success) {
|
||||
sendValidationError(request, reply, parsedBody.error.issues);
|
||||
return reply;
|
||||
}
|
||||
|
||||
const target = parsedBody.data.target as ConvertTarget | undefined;
|
||||
if (!target || !convertTargets.includes(target)) {
|
||||
return reply.status(400).send(
|
||||
errorEnvelope(
|
||||
"CONVERT_TARGET_INVALID",
|
||||
"Convert target must be bug, task, memory_rule, or decision.",
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!findCheck(params.projectId, params.sourceId)) {
|
||||
return sendCheckNotFound(request, reply, params.sourceId);
|
||||
}
|
||||
|
||||
const response = convertCheck(
|
||||
params.projectId,
|
||||
params.sourceId,
|
||||
target,
|
||||
parsedBody.data.title,
|
||||
parsedBody.data.reason,
|
||||
);
|
||||
if (!response) {
|
||||
return reply.status(409).send(
|
||||
errorEnvelope(
|
||||
"CONVERSION_FAILED",
|
||||
`Conversion failed for check '${params.sourceId}'.`,
|
||||
getRequestId(request),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const body = successEnvelope<ReviewMutationResponseDto>(
|
||||
response,
|
||||
getRequestId(request),
|
||||
);
|
||||
setIdempotentResponse(cacheKey, { statusCode: 200, body });
|
||||
return reply.status(200).send(body);
|
||||
},
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user