gh-inari 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +112 -0
- package/dist/artifact.d.ts +82 -0
- package/dist/artifact.js +472 -0
- package/dist/artifact.js.map +1 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.js +395 -0
- package/dist/cli.js.map +1 -0
- package/dist/contract/index.d.ts +4 -0
- package/dist/contract/index.js +5 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/contract/ir.d.ts +178 -0
- package/dist/contract/ir.js +1071 -0
- package/dist/contract/ir.js.map +1 -0
- package/dist/contract/issue-form.d.ts +38 -0
- package/dist/contract/issue-form.js +662 -0
- package/dist/contract/issue-form.js.map +1 -0
- package/dist/contract/schema.d.ts +48 -0
- package/dist/contract/schema.js +162 -0
- package/dist/contract/schema.js.map +1 -0
- package/dist/contract/validation.d.ts +24 -0
- package/dist/contract/validation.js +212 -0
- package/dist/contract/validation.js.map +1 -0
- package/dist/github/adapter.d.ts +48 -0
- package/dist/github/adapter.js +463 -0
- package/dist/github/adapter.js.map +1 -0
- package/dist/github/errors.d.ts +43 -0
- package/dist/github/errors.js +65 -0
- package/dist/github/errors.js.map +1 -0
- package/dist/github/index.d.ts +4 -0
- package/dist/github/index.js +5 -0
- package/dist/github/index.js.map +1 -0
- package/dist/github/transport.d.ts +19 -0
- package/dist/github/transport.js +54 -0
- package/dist/github/transport.js.map +1 -0
- package/dist/github/types.d.ts +62 -0
- package/dist/github/types.js +16 -0
- package/dist/github/types.js.map +1 -0
- package/dist/github.d.ts +1 -0
- package/dist/github.js +2 -0
- package/dist/github.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/pr-policy.d.ts +39 -0
- package/dist/pr-policy.js +313 -0
- package/dist/pr-policy.js.map +1 -0
- package/dist/pull-request-template.d.ts +42 -0
- package/dist/pull-request-template.js +481 -0
- package/dist/pull-request-template.js.map +1 -0
- package/dist/template-discovery.d.ts +68 -0
- package/dist/template-discovery.js +431 -0
- package/dist/template-discovery.js.map +1 -0
- package/gh-inari +18 -0
- package/package.json +92 -0
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseDocument } from "yaml";
|
|
4
|
+
import { assertCanonicalContract, CANONICAL_IR_VERSION, CONTRACT_SCHEMA_VERSION, } from "./ir.js";
|
|
5
|
+
import { selectIssueTemplate, } from "../template-discovery.js";
|
|
6
|
+
const identifierPattern = /^[A-Za-z][A-Za-z0-9_-]*$/u;
|
|
7
|
+
export class IssueFormCompilerError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
path;
|
|
10
|
+
context;
|
|
11
|
+
violations;
|
|
12
|
+
constructor(violations, options) {
|
|
13
|
+
const first = violations[0];
|
|
14
|
+
if (first === undefined)
|
|
15
|
+
throw new Error("Issue Form compiler errors require at least one violation.");
|
|
16
|
+
super(violations
|
|
17
|
+
.map((violation) => `${violation.context.templatePath}${violation.path}: ${violation.message}`)
|
|
18
|
+
.join("\n"), options);
|
|
19
|
+
this.name = "IssueFormCompilerError";
|
|
20
|
+
this.code = first.code;
|
|
21
|
+
this.path = first.path;
|
|
22
|
+
this.context = first.context;
|
|
23
|
+
this.violations = violations;
|
|
24
|
+
}
|
|
25
|
+
toJSON() {
|
|
26
|
+
return {
|
|
27
|
+
code: this.code,
|
|
28
|
+
message: this.message,
|
|
29
|
+
path: this.path,
|
|
30
|
+
context: this.context,
|
|
31
|
+
violations: this.violations,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
class Diagnostics {
|
|
36
|
+
templateId;
|
|
37
|
+
templatePath;
|
|
38
|
+
violations = [];
|
|
39
|
+
constructor(templateId, templatePath) {
|
|
40
|
+
this.templateId = templateId;
|
|
41
|
+
this.templatePath = templatePath;
|
|
42
|
+
}
|
|
43
|
+
add(code, yamlPath, message, position) {
|
|
44
|
+
this.violations.push({
|
|
45
|
+
code,
|
|
46
|
+
path: yamlPath,
|
|
47
|
+
message,
|
|
48
|
+
context: {
|
|
49
|
+
templateId: this.templateId,
|
|
50
|
+
templatePath: this.templatePath,
|
|
51
|
+
yamlPath,
|
|
52
|
+
...(position === undefined ? {} : { line: position.line, column: position.column }),
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
throwIfAny() {
|
|
57
|
+
if (this.violations.length > 0)
|
|
58
|
+
throw new IssueFormCompilerError(this.violations);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Compile YAML already selected by the repository's template discovery layer. */
|
|
62
|
+
export function compileIssueFormYaml(source, template) {
|
|
63
|
+
const diagnostics = new Diagnostics(String(template.id), String(template.path));
|
|
64
|
+
validateTemplateIdentity(template, diagnostics);
|
|
65
|
+
const root = parseYaml(source, diagnostics);
|
|
66
|
+
if (root === undefined)
|
|
67
|
+
diagnostics.throwIfAny();
|
|
68
|
+
if (root === undefined || !isRecord(root)) {
|
|
69
|
+
diagnostics.add("ISSUE_FORM_INVALID_ROOT", "$", "Issue Form YAML must contain a mapping at its root.");
|
|
70
|
+
diagnostics.throwIfAny();
|
|
71
|
+
throw new Error("Unreachable");
|
|
72
|
+
}
|
|
73
|
+
const metadata = parseFormMetadata(root, diagnostics);
|
|
74
|
+
const fields = new Set();
|
|
75
|
+
const sections = new Set();
|
|
76
|
+
const compiledSections = [];
|
|
77
|
+
let inputCount = 0;
|
|
78
|
+
if (metadata !== undefined) {
|
|
79
|
+
metadata.body.forEach((bodyEntry, index) => {
|
|
80
|
+
const section = compileBodyEntry(bodyEntry, index, fields, sections, diagnostics);
|
|
81
|
+
if (section !== undefined) {
|
|
82
|
+
compiledSections.push(section);
|
|
83
|
+
if (section.kind === "input")
|
|
84
|
+
inputCount += 1;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
if (inputCount === 0) {
|
|
88
|
+
diagnostics.add("ISSUE_FORM_INVALID_VALUE", "$.body", "Issue Form body must contain at least one input field.");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const formName = metadata?.name ?? template.name;
|
|
92
|
+
const canonicalTemplateId = canonicalTemplateIdentifier(template);
|
|
93
|
+
const templateIdentity = {
|
|
94
|
+
id: canonicalTemplateId,
|
|
95
|
+
name: formName,
|
|
96
|
+
path: template.path,
|
|
97
|
+
source: "issue_form",
|
|
98
|
+
};
|
|
99
|
+
const nativeMetadata = {
|
|
100
|
+
source: "issue_form",
|
|
101
|
+
path: template.path,
|
|
102
|
+
...(metadata?.title === undefined ? {} : { title: metadata.title }),
|
|
103
|
+
...(metadata?.description === undefined ? {} : { description: metadata.description }),
|
|
104
|
+
...(metadata?.labels === undefined ? {} : { labels: metadata.labels }),
|
|
105
|
+
};
|
|
106
|
+
const contract = {
|
|
107
|
+
irVersion: CANONICAL_IR_VERSION,
|
|
108
|
+
schemaVersion: CONTRACT_SCHEMA_VERSION,
|
|
109
|
+
artifactKind: "issue",
|
|
110
|
+
templateIdentity,
|
|
111
|
+
nativeMetadata,
|
|
112
|
+
sections: compiledSections,
|
|
113
|
+
supplementalConstraints: { fields: [] },
|
|
114
|
+
};
|
|
115
|
+
diagnostics.throwIfAny();
|
|
116
|
+
try {
|
|
117
|
+
assertCanonicalContract(contract);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (error instanceof Error && "violations" in error && Array.isArray(error.violations)) {
|
|
121
|
+
for (const violation of error.violations) {
|
|
122
|
+
if (isRecord(violation)) {
|
|
123
|
+
const code = typeof violation.code === "string" ? violation.code : "IR_INVALID";
|
|
124
|
+
const message = typeof violation.message === "string" ? violation.message : "Canonical IR is invalid.";
|
|
125
|
+
const violationPath = typeof violation.path === "string" ? violation.path : "$";
|
|
126
|
+
diagnostics.add("ISSUE_FORM_IR_INVALID", violationPath, `${code}: ${message}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (diagnostics.violations.length === 0) {
|
|
131
|
+
diagnostics.add("ISSUE_FORM_IR_INVALID", "$", "Compiled Issue Form did not satisfy the canonical contract.");
|
|
132
|
+
}
|
|
133
|
+
diagnostics.throwIfAny();
|
|
134
|
+
}
|
|
135
|
+
return contract;
|
|
136
|
+
}
|
|
137
|
+
/** Short alias for callers compiling an in-memory Issue Form source. */
|
|
138
|
+
export const compileIssueForm = compileIssueFormYaml;
|
|
139
|
+
/** Discover, read, select, and compile one repository-native Issue Form. */
|
|
140
|
+
export async function compileIssueFormTemplate(discovery, selector) {
|
|
141
|
+
const template = selectIssueTemplate(discovery, selector);
|
|
142
|
+
const repositoryRoot = path.resolve(discovery.repositoryRoot);
|
|
143
|
+
const absolutePath = path.resolve(repositoryRoot, template.path);
|
|
144
|
+
const relativePath = path.relative(repositoryRoot, absolutePath);
|
|
145
|
+
if (relativePath.length === 0 || relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
146
|
+
throw new IssueFormCompilerError([
|
|
147
|
+
{
|
|
148
|
+
code: "ISSUE_FORM_SOURCE_ERROR",
|
|
149
|
+
path: "$",
|
|
150
|
+
message: "Discovered template path must remain inside the repository root.",
|
|
151
|
+
context: { templateId: template.id, templatePath: template.path, yamlPath: "$" },
|
|
152
|
+
},
|
|
153
|
+
]);
|
|
154
|
+
}
|
|
155
|
+
let source;
|
|
156
|
+
try {
|
|
157
|
+
source = await readFile(absolutePath, "utf8");
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
throw new IssueFormCompilerError([
|
|
161
|
+
{
|
|
162
|
+
code: "ISSUE_FORM_SOURCE_ERROR",
|
|
163
|
+
path: "$",
|
|
164
|
+
message: `Cannot read discovered Issue Form source: ${absolutePath}.`,
|
|
165
|
+
context: { templateId: template.id, templatePath: template.path, yamlPath: "$" },
|
|
166
|
+
},
|
|
167
|
+
], { cause: error });
|
|
168
|
+
}
|
|
169
|
+
return compileIssueFormYaml(source, template);
|
|
170
|
+
}
|
|
171
|
+
function validateTemplateIdentity(template, diagnostics) {
|
|
172
|
+
if (template.type !== undefined && template.type !== "issue-form") {
|
|
173
|
+
diagnostics.add("ISSUE_FORM_UNSUPPORTED_SEMANTICS", "$", `Template type "${template.type}" is not an Issue Form.`);
|
|
174
|
+
}
|
|
175
|
+
if (template.kind !== undefined && template.kind !== "issue") {
|
|
176
|
+
diagnostics.add("ISSUE_FORM_UNSUPPORTED_SEMANTICS", "$", `Template kind "${template.kind}" is not an issue template.`);
|
|
177
|
+
}
|
|
178
|
+
if (template.id.trim().length === 0)
|
|
179
|
+
diagnostics.add("ISSUE_FORM_INVALID_VALUE", "$", "Template identity id must not be empty.");
|
|
180
|
+
if (template.name.trim().length === 0)
|
|
181
|
+
diagnostics.add("ISSUE_FORM_INVALID_VALUE", "$", "Template identity name must not be empty.");
|
|
182
|
+
if (template.path.trim().length === 0)
|
|
183
|
+
diagnostics.add("ISSUE_FORM_INVALID_VALUE", "$", "Template path must not be empty.");
|
|
184
|
+
if (path.isAbsolute(template.path) || template.path.includes("\\") || template.path.split("/").includes("..")) {
|
|
185
|
+
diagnostics.add("ISSUE_FORM_INVALID_VALUE", "$", "Template path must be repository-relative.");
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function parseYaml(source, diagnostics) {
|
|
189
|
+
let document;
|
|
190
|
+
try {
|
|
191
|
+
document = parseDocument(source, {
|
|
192
|
+
merge: false,
|
|
193
|
+
prettyErrors: true,
|
|
194
|
+
strict: true,
|
|
195
|
+
stringKeys: true,
|
|
196
|
+
uniqueKeys: true,
|
|
197
|
+
version: "1.2",
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
diagnostics.add("ISSUE_FORM_INVALID_YAML", "$", error instanceof Error ? error.message : "YAML parsing failed.");
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
for (const error of document.errors) {
|
|
205
|
+
diagnostics.add(yamlErrorCode(error), "$", error.message, yamlPosition(error));
|
|
206
|
+
}
|
|
207
|
+
if (document.errors.length > 0)
|
|
208
|
+
return undefined;
|
|
209
|
+
try {
|
|
210
|
+
return document.toJS({ maxAliasCount: 0 });
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
const message = error instanceof Error ? error.message : "YAML aliases could not be resolved.";
|
|
214
|
+
diagnostics.add(message.toLocaleLowerCase("en-US").includes("alias")
|
|
215
|
+
? "ISSUE_FORM_UNSUPPORTED_SEMANTICS"
|
|
216
|
+
: "ISSUE_FORM_INVALID_YAML", "$", message);
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function yamlErrorCode(error) {
|
|
221
|
+
return error.code === "DUPLICATE_KEY" ? "ISSUE_FORM_DUPLICATE_KEY" : "ISSUE_FORM_INVALID_YAML";
|
|
222
|
+
}
|
|
223
|
+
function yamlPosition(error) {
|
|
224
|
+
const position = error.linePos?.[0];
|
|
225
|
+
return position === undefined ? undefined : { line: position.line, column: position.col };
|
|
226
|
+
}
|
|
227
|
+
function parseFormMetadata(root, diagnostics) {
|
|
228
|
+
checkUnknownKeys(root, ["name", "description", "title", "labels", "body", "assignees", "projects", "type"], "$", diagnostics);
|
|
229
|
+
for (const key of ["assignees", "projects", "type"]) {
|
|
230
|
+
if (hasOwn(root, key)) {
|
|
231
|
+
diagnostics.add("ISSUE_FORM_UNSUPPORTED_SEMANTICS", `$.${key}`, `Top-level Issue Form key "${key}" is not representable by the canonical contract.`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const name = requiredNonEmptyString(root, "name", "$", diagnostics);
|
|
235
|
+
const description = requiredNonEmptyString(root, "description", "$", diagnostics);
|
|
236
|
+
const title = optionalString(root, "title", "$", diagnostics);
|
|
237
|
+
const labels = hasOwn(root, "labels") ? parseStringList(root.labels, "$.labels", diagnostics) : undefined;
|
|
238
|
+
const body = requiredArray(root, "body", "$", diagnostics);
|
|
239
|
+
if (name === undefined || description === undefined || body === undefined)
|
|
240
|
+
return undefined;
|
|
241
|
+
return {
|
|
242
|
+
name,
|
|
243
|
+
description,
|
|
244
|
+
...(title === undefined ? {} : { title }),
|
|
245
|
+
...(labels === undefined ? {} : { labels }),
|
|
246
|
+
body,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
function compileBodyEntry(value, index, fieldIds, sectionIds, diagnostics) {
|
|
250
|
+
const pathPrefix = `$.body[${index}]`;
|
|
251
|
+
if (!isRecord(value)) {
|
|
252
|
+
diagnostics.add("ISSUE_FORM_INVALID_VALUE", pathPrefix, "Body entries must be objects.");
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
checkUnknownKeys(value, ["type", "id", "attributes", "validations"], pathPrefix, diagnostics);
|
|
256
|
+
const type = requiredNonEmptyString(value, "type", pathPrefix, diagnostics);
|
|
257
|
+
if (type === undefined)
|
|
258
|
+
return undefined;
|
|
259
|
+
if (type === "markdown")
|
|
260
|
+
return compileMarkdown(value, index, pathPrefix, sectionIds, diagnostics);
|
|
261
|
+
if (type === "upload") {
|
|
262
|
+
diagnostics.add("ISSUE_FORM_UNSUPPORTED_TYPE", `${pathPrefix}.type`, "Issue Form upload fields are browser/API-only and cannot be represented by the canonical contract.");
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
if (type !== "input" && type !== "textarea" && type !== "dropdown" && type !== "checkboxes") {
|
|
266
|
+
diagnostics.add("ISSUE_FORM_UNSUPPORTED_TYPE", `${pathPrefix}.type`, `Issue Form element type "${type}" is not supported.`);
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
const id = requiredNonEmptyString(value, "id", pathPrefix, diagnostics);
|
|
270
|
+
if (id === undefined) {
|
|
271
|
+
diagnostics.add("ISSUE_FORM_AMBIGUOUS", `${pathPrefix}.id`, "Non-markdown Issue Form elements require an explicit id; deriving one from presentation text is ambiguous.");
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
validateIdentifier(id, `${pathPrefix}.id`, diagnostics);
|
|
275
|
+
if (fieldIds.has(id))
|
|
276
|
+
diagnostics.add("ISSUE_FORM_DUPLICATE_ID", `${pathPrefix}.id`, `Duplicate field id "${id}".`);
|
|
277
|
+
fieldIds.add(id);
|
|
278
|
+
if (sectionIds.has(id))
|
|
279
|
+
diagnostics.add("ISSUE_FORM_DUPLICATE_ID", `${pathPrefix}.id`, `Duplicate section id "${id}".`);
|
|
280
|
+
sectionIds.add(id);
|
|
281
|
+
}
|
|
282
|
+
const attributes = requiredRecord(value, "attributes", pathPrefix, diagnostics);
|
|
283
|
+
const validations = parseValidations(value.validations, `${pathPrefix}.validations`, diagnostics);
|
|
284
|
+
const fieldId = id ?? `field-${index}`;
|
|
285
|
+
if (attributes === undefined)
|
|
286
|
+
return undefined;
|
|
287
|
+
if (type === "input" || type === "textarea") {
|
|
288
|
+
return compileTextField(type, fieldId, attributes, validations, index, pathPrefix, diagnostics);
|
|
289
|
+
}
|
|
290
|
+
if (type === "dropdown") {
|
|
291
|
+
return compileDropdown(fieldId, attributes, validations, index, pathPrefix, diagnostics);
|
|
292
|
+
}
|
|
293
|
+
return compileCheckboxes(fieldId, attributes, validations, index, pathPrefix, diagnostics);
|
|
294
|
+
}
|
|
295
|
+
function compileMarkdown(value, index, pathPrefix, sectionIds, diagnostics) {
|
|
296
|
+
if (hasOwn(value, "id"))
|
|
297
|
+
diagnostics.add("ISSUE_FORM_INVALID_VALUE", `${pathPrefix}.id`, "Markdown elements must not define an id.");
|
|
298
|
+
if (hasOwn(value, "validations"))
|
|
299
|
+
diagnostics.add("ISSUE_FORM_INVALID_VALUE", `${pathPrefix}.validations`, "Markdown elements cannot define validations.");
|
|
300
|
+
const attributes = requiredRecord(value, "attributes", pathPrefix, diagnostics);
|
|
301
|
+
if (attributes === undefined)
|
|
302
|
+
return undefined;
|
|
303
|
+
checkUnknownKeys(attributes, ["value"], `${pathPrefix}.attributes`, diagnostics);
|
|
304
|
+
const content = requiredString(attributes, "value", `${pathPrefix}.attributes`, diagnostics);
|
|
305
|
+
if (content === undefined)
|
|
306
|
+
return undefined;
|
|
307
|
+
// The canonical IR requires section IDs, while native markdown blocks intentionally have none.
|
|
308
|
+
const id = `markdown-${index}`;
|
|
309
|
+
if (sectionIds.has(id))
|
|
310
|
+
diagnostics.add("ISSUE_FORM_DUPLICATE_ID", `${pathPrefix}.type`, `Duplicate generated section id "${id}".`);
|
|
311
|
+
sectionIds.add(id);
|
|
312
|
+
return {
|
|
313
|
+
id,
|
|
314
|
+
kind: "documentation",
|
|
315
|
+
content,
|
|
316
|
+
render: { order: index },
|
|
317
|
+
nativeMetadata: { elementType: "markdown", markdown: content },
|
|
318
|
+
fields: [],
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
function compileTextField(elementType, id, attributes, validation, index, pathPrefix, diagnostics) {
|
|
322
|
+
const allowedKeys = elementType === "textarea"
|
|
323
|
+
? ["label", "description", "placeholder", "value", "render"]
|
|
324
|
+
: ["label", "description", "placeholder", "value"];
|
|
325
|
+
checkUnknownKeys(attributes, allowedKeys, `${pathPrefix}.attributes`, diagnostics);
|
|
326
|
+
if (elementType === "textarea" && hasOwn(attributes, "render")) {
|
|
327
|
+
diagnostics.add("ISSUE_FORM_UNSUPPORTED_SEMANTICS", `${pathPrefix}.attributes.render`, "Textarea render/code-block formatting is not representable by the canonical contract.");
|
|
328
|
+
}
|
|
329
|
+
const common = parseFieldCommon(id, attributes, validation, index, pathPrefix, diagnostics);
|
|
330
|
+
const placeholder = optionalString(attributes, "placeholder", `${pathPrefix}.attributes`, diagnostics);
|
|
331
|
+
const defaultValue = optionalString(attributes, "value", `${pathPrefix}.attributes`, diagnostics);
|
|
332
|
+
const nativeMetadata = {
|
|
333
|
+
elementType,
|
|
334
|
+
sourceId: id,
|
|
335
|
+
...(placeholder === undefined ? {} : { placeholder }),
|
|
336
|
+
...(defaultValue === undefined ? {} : { defaultValue }),
|
|
337
|
+
};
|
|
338
|
+
const field = {
|
|
339
|
+
id: common.id,
|
|
340
|
+
label: common.label,
|
|
341
|
+
...(common.description === undefined ? {} : { description: common.description }),
|
|
342
|
+
type: "string",
|
|
343
|
+
required: common.required,
|
|
344
|
+
...(defaultValue === undefined ? {} : { defaultValue }),
|
|
345
|
+
render: { order: 0 },
|
|
346
|
+
nativeMetadata,
|
|
347
|
+
};
|
|
348
|
+
return inputSection(common, elementType, field);
|
|
349
|
+
}
|
|
350
|
+
function compileDropdown(id, attributes, validation, index, pathPrefix, diagnostics) {
|
|
351
|
+
checkUnknownKeys(attributes, ["label", "description", "options", "multiple", "default"], `${pathPrefix}.attributes`, diagnostics);
|
|
352
|
+
const common = parseFieldCommon(id, attributes, validation, index, pathPrefix, diagnostics);
|
|
353
|
+
const optionValues = parseDropdownOptions(attributes.options, `${pathPrefix}.attributes.options`, diagnostics);
|
|
354
|
+
const multiple = optionalBoolean(attributes, "multiple", `${pathPrefix}.attributes`, diagnostics) ?? false;
|
|
355
|
+
const defaultIndex = optionalSafeInteger(attributes, "default", `${pathPrefix}.attributes`, diagnostics);
|
|
356
|
+
let defaultOption;
|
|
357
|
+
if (defaultIndex !== undefined) {
|
|
358
|
+
if (optionValues !== undefined && (defaultIndex < 0 || defaultIndex >= optionValues.length)) {
|
|
359
|
+
diagnostics.add("ISSUE_FORM_INVALID_VALUE", `${pathPrefix}.attributes.default`, "Dropdown default must be a valid option index.");
|
|
360
|
+
}
|
|
361
|
+
else if (optionValues !== undefined) {
|
|
362
|
+
defaultOption = optionValues[defaultIndex];
|
|
363
|
+
if (defaultOption !== undefined && isReservedEmptyOption(defaultOption)) {
|
|
364
|
+
diagnostics.add("ISSUE_FORM_INVALID_VALUE", `${pathPrefix}.attributes.default`, 'A dropdown with a default cannot contain the native empty option "None" or "n/a".');
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
const options = optionValues?.map((option) => ({ value: option, label: option })) ?? [];
|
|
369
|
+
const nativeOptions = options.map((option) => ({ value: option.value }));
|
|
370
|
+
const nativeMetadata = {
|
|
371
|
+
elementType: "dropdown",
|
|
372
|
+
sourceId: id,
|
|
373
|
+
...(Object.prototype.hasOwnProperty.call(attributes, "multiple") ? { multiple } : {}),
|
|
374
|
+
...(optionValues === undefined ? {} : { options: nativeOptions }),
|
|
375
|
+
...(defaultOption === undefined ? {} : { defaultValue: multiple ? [defaultOption] : defaultOption }),
|
|
376
|
+
};
|
|
377
|
+
if (multiple) {
|
|
378
|
+
const field = {
|
|
379
|
+
id: common.id,
|
|
380
|
+
label: common.label,
|
|
381
|
+
...(common.description === undefined ? {} : { description: common.description }),
|
|
382
|
+
type: "array",
|
|
383
|
+
selection: "multi_select",
|
|
384
|
+
required: common.required,
|
|
385
|
+
items: { type: "string", options },
|
|
386
|
+
...(defaultOption === undefined ? {} : { defaultValue: [defaultOption] }),
|
|
387
|
+
render: { order: 0 },
|
|
388
|
+
nativeMetadata,
|
|
389
|
+
};
|
|
390
|
+
return inputSection(common, "dropdown", field);
|
|
391
|
+
}
|
|
392
|
+
const field = {
|
|
393
|
+
id: common.id,
|
|
394
|
+
label: common.label,
|
|
395
|
+
...(common.description === undefined ? {} : { description: common.description }),
|
|
396
|
+
type: "enum",
|
|
397
|
+
required: common.required,
|
|
398
|
+
options,
|
|
399
|
+
...(defaultOption === undefined ? {} : { defaultValue: defaultOption }),
|
|
400
|
+
render: { order: 0 },
|
|
401
|
+
nativeMetadata,
|
|
402
|
+
};
|
|
403
|
+
return inputSection(common, "dropdown", field);
|
|
404
|
+
}
|
|
405
|
+
function compileCheckboxes(id, attributes, validation, index, pathPrefix, diagnostics) {
|
|
406
|
+
checkUnknownKeys(attributes, ["label", "description", "options"], `${pathPrefix}.attributes`, diagnostics);
|
|
407
|
+
const common = parseFieldCommon(id, attributes, validation, index, pathPrefix, diagnostics);
|
|
408
|
+
const rawOptions = requiredArray(attributes, "options", `${pathPrefix}.attributes`, diagnostics);
|
|
409
|
+
const itemIds = new Set();
|
|
410
|
+
const items = [];
|
|
411
|
+
const nativeOptions = [];
|
|
412
|
+
if (rawOptions !== undefined) {
|
|
413
|
+
if (rawOptions.length === 0)
|
|
414
|
+
diagnostics.add("ISSUE_FORM_INVALID_VALUE", `${pathPrefix}.attributes.options`, "Checkbox options must not be empty.");
|
|
415
|
+
rawOptions.forEach((rawOption, optionIndex) => {
|
|
416
|
+
const optionPath = `${pathPrefix}.attributes.options[${optionIndex}]`;
|
|
417
|
+
if (!isRecord(rawOption)) {
|
|
418
|
+
diagnostics.add("ISSUE_FORM_INVALID_VALUE", optionPath, "Checkbox options must be objects.");
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
checkUnknownKeys(rawOption, ["label", "required"], optionPath, diagnostics);
|
|
422
|
+
const label = requiredNonEmptyString(rawOption, "label", optionPath, diagnostics);
|
|
423
|
+
const required = optionalBoolean(rawOption, "required", optionPath, diagnostics) ?? false;
|
|
424
|
+
if (label === undefined)
|
|
425
|
+
return;
|
|
426
|
+
if (new Set(items.map((item) => item.label)).has(label)) {
|
|
427
|
+
diagnostics.add("ISSUE_FORM_AMBIGUOUS", `${optionPath}.label`, `Duplicate checkbox label "${label}".`);
|
|
428
|
+
}
|
|
429
|
+
const itemId = checklistIdentifier(label, optionIndex);
|
|
430
|
+
if (itemIds.has(itemId))
|
|
431
|
+
diagnostics.add("ISSUE_FORM_AMBIGUOUS", `${optionPath}.label`, `Checkbox label maps to duplicate canonical id "${itemId}".`);
|
|
432
|
+
itemIds.add(itemId);
|
|
433
|
+
items.push({ id: itemId, label, required });
|
|
434
|
+
nativeOptions.push({ value: itemId, label, required });
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
const requiredByItem = items.some((item) => item.required);
|
|
438
|
+
const field = {
|
|
439
|
+
id: common.id,
|
|
440
|
+
label: common.label,
|
|
441
|
+
...(common.description === undefined ? {} : { description: common.description }),
|
|
442
|
+
type: "checklist",
|
|
443
|
+
required: validation.required || requiredByItem ? "required" : "optional",
|
|
444
|
+
items,
|
|
445
|
+
render: { order: 0 },
|
|
446
|
+
nativeMetadata: { elementType: "checkboxes", sourceId: id, options: nativeOptions },
|
|
447
|
+
};
|
|
448
|
+
return inputSection(common, "checkboxes", field);
|
|
449
|
+
}
|
|
450
|
+
function parseFieldCommon(id, attributes, validation, order, pathPrefix, diagnostics) {
|
|
451
|
+
const label = requiredNonEmptyString(attributes, "label", `${pathPrefix}.attributes`, diagnostics) ?? id;
|
|
452
|
+
const description = optionalString(attributes, "description", `${pathPrefix}.attributes`, diagnostics);
|
|
453
|
+
return {
|
|
454
|
+
id,
|
|
455
|
+
label,
|
|
456
|
+
...(description === undefined ? {} : { description }),
|
|
457
|
+
required: validation.required ? "required" : "optional",
|
|
458
|
+
order,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
function inputSection(common, elementType, field) {
|
|
462
|
+
return {
|
|
463
|
+
id: common.id,
|
|
464
|
+
title: common.label,
|
|
465
|
+
kind: "input",
|
|
466
|
+
render: { order: common.order },
|
|
467
|
+
nativeMetadata: { elementType, sourceId: common.id },
|
|
468
|
+
fields: [field],
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
function parseValidations(value, pathPrefix, diagnostics) {
|
|
472
|
+
if (value === undefined)
|
|
473
|
+
return { required: false };
|
|
474
|
+
const validations = requiredRecordValue(value, pathPrefix, diagnostics);
|
|
475
|
+
if (validations === undefined)
|
|
476
|
+
return { required: false };
|
|
477
|
+
checkUnknownKeys(validations, ["required"], pathPrefix, diagnostics);
|
|
478
|
+
return { required: optionalBoolean(validations, "required", pathPrefix, diagnostics) ?? false };
|
|
479
|
+
}
|
|
480
|
+
function parseDropdownOptions(value, pathPrefix, diagnostics) {
|
|
481
|
+
const options = requiredArrayValue(value, pathPrefix, diagnostics);
|
|
482
|
+
if (options === undefined)
|
|
483
|
+
return undefined;
|
|
484
|
+
if (options.length === 0)
|
|
485
|
+
diagnostics.add("ISSUE_FORM_INVALID_VALUE", pathPrefix, "Dropdown options must not be empty.");
|
|
486
|
+
const values = [];
|
|
487
|
+
const seen = new Set();
|
|
488
|
+
options.forEach((option, index) => {
|
|
489
|
+
if (typeof option !== "string" || option.length === 0) {
|
|
490
|
+
diagnostics.add("ISSUE_FORM_INVALID_VALUE", `${pathPrefix}[${index}]`, "Dropdown options must be non-empty strings.");
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (seen.has(option))
|
|
494
|
+
diagnostics.add("ISSUE_FORM_DUPLICATE_VALUE", `${pathPrefix}[${index}]`, `Duplicate dropdown option "${option}".`);
|
|
495
|
+
seen.add(option);
|
|
496
|
+
values.push(option);
|
|
497
|
+
});
|
|
498
|
+
return values;
|
|
499
|
+
}
|
|
500
|
+
function parseStringList(value, pathPrefix, diagnostics) {
|
|
501
|
+
const values = [];
|
|
502
|
+
if (typeof value === "string") {
|
|
503
|
+
if (value.trim().length > 0) {
|
|
504
|
+
for (const entry of value.split(",")) {
|
|
505
|
+
const trimmed = entry.trim();
|
|
506
|
+
if (trimmed.length === 0) {
|
|
507
|
+
diagnostics.add("ISSUE_FORM_INVALID_VALUE", pathPrefix, "Comma-delimited labels cannot contain empty entries.");
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
values.push(trimmed);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
else if (Array.isArray(value)) {
|
|
516
|
+
value.forEach((entry, index) => {
|
|
517
|
+
if (typeof entry !== "string" || entry.trim().length === 0) {
|
|
518
|
+
diagnostics.add("ISSUE_FORM_INVALID_VALUE", `${pathPrefix}[${index}]`, "Labels must be non-empty strings.");
|
|
519
|
+
}
|
|
520
|
+
else {
|
|
521
|
+
values.push(entry.trim());
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
diagnostics.add("ISSUE_FORM_INVALID_VALUE", pathPrefix, "Labels must be an array or comma-delimited string.");
|
|
527
|
+
}
|
|
528
|
+
const seen = new Set();
|
|
529
|
+
values.forEach((value, index) => {
|
|
530
|
+
if (seen.has(value))
|
|
531
|
+
diagnostics.add("ISSUE_FORM_DUPLICATE_VALUE", `${pathPrefix}[${index}]`, `Duplicate label "${value}".`);
|
|
532
|
+
seen.add(value);
|
|
533
|
+
});
|
|
534
|
+
return values;
|
|
535
|
+
}
|
|
536
|
+
function checkUnknownKeys(record, allowedKeys, pathPrefix, diagnostics) {
|
|
537
|
+
const allowed = new Set(allowedKeys);
|
|
538
|
+
for (const key of Object.keys(record)) {
|
|
539
|
+
if (!allowed.has(key))
|
|
540
|
+
diagnostics.add("ISSUE_FORM_UNKNOWN_PROPERTY", `${pathPrefix}.${key}`, `Property "${key}" is not supported.`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
function requiredNonEmptyString(record, key, pathPrefix, diagnostics) {
|
|
544
|
+
if (!hasOwn(record, key)) {
|
|
545
|
+
diagnostics.add("ISSUE_FORM_MISSING_PROPERTY", `${pathPrefix}.${key}`, `Property "${key}" is required.`);
|
|
546
|
+
return undefined;
|
|
547
|
+
}
|
|
548
|
+
const value = record[key];
|
|
549
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
550
|
+
diagnostics.add("ISSUE_FORM_INVALID_VALUE", `${pathPrefix}.${key}`, `Property "${key}" must be a non-empty string.`);
|
|
551
|
+
return undefined;
|
|
552
|
+
}
|
|
553
|
+
return value;
|
|
554
|
+
}
|
|
555
|
+
function requiredString(record, key, pathPrefix, diagnostics) {
|
|
556
|
+
if (!hasOwn(record, key)) {
|
|
557
|
+
diagnostics.add("ISSUE_FORM_MISSING_PROPERTY", `${pathPrefix}.${key}`, `Property "${key}" is required.`);
|
|
558
|
+
return undefined;
|
|
559
|
+
}
|
|
560
|
+
const value = record[key];
|
|
561
|
+
if (typeof value !== "string") {
|
|
562
|
+
diagnostics.add("ISSUE_FORM_INVALID_VALUE", `${pathPrefix}.${key}`, `Property "${key}" must be a string.`);
|
|
563
|
+
return undefined;
|
|
564
|
+
}
|
|
565
|
+
return value;
|
|
566
|
+
}
|
|
567
|
+
function optionalString(record, key, pathPrefix, diagnostics) {
|
|
568
|
+
if (!hasOwn(record, key))
|
|
569
|
+
return undefined;
|
|
570
|
+
const value = record[key];
|
|
571
|
+
if (typeof value !== "string") {
|
|
572
|
+
diagnostics.add("ISSUE_FORM_INVALID_VALUE", `${pathPrefix}.${key}`, `Property "${key}" must be a string when present.`);
|
|
573
|
+
return undefined;
|
|
574
|
+
}
|
|
575
|
+
return value;
|
|
576
|
+
}
|
|
577
|
+
function optionalBoolean(record, key, pathPrefix, diagnostics) {
|
|
578
|
+
if (!hasOwn(record, key))
|
|
579
|
+
return undefined;
|
|
580
|
+
const value = record[key];
|
|
581
|
+
if (typeof value !== "boolean") {
|
|
582
|
+
diagnostics.add("ISSUE_FORM_INVALID_VALUE", `${pathPrefix}.${key}`, `Property "${key}" must be a boolean when present.`);
|
|
583
|
+
return undefined;
|
|
584
|
+
}
|
|
585
|
+
return value;
|
|
586
|
+
}
|
|
587
|
+
function optionalSafeInteger(record, key, pathPrefix, diagnostics) {
|
|
588
|
+
if (!hasOwn(record, key))
|
|
589
|
+
return undefined;
|
|
590
|
+
const value = record[key];
|
|
591
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value)) {
|
|
592
|
+
diagnostics.add("ISSUE_FORM_INVALID_VALUE", `${pathPrefix}.${key}`, `Property "${key}" must be a safe integer when present.`);
|
|
593
|
+
return undefined;
|
|
594
|
+
}
|
|
595
|
+
return value;
|
|
596
|
+
}
|
|
597
|
+
function requiredArray(record, key, pathPrefix, diagnostics) {
|
|
598
|
+
if (!hasOwn(record, key)) {
|
|
599
|
+
diagnostics.add("ISSUE_FORM_MISSING_PROPERTY", `${pathPrefix}.${key}`, `Property "${key}" is required.`);
|
|
600
|
+
return undefined;
|
|
601
|
+
}
|
|
602
|
+
return requiredArrayValue(record[key], `${pathPrefix}.${key}`, diagnostics);
|
|
603
|
+
}
|
|
604
|
+
function requiredArrayValue(value, pathPrefix, diagnostics) {
|
|
605
|
+
if (!Array.isArray(value)) {
|
|
606
|
+
diagnostics.add("ISSUE_FORM_INVALID_VALUE", pathPrefix, "Value must be an array.");
|
|
607
|
+
return undefined;
|
|
608
|
+
}
|
|
609
|
+
return value;
|
|
610
|
+
}
|
|
611
|
+
function requiredRecord(record, key, pathPrefix, diagnostics) {
|
|
612
|
+
if (!hasOwn(record, key)) {
|
|
613
|
+
diagnostics.add("ISSUE_FORM_MISSING_PROPERTY", `${pathPrefix}.${key}`, `Property "${key}" is required.`);
|
|
614
|
+
return undefined;
|
|
615
|
+
}
|
|
616
|
+
return requiredRecordValue(record[key], `${pathPrefix}.${key}`, diagnostics);
|
|
617
|
+
}
|
|
618
|
+
function requiredRecordValue(value, pathPrefix, diagnostics) {
|
|
619
|
+
if (!isRecord(value)) {
|
|
620
|
+
diagnostics.add("ISSUE_FORM_INVALID_VALUE", pathPrefix, "Value must be an object.");
|
|
621
|
+
return undefined;
|
|
622
|
+
}
|
|
623
|
+
return value;
|
|
624
|
+
}
|
|
625
|
+
function validateIdentifier(value, pathPrefix, diagnostics) {
|
|
626
|
+
if (!identifierPattern.test(value)) {
|
|
627
|
+
diagnostics.add("ISSUE_FORM_INVALID_VALUE", pathPrefix, "Identifiers must start with a letter and contain only letters, numbers, hyphens, or underscores.");
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
function canonicalTemplateIdentifier(template) {
|
|
631
|
+
const preferred = identifierPattern.test(template.id) ? template.id : template.name;
|
|
632
|
+
const normalized = preferred
|
|
633
|
+
.normalize("NFKD")
|
|
634
|
+
.replace(/[\u0300-\u036f]/gu, "")
|
|
635
|
+
.replace(/[^A-Za-z0-9_-]+/gu, "-")
|
|
636
|
+
.replace(/^-+|-+$/gu, "");
|
|
637
|
+
if (normalized.length === 0)
|
|
638
|
+
return "template";
|
|
639
|
+
return /^[A-Za-z]/u.test(normalized) ? normalized : `template-${normalized}`;
|
|
640
|
+
}
|
|
641
|
+
function checklistIdentifier(label, index) {
|
|
642
|
+
// GitHub checkbox options expose labels but no IDs; this ID is only a stable IR key.
|
|
643
|
+
const normalized = label
|
|
644
|
+
.normalize("NFKD")
|
|
645
|
+
.replace(/[\u0300-\u036f]/gu, "")
|
|
646
|
+
.replace(/[^A-Za-z0-9_-]+/gu, "-")
|
|
647
|
+
.replace(/^-+|-+$/gu, "");
|
|
648
|
+
if (normalized.length === 0)
|
|
649
|
+
return `item-${index + 1}`;
|
|
650
|
+
return /^[A-Za-z]/u.test(normalized) ? normalized : `item-${normalized}`;
|
|
651
|
+
}
|
|
652
|
+
function isReservedEmptyOption(value) {
|
|
653
|
+
const normalized = value.trim().toLocaleLowerCase("en-US");
|
|
654
|
+
return normalized === "none" || normalized === "n/a";
|
|
655
|
+
}
|
|
656
|
+
function isRecord(value) {
|
|
657
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
658
|
+
}
|
|
659
|
+
function hasOwn(record, key) {
|
|
660
|
+
return Object.prototype.hasOwnProperty.call(record, key);
|
|
661
|
+
}
|
|
662
|
+
//# sourceMappingURL=issue-form.js.map
|