Files
PaperClipAI/packages/shared/src/validators/chat-github.ts
T
DottaandPaperclip 5c807d11fa fix(github): prevent duplicate review score headings (#18321)
Clarify the shared GitHub review submission contract for new and existing bots.
Remove copied score headings while preserving the structured score and saved assessment.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-30 10:19:31 -05:00

163 lines
5.3 KiB
TypeScript

import { z } from "zod";
import { GITHUB_REVIEW_EVENTS } from "../types/chat-github.js";
export const githubIdSchema = z.string().regex(/^[1-9][0-9]{0,19}$/);
export const githubCommitSchema = z
.string()
.regex(/^[a-f0-9]{40}$/i)
.transform((value) => value.toLowerCase());
const shortList = z.array(z.string().trim().min(1).max(256)).max(100);
const prompts = z
.object({
opened: z.string().max(12000),
synchronize: z.string().max(12000),
reopened: z.string().max(12000),
ready_for_review: z.string().max(12000),
mention: z.string().max(12000),
comment: z.string().max(12000),
})
.strict();
export const githubReviewPolicySchema = z
.object({
invocation: z.enum(["mentions_only", "linked_authors", "allowed_authors"]),
events: z
.array(z.enum(GITHUB_REVIEW_EVENTS))
.max(GITHUB_REVIEW_EVENTS.length),
issueOpened: z.boolean().optional(),
issueOpenedInstructions: z.string().max(12000).optional(),
reviewDrafts: z.boolean(),
reviewBotAuthors: z.boolean(),
includeAuthors: shortList,
excludeAuthors: shortList,
targetBranches: shortList,
excludedBranches: shortList,
requiredLabels: shortList,
excludedLabels: shortList,
ignoredPaths: shortList,
instructions: z.string().max(24000),
prompts,
findingCategories: shortList,
minimumCommentSeverity: z.enum(["info", "warning", "error"]),
publishSummary: z.boolean(),
publishInline: z.boolean(),
allowApprove: z.boolean(),
allowRequestChanges: z.boolean(),
ratingThreshold: z.union([
z.literal(1),
z.literal(2),
z.literal(3),
z.literal(4),
z.literal(5),
z.null(),
]),
})
.strict();
const person = {
githubUserId: githubIdSchema,
login: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9-]{0,38}(?:\[bot\])?$/),
// Accept legacy saved configurations; automatic scheduling uses author filters.
automaticReviews: z.boolean().optional(),
};
export const githubAllowedPersonSchema = z.discriminatedUnion("kind", [
z
.object({ ...person, kind: z.literal("member"), userId: z.string().min(1) })
.strict(),
z
.object({
...person,
kind: z.literal("guest"),
sponsorUserId: z.string().min(1),
permissionProfile: z.literal("restricted"),
})
.strict(),
]);
export const githubChatConfigurationSchema = z
.object({
version: z.literal(1),
toolsEnabled: z.boolean(),
responsibleUserId: z.string().min(1),
memberAccess: z.enum(["all_linked", "selected"]),
people: z.array(githubAllowedPersonSchema).max(500),
defaults: githubReviewPolicySchema,
repositories: z
.record(githubIdSchema, githubReviewPolicySchema.partial())
.refine((value) => Object.keys(value).length <= 500),
})
.strict()
.superRefine((value, ctx) => {
if (
new Set(value.people.map((person) => person.githubUserId)).size !==
value.people.length
)
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["people"],
message: "Each GitHub account can be allowed only once.",
});
});
export const updateGitHubChatConfigurationSchema = z
.object({
expectedRevision: z.number().int().nonnegative(),
configuration: githubChatConfigurationSchema,
})
.strict();
export const githubReviewAssessmentSchema = z
.object({
reviewedCommit: githubCommitSchema,
score: z.union([
z.literal(0),
z.literal(1),
z.literal(2),
z.literal(3),
z.literal(4),
z.literal(5),
]),
complete: z.boolean(),
summary: z.string().trim().min(1).max(24000).describe(
"Review body only: describe the change, findings, and verdict. Paperclip adds the bot-name/score heading and reviewed-commit, coverage, and task/run footer; do not include them here or copy a previously published comment.",
),
rationale: z.string().trim().min(1).max(12000).describe(
"Explain the score, supporting evidence, and remaining risk. Published after summary. Do not repeat the summary, score heading, or generated footer; put reviewed paths and limitations in coverage.",
),
coverage: z
.object({
reviewedPaths: z.array(z.string().min(1).max(1024)).max(5000),
omittedPaths: z.array(z.string().min(1).max(1024)).max(5000),
limitations: shortList,
})
.strict(),
findings: z
.array(
z
.object({
key: z.string().min(1).max(160),
path: z.string().min(1).max(1024),
line: z.number().int().positive(),
side: z.enum(["LEFT", "RIGHT"]),
severity: z.enum(["info", "warning", "error"]),
category: z.string().min(1).max(80),
body: z.string().trim().min(1).max(12000),
})
.strict(),
)
.max(300),
})
.strict()
.superRefine((value, ctx) => {
if (value.complete && value.coverage.reviewedPaths.length === 0)
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["coverage"],
message: "A complete assessment must identify reviewed files.",
});
const keys = value.findings.map(
(f) => `${f.key}:${f.path}:${f.side}:${f.line}`,
);
if (new Set(keys).size !== keys.length)
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["findings"],
message: "Duplicate findings are not allowed.",
});
});