import { z } from "zod"; const MAX_OPTIONS = 50; const text = z.string().max(2000); const options = z.array(z.union([z.string().max(200), z.object({ label: z.string().max(200) })])).max(MAX_OPTIONS); const fieldSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("markdown"), attributes: z.object({ value: z.string().max(10000) }) }), z.object({ type: z.enum(["input", "textarea"]), id: z.string().max(100).optional(), attributes: z.object({ label: text, description: text.optional(), placeholder: text.optional(), value: z.string().max(10000).optional(), render: z.string().max(50).optional(), }), validations: z.object({ required: z.boolean().optional() }).optional(), }), z.object({ type: z.literal("dropdown"), id: z.string().max(100).optional(), attributes: z.object({ label: text, description: text.optional(), multiple: z.boolean().optional(), options, }), validations: z.object({ required: z.boolean().optional() }).optional(), }), z.object({ type: z.literal("checkboxes"), id: z.string().max(100).optional(), attributes: z.object({ label: text, description: text.optional(), options }), }), ]); const labels = z .union([z.array(z.string().max(50)).max(20), z.string().max(500)]) .optional() .transform((value) => (typeof value === "string" ? value.split(",") : (value ?? [])).map((label) => label.trim()).filter(Boolean), ); /** `.gitfor/ISSUE_TEMPLATE/*.yml` issue form, validated with the same shape as GitHub issue forms. */ export const issueFormSchema = z.object({ name: z.string().max(200), description: z.string().max(500).default(""), title: z.string().max(256).default(""), labels, body: z.array(fieldSchema).max(50), }); /** Front matter of a markdown issue template. */ export const markdownTemplateSchema = z.object({ name: z.string().max(200), about: z.string().max(500).default(""), title: z.string().max(256).default(""), labels, }); export const templateConfigSchema = z.object({ blank_issues_enabled: z.boolean().default(true), contact_links: z .array( z.object({ name: z.string().max(200), url: z .url() .max(1000) .refine((value) => /^https?:\/\//i.test(value), "Contact links must use http or https"), about: z.string().max(500).default(""), }), ) .max(20) .default([]), }); export type IssueFormField = z.infer; export type IssueTemplate = { file: string; name: string; about: string; title: string; labels: string[]; } & ({ kind: "markdown"; body: string } | { kind: "form"; fields: IssueFormField[] }); export type TemplateConfig = z.infer; export function optionLabel(option: string | { label: string }): string { return typeof option === "string" ? option : option.label; } /** Renders submitted form answers into the issue body the way GitHub issue forms do. */ export function renderFormBody(fields: IssueFormField[], answers: Record): string { const sections: string[] = []; fields.forEach((field, index) => { if (field.type === "markdown") return; const key = field.id ?? `field-${index}`; const answer = answers[key]; if (field.type === "checkboxes") { const checked = new Set(Array.isArray(answer) ? answer : []); const lines = field.attributes.options.map((option) => { const label = optionLabel(option); return `- [${checked.has(label) ? "X" : " "}] ${label}`; }); sections.push(`### ${field.attributes.label}\n\n${lines.join("\n")}`); return; } const value = Array.isArray(answer) ? answer.join(", ") : (answer ?? "").trim(); const rendered = field.type === "textarea" && field.attributes.render && value ? `\`\`\`${field.attributes.render}\n${value}\n\`\`\`` : value || "_No response_"; sections.push(`### ${field.attributes.label}\n\n${rendered}`); }); return sections.join("\n\n"); }