Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
308 lines
9.3 KiB
TypeScript
308 lines
9.3 KiB
TypeScript
export interface CodebuddyElicitationOption {
|
|
key: string;
|
|
value: unknown;
|
|
label: string;
|
|
}
|
|
|
|
export interface CodebuddyElicitationField {
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
type: string;
|
|
required: boolean;
|
|
defaultValue?: unknown;
|
|
options: CodebuddyElicitationOption[];
|
|
minimum?: number;
|
|
maximum?: number;
|
|
minLength?: number;
|
|
maxLength?: number;
|
|
minItems?: number;
|
|
maxItems?: number;
|
|
format?: string;
|
|
}
|
|
|
|
export type CodebuddyElicitationValidationCode =
|
|
| 'required'
|
|
| 'invalidType'
|
|
| 'integer'
|
|
| 'notInteger'
|
|
| 'minimum'
|
|
| 'maximum'
|
|
| 'minLength'
|
|
| 'maxLength'
|
|
| 'minItems'
|
|
| 'maxItems'
|
|
| 'format'
|
|
| 'option';
|
|
|
|
export interface CodebuddyElicitationValidationIssue {
|
|
fieldId: string;
|
|
fieldTitle: string;
|
|
code: CodebuddyElicitationValidationCode;
|
|
limit?: number;
|
|
format?: string;
|
|
}
|
|
|
|
const EMAIL_PATTERN =
|
|
/^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i;
|
|
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
const DATE_TIME_PATTERN =
|
|
/^(\d{4}-\d{2}-\d{2})[Tt ](\d{2}):(\d{2}):(\d{2}(?:\.\d+)?)(?:[Zz]|([+-])(\d{2})(?::?(\d{2}))?)$/;
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: {};
|
|
}
|
|
|
|
function optionalNumber(value: unknown): number | undefined {
|
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
}
|
|
|
|
function fieldOptions(schema: Record<string, unknown>): CodebuddyElicitationOption[] {
|
|
if (Array.isArray(schema.enum)) {
|
|
const labels = Array.isArray(schema.enumNames) ? schema.enumNames : [];
|
|
return schema.enum.map((value, index) => ({
|
|
key: `enum:${index}`,
|
|
value,
|
|
label: String(labels[index] ?? value),
|
|
}));
|
|
}
|
|
const variants = Array.isArray(schema.oneOf)
|
|
? schema.oneOf
|
|
: Array.isArray(schema.anyOf)
|
|
? schema.anyOf
|
|
: [];
|
|
return variants.flatMap((variant, index) => {
|
|
const option = asRecord(variant);
|
|
return !Object.prototype.hasOwnProperty.call(option, 'const')
|
|
? []
|
|
: [{
|
|
key: `variant:${index}`,
|
|
value: option.const,
|
|
label: String(option.title ?? option.const),
|
|
}];
|
|
});
|
|
}
|
|
|
|
export function parseCodebuddyElicitationFields(
|
|
requestedSchema: unknown,
|
|
): CodebuddyElicitationField[] {
|
|
const schema = asRecord(requestedSchema);
|
|
const properties = asRecord(schema.properties);
|
|
const required = new Set(
|
|
Array.isArray(schema.required) ? schema.required.map((value) => String(value)) : [],
|
|
);
|
|
return Object.entries(properties).map(([id, rawField]) => {
|
|
const field = asRecord(rawField);
|
|
const type = String(field.type || 'string');
|
|
return {
|
|
id,
|
|
title: String(field.title || id),
|
|
description: String(field.description || ''),
|
|
type,
|
|
required: required.has(id),
|
|
defaultValue: field.default === null ? undefined : field.default,
|
|
options: fieldOptions(type === 'array' ? asRecord(field.items) : field),
|
|
minimum: optionalNumber(field.minimum),
|
|
maximum: optionalNumber(field.maximum),
|
|
minLength: optionalNumber(field.minLength),
|
|
maxLength: optionalNumber(field.maxLength),
|
|
minItems: optionalNumber(field.minItems),
|
|
maxItems: optionalNumber(field.maxItems),
|
|
format: typeof field.format === 'string' ? field.format : undefined,
|
|
};
|
|
});
|
|
}
|
|
|
|
export function initialCodebuddyElicitationValues(
|
|
fields: CodebuddyElicitationField[],
|
|
): Record<string, unknown> {
|
|
return Object.fromEntries(
|
|
fields
|
|
.filter((field) => field.defaultValue !== undefined)
|
|
.map((field) => [field.id, field.defaultValue]),
|
|
);
|
|
}
|
|
|
|
export function selectedCodebuddyOptionKey(
|
|
options: CodebuddyElicitationOption[],
|
|
value: unknown,
|
|
): string {
|
|
return options.find((option) => Object.is(option.value, value))?.key || '';
|
|
}
|
|
|
|
export function toggleCodebuddyArrayOption(
|
|
currentValue: unknown,
|
|
optionValue: unknown,
|
|
checked: boolean,
|
|
): unknown[] {
|
|
const selected = Array.isArray(currentValue) ? currentValue : [];
|
|
if (checked) {
|
|
return selected.some((value) => Object.is(value, optionValue))
|
|
? selected
|
|
: [...selected, optionValue];
|
|
}
|
|
return selected.filter((value) => !Object.is(value, optionValue));
|
|
}
|
|
|
|
export function buildCodebuddyElicitationContent(
|
|
values: Record<string, unknown>,
|
|
fields?: CodebuddyElicitationField[],
|
|
): Record<string, unknown> {
|
|
const allowedFieldIds = fields
|
|
? new Set(fields.map((field) => field.id))
|
|
: null;
|
|
return Object.fromEntries(
|
|
Object.entries(values).filter(
|
|
([fieldId, value]) => value !== undefined
|
|
&& (!allowedFieldIds || allowedFieldIds.has(fieldId)),
|
|
),
|
|
);
|
|
}
|
|
|
|
function hasOptionValue(
|
|
options: CodebuddyElicitationOption[],
|
|
value: unknown,
|
|
): boolean {
|
|
return options.some((option) => Object.is(option.value, value));
|
|
}
|
|
|
|
function isValidDate(value: string): boolean {
|
|
if (!DATE_PATTERN.test(value)) return false;
|
|
const date = new Date(`${value}T00:00:00.000Z`);
|
|
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
|
|
}
|
|
|
|
function isValidDateTime(value: string): boolean {
|
|
const match = DATE_TIME_PATTERN.exec(value);
|
|
if (!match || !isValidDate(match[1])) return false;
|
|
const hour = Number(match[2]);
|
|
const minute = Number(match[3]);
|
|
const second = Number(match[4]);
|
|
const offsetSign = match[5] === '-' ? -1 : 1;
|
|
const offsetHour = match[6] === undefined ? 0 : Number(match[6]);
|
|
const offsetMinute = match[7] === undefined ? 0 : Number(match[7]);
|
|
if (offsetHour > 23 || offsetMinute > 59) return false;
|
|
if (hour <= 23 && minute <= 59 && second < 60) return true;
|
|
|
|
const utcMinute = minute - offsetMinute * offsetSign;
|
|
const utcHour = hour - offsetHour * offsetSign - (utcMinute < 0 ? 1 : 0);
|
|
return (utcHour === 23 || utcHour === -1)
|
|
&& (utcMinute === 59 || utcMinute === -1)
|
|
&& second < 61;
|
|
}
|
|
|
|
function matchesFormat(value: string, format: string | undefined): boolean {
|
|
if (!format) return true;
|
|
if (format === 'email') {
|
|
return EMAIL_PATTERN.test(value);
|
|
}
|
|
if (format === 'uri') {
|
|
try {
|
|
return Boolean(new URL(value).protocol);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
if (format === 'date') {
|
|
return isValidDate(value);
|
|
}
|
|
if (format === 'date-time') {
|
|
return isValidDateTime(value);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function issue(
|
|
field: CodebuddyElicitationField,
|
|
code: CodebuddyElicitationValidationCode,
|
|
details: Pick<CodebuddyElicitationValidationIssue, 'limit' | 'format'> = {},
|
|
): CodebuddyElicitationValidationIssue {
|
|
return {
|
|
fieldId: field.id,
|
|
fieldTitle: field.title,
|
|
code,
|
|
...details,
|
|
};
|
|
}
|
|
|
|
function validateField(
|
|
field: CodebuddyElicitationField,
|
|
value: unknown,
|
|
): CodebuddyElicitationValidationIssue | null {
|
|
if (value === undefined) {
|
|
return field.required ? issue(field, 'required') : null;
|
|
}
|
|
|
|
if (field.type === 'boolean') {
|
|
if (typeof value !== 'boolean') return issue(field, 'invalidType');
|
|
if (field.options.length > 0 && !hasOptionValue(field.options, value)) {
|
|
return issue(field, 'option');
|
|
}
|
|
return null;
|
|
}
|
|
|
|
if (field.type === 'array') {
|
|
if (!Array.isArray(value)) return issue(field, 'invalidType');
|
|
if (field.minItems !== undefined && value.length < field.minItems) {
|
|
return issue(field, 'minItems', { limit: field.minItems });
|
|
}
|
|
if (field.maxItems !== undefined && value.length > field.maxItems) {
|
|
return issue(field, 'maxItems', { limit: field.maxItems });
|
|
}
|
|
if (
|
|
field.options.length > 0 &&
|
|
value.some((selected) => !hasOptionValue(field.options, selected))
|
|
) {
|
|
return issue(field, 'option');
|
|
}
|
|
return null;
|
|
}
|
|
|
|
if (field.type === 'number' || field.type === 'integer') {
|
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
return issue(field, 'invalidType');
|
|
}
|
|
if (field.type === 'integer' && !Number.isInteger(value)) {
|
|
return issue(field, 'notInteger');
|
|
}
|
|
if (field.minimum !== undefined && value < field.minimum) {
|
|
return issue(field, 'minimum', { limit: field.minimum });
|
|
}
|
|
if (field.maximum !== undefined && value > field.maximum) {
|
|
return issue(field, 'maximum', { limit: field.maximum });
|
|
}
|
|
if (field.options.length > 0 && !hasOptionValue(field.options, value)) {
|
|
return issue(field, 'option');
|
|
}
|
|
return null;
|
|
}
|
|
|
|
if (typeof value !== 'string') return issue(field, 'invalidType');
|
|
const length = [...value].length;
|
|
if (field.minLength !== undefined && length < field.minLength) {
|
|
return issue(field, 'minLength', { limit: field.minLength });
|
|
}
|
|
if (field.maxLength !== undefined && length > field.maxLength) {
|
|
return issue(field, 'maxLength', { limit: field.maxLength });
|
|
}
|
|
if (!matchesFormat(value, field.format)) {
|
|
return issue(field, 'format', { format: field.format });
|
|
}
|
|
if (field.options.length > 0 && !hasOptionValue(field.options, value)) {
|
|
return issue(field, 'option');
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function validateCodebuddyElicitationValues(
|
|
fields: CodebuddyElicitationField[],
|
|
values: Record<string, unknown>,
|
|
): CodebuddyElicitationValidationIssue[] {
|
|
return fields.flatMap((field) => {
|
|
const validationIssue = validateField(field, values[field.id]);
|
|
return validationIssue ? [validationIssue] : [];
|
|
});
|
|
}
|