@xera-ai/core 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/bin/internal.ts +4 -0
- package/dist/adapter/types.d.ts +70 -0
- package/dist/adapter/types.d.ts.map +1 -0
- package/dist/artifact/hash.d.ts +4 -0
- package/dist/artifact/hash.d.ts.map +1 -0
- package/dist/artifact/meta.d.ts +46 -0
- package/dist/artifact/meta.d.ts.map +1 -0
- package/dist/artifact/paths.d.ts +25 -0
- package/dist/artifact/paths.d.ts.map +1 -0
- package/dist/artifact/status.d.ts +96 -0
- package/dist/artifact/status.d.ts.map +1 -0
- package/dist/auth/encrypt.d.ts +4 -0
- package/dist/auth/encrypt.d.ts.map +1 -0
- package/dist/auth/key.d.ts +3 -0
- package/dist/auth/key.d.ts.map +1 -0
- package/dist/auth/refresh.d.ts +9 -0
- package/dist/auth/refresh.d.ts.map +1 -0
- package/dist/auth/state.d.ts +24 -0
- package/dist/auth/state.d.ts.map +1 -0
- package/dist/bin/internal.js +1013 -0
- package/dist/bin-internal/exec.d.ts +2 -0
- package/dist/bin-internal/exec.d.ts.map +1 -0
- package/dist/bin-internal/fetch.d.ts +5 -0
- package/dist/bin-internal/fetch.d.ts.map +1 -0
- package/dist/bin-internal/index.d.ts +2 -0
- package/dist/bin-internal/index.d.ts.map +1 -0
- package/dist/bin-internal/lint.d.ts +2 -0
- package/dist/bin-internal/lint.d.ts.map +1 -0
- package/dist/bin-internal/normalize.d.ts +2 -0
- package/dist/bin-internal/normalize.d.ts.map +1 -0
- package/dist/bin-internal/post.d.ts +2 -0
- package/dist/bin-internal/post.d.ts.map +1 -0
- package/dist/bin-internal/promote.d.ts +2 -0
- package/dist/bin-internal/promote.d.ts.map +1 -0
- package/dist/bin-internal/report.d.ts +2 -0
- package/dist/bin-internal/report.d.ts.map +1 -0
- package/dist/bin-internal/status-cmd.d.ts +2 -0
- package/dist/bin-internal/status-cmd.d.ts.map +1 -0
- package/dist/bin-internal/typecheck.d.ts +2 -0
- package/dist/bin-internal/typecheck.d.ts.map +1 -0
- package/dist/bin-internal/unlock.d.ts +2 -0
- package/dist/bin-internal/unlock.d.ts.map +1 -0
- package/dist/bin-internal/validate-feature.d.ts +2 -0
- package/dist/bin-internal/validate-feature.d.ts.map +1 -0
- package/dist/classifier/aggregate.d.ts +3 -0
- package/dist/classifier/aggregate.d.ts.map +1 -0
- package/dist/classifier/history.d.ts +13 -0
- package/dist/classifier/history.d.ts.map +1 -0
- package/dist/classifier/types.d.ts +26 -0
- package/dist/classifier/types.d.ts.map +1 -0
- package/dist/config/define.d.ts +3 -0
- package/dist/config/define.d.ts.map +1 -0
- package/dist/config/load.d.ts +3 -0
- package/dist/config/load.d.ts.map +1 -0
- package/dist/config/schema.d.ts +326 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/jira/client.d.ts +11 -0
- package/dist/jira/client.d.ts.map +1 -0
- package/dist/jira/fields.d.ts +7 -0
- package/dist/jira/fields.d.ts.map +1 -0
- package/dist/jira/mcp-backend.d.ts +3 -0
- package/dist/jira/mcp-backend.d.ts.map +1 -0
- package/dist/jira/rest-backend.d.ts +8 -0
- package/dist/jira/rest-backend.d.ts.map +1 -0
- package/dist/jira/retry.d.ts +8 -0
- package/dist/jira/retry.d.ts.map +1 -0
- package/dist/jira/types.d.ts +29 -0
- package/dist/jira/types.d.ts.map +1 -0
- package/dist/lock/file-lock.d.ts +12 -0
- package/dist/lock/file-lock.d.ts.map +1 -0
- package/dist/logging/ndjson-logger.d.ts +11 -0
- package/dist/logging/ndjson-logger.d.ts.map +1 -0
- package/dist/reporter/jira-comment.d.ts +9 -0
- package/dist/reporter/jira-comment.d.ts.map +1 -0
- package/dist/reporter/status-writer.d.ts +14 -0
- package/dist/reporter/status-writer.d.ts.map +1 -0
- package/dist/src/index.js +587 -0
- package/package.json +30 -0
|
@@ -0,0 +1,1013 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// src/bin-internal/fetch.ts
|
|
5
|
+
import { writeFileSync as writeFileSync3, mkdirSync as mkdirSync3 } from "fs";
|
|
6
|
+
import { dirname as dirname2 } from "path";
|
|
7
|
+
|
|
8
|
+
// src/config/load.ts
|
|
9
|
+
import { existsSync } from "fs";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
import { pathToFileURL } from "url";
|
|
12
|
+
|
|
13
|
+
// src/config/schema.ts
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
var AuthRoleSchema = z.object({
|
|
16
|
+
envEmail: z.string().min(1),
|
|
17
|
+
envPassword: z.string().min(1)
|
|
18
|
+
});
|
|
19
|
+
var AuthSchema = z.object({
|
|
20
|
+
strategy: z.enum(["storageState", "apiToken", "none"]).default("none"),
|
|
21
|
+
ttl: z.string().default("8h"),
|
|
22
|
+
refreshBuffer: z.string().default("30m"),
|
|
23
|
+
setupScript: z.string().optional(),
|
|
24
|
+
roles: z.record(z.string(), AuthRoleSchema).default({})
|
|
25
|
+
});
|
|
26
|
+
var WebSchema = z.object({
|
|
27
|
+
baseUrl: z.record(z.string(), z.string().url()).refine((m) => Object.keys(m).length > 0, {
|
|
28
|
+
message: "baseUrl must have at least one environment"
|
|
29
|
+
}),
|
|
30
|
+
defaultEnv: z.string(),
|
|
31
|
+
auth: AuthSchema.default({}),
|
|
32
|
+
testData: z.object({
|
|
33
|
+
users: z.record(z.string(), z.object({ fromAuth: z.string() })).default({})
|
|
34
|
+
}).default({ users: {} })
|
|
35
|
+
}).refine((w) => w.baseUrl[w.defaultEnv] !== undefined, {
|
|
36
|
+
message: "defaultEnv must exist in baseUrl map",
|
|
37
|
+
path: ["defaultEnv"]
|
|
38
|
+
});
|
|
39
|
+
var JiraSchema = z.object({
|
|
40
|
+
baseUrl: z.string().url(),
|
|
41
|
+
projectKeys: z.array(z.string().min(1)).min(1),
|
|
42
|
+
fields: z.object({
|
|
43
|
+
story: z.string().min(1),
|
|
44
|
+
acceptanceCriteria: z.string().optional(),
|
|
45
|
+
attachments: z.string().default("attachment")
|
|
46
|
+
})
|
|
47
|
+
});
|
|
48
|
+
var AISchema = z.object({
|
|
49
|
+
livePageSnapshot: z.boolean().default(true),
|
|
50
|
+
confidenceThreshold: z.enum(["low", "medium", "high"]).default("medium"),
|
|
51
|
+
maxRetries: z.object({
|
|
52
|
+
typecheck: z.number().int().min(0).max(5).default(2),
|
|
53
|
+
lint: z.number().int().min(0).max(5).default(2),
|
|
54
|
+
validateFeature: z.number().int().min(0).max(5).default(2)
|
|
55
|
+
}).default({})
|
|
56
|
+
}).default({});
|
|
57
|
+
var ReportingSchema = z.object({
|
|
58
|
+
language: z.enum(["en", "vi"]).default("en"),
|
|
59
|
+
postToJira: z.boolean().default(true),
|
|
60
|
+
transition: z.object({
|
|
61
|
+
onPass: z.string().nullable().default(null),
|
|
62
|
+
onFail: z.string().nullable().default(null)
|
|
63
|
+
}).default({}),
|
|
64
|
+
artifactLinks: z.enum(["git", "local"]).default("git")
|
|
65
|
+
}).default({});
|
|
66
|
+
var XeraConfigSchema = z.object({
|
|
67
|
+
jira: JiraSchema,
|
|
68
|
+
web: WebSchema,
|
|
69
|
+
ai: AISchema,
|
|
70
|
+
reporting: ReportingSchema,
|
|
71
|
+
adapters: z.array(z.string().min(1)).min(1).default(["web"])
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// src/config/load.ts
|
|
75
|
+
async function loadConfig(cwd) {
|
|
76
|
+
const path = join(cwd, "xera.config.ts");
|
|
77
|
+
if (!existsSync(path)) {
|
|
78
|
+
throw new Error(`xera.config.ts not found in ${cwd}`);
|
|
79
|
+
}
|
|
80
|
+
const mod = await import(pathToFileURL(path).href);
|
|
81
|
+
const raw = mod.default ?? mod;
|
|
82
|
+
return XeraConfigSchema.parse(raw);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// src/artifact/paths.ts
|
|
86
|
+
import { join as join2 } from "path";
|
|
87
|
+
var TICKET_RE = /^[A-Z][A-Z0-9_]*-\d+$|^SAMPLE-\d+$/;
|
|
88
|
+
function resolveArtifactPaths(repoRoot, ticket) {
|
|
89
|
+
if (!TICKET_RE.test(ticket)) {
|
|
90
|
+
throw new Error(`Invalid ticket key: "${ticket}" (expected e.g. JIRA-123 or SAMPLE-001)`);
|
|
91
|
+
}
|
|
92
|
+
const ticketDir = join2(repoRoot, ".xera", ticket);
|
|
93
|
+
return {
|
|
94
|
+
ticketDir,
|
|
95
|
+
storyPath: join2(ticketDir, "story.md"),
|
|
96
|
+
featurePath: join2(ticketDir, "test.feature"),
|
|
97
|
+
specPath: join2(ticketDir, "spec.ts"),
|
|
98
|
+
pageObjectsDir: join2(ticketDir, "page-objects"),
|
|
99
|
+
runsDir: join2(ticketDir, "runs"),
|
|
100
|
+
metaPath: join2(ticketDir, "meta.json"),
|
|
101
|
+
statusPath: join2(ticketDir, "status.json"),
|
|
102
|
+
logPath: join2(ticketDir, "xera.log"),
|
|
103
|
+
lockPath: join2(ticketDir, ".lock"),
|
|
104
|
+
authDir: join2(repoRoot, ".xera", ".auth"),
|
|
105
|
+
runPath: (runId) => {
|
|
106
|
+
const runDir = join2(ticketDir, "runs", runId);
|
|
107
|
+
return {
|
|
108
|
+
runDir,
|
|
109
|
+
reportJsonPath: join2(runDir, "report.json"),
|
|
110
|
+
tracePath: join2(runDir, "trace.zip"),
|
|
111
|
+
normalizedPath: join2(runDir, "normalized.json"),
|
|
112
|
+
screenshotsDir: join2(runDir, "screenshots"),
|
|
113
|
+
videoDir: join2(runDir, "videos")
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function generateRunId(now = new Date) {
|
|
119
|
+
return now.toISOString().replace(/[:.]/g, "-").replace("Z", "");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/artifact/hash.ts
|
|
123
|
+
import { createHash } from "crypto";
|
|
124
|
+
import { existsSync as existsSync2, readFileSync } from "fs";
|
|
125
|
+
function hashString(s) {
|
|
126
|
+
return `sha256:${createHash("sha256").update(s).digest("hex")}`;
|
|
127
|
+
}
|
|
128
|
+
function hashFile(path) {
|
|
129
|
+
return hashString(readFileSync(path, "utf8"));
|
|
130
|
+
}
|
|
131
|
+
function hashFileIfExists(path) {
|
|
132
|
+
if (!existsSync2(path))
|
|
133
|
+
return null;
|
|
134
|
+
return hashFile(path);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// src/artifact/meta.ts
|
|
138
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2, writeFileSync, mkdirSync } from "fs";
|
|
139
|
+
import { dirname } from "path";
|
|
140
|
+
import { z as z2 } from "zod";
|
|
141
|
+
var MetaJsonSchema = z2.object({
|
|
142
|
+
ticket: z2.string(),
|
|
143
|
+
adapter: z2.string(),
|
|
144
|
+
xera_version: z2.string(),
|
|
145
|
+
prompts_version: z2.string(),
|
|
146
|
+
fetched_at: z2.string().optional(),
|
|
147
|
+
story_hash: z2.string().optional(),
|
|
148
|
+
feature_generated_at: z2.string().optional(),
|
|
149
|
+
feature_generated_from_story_hash: z2.string().optional(),
|
|
150
|
+
feature_hash: z2.string().optional(),
|
|
151
|
+
script_generated_at: z2.string().optional(),
|
|
152
|
+
script_generated_from_feature_hash: z2.string().optional(),
|
|
153
|
+
script_warnings: z2.array(z2.string()).optional()
|
|
154
|
+
});
|
|
155
|
+
function readMeta(path) {
|
|
156
|
+
if (!existsSync3(path))
|
|
157
|
+
return null;
|
|
158
|
+
return MetaJsonSchema.parse(JSON.parse(readFileSync2(path, "utf8")));
|
|
159
|
+
}
|
|
160
|
+
function writeMeta(path, meta) {
|
|
161
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
162
|
+
writeFileSync(path, JSON.stringify(meta, null, 2));
|
|
163
|
+
}
|
|
164
|
+
function updateMeta(path, patch) {
|
|
165
|
+
const existing = readMeta(path);
|
|
166
|
+
if (!existing) {
|
|
167
|
+
throw new Error(`meta.json not found at ${path}; cannot update`);
|
|
168
|
+
}
|
|
169
|
+
const next = { ...existing, ...patch };
|
|
170
|
+
writeMeta(path, next);
|
|
171
|
+
return next;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// src/jira/mcp-backend.ts
|
|
175
|
+
import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "fs";
|
|
176
|
+
import { tmpdir } from "os";
|
|
177
|
+
import { join as join3 } from "path";
|
|
178
|
+
var MCP_ENV = "XERA_MCP_JIRA";
|
|
179
|
+
async function createMcpBackend(_baseUrl) {
|
|
180
|
+
if (process.env[MCP_ENV] !== "1")
|
|
181
|
+
return null;
|
|
182
|
+
const tmpDir = join3(tmpdir(), "xera-mcp");
|
|
183
|
+
mkdirSync2(tmpDir, { recursive: true });
|
|
184
|
+
return {
|
|
185
|
+
backend: "mcp",
|
|
186
|
+
async fetchTicket(key, _fields) {
|
|
187
|
+
const cachePath = join3(tmpDir, `${key}.json`);
|
|
188
|
+
if (!existsSync4(cachePath)) {
|
|
189
|
+
throw new Error(`MCP-mode fetch requires the skill to first call mcp__atlassian__getJiraIssue and write ${cachePath}. ` + `If you are running this directly, unset ${MCP_ENV} to use REST.`);
|
|
190
|
+
}
|
|
191
|
+
const parsed = JSON.parse(readFileSync3(cachePath, "utf8"));
|
|
192
|
+
return parsed;
|
|
193
|
+
},
|
|
194
|
+
async postComment(key, body) {
|
|
195
|
+
const outPath = join3(tmpDir, `${key}.comment.json`);
|
|
196
|
+
writeFileSync2(outPath, JSON.stringify({ key, body }));
|
|
197
|
+
return { id: "mcp-pending" };
|
|
198
|
+
},
|
|
199
|
+
async transitionStatus(key, statusName) {
|
|
200
|
+
const outPath = join3(tmpDir, `${key}.transition.json`);
|
|
201
|
+
writeFileSync2(outPath, JSON.stringify({ key, statusName }));
|
|
202
|
+
},
|
|
203
|
+
async listFields(_sampleKey) {
|
|
204
|
+
throw new Error("listFields is REST-only; init flow uses REST for field discovery.");
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// src/jira/rest-backend.ts
|
|
210
|
+
function createRestBackend(baseUrl, creds) {
|
|
211
|
+
const authHeader = `Basic ${Buffer.from(`${creds.email}:${creds.apiToken}`).toString("base64")}`;
|
|
212
|
+
const base = baseUrl.replace(/\/$/, "");
|
|
213
|
+
async function req(path, init) {
|
|
214
|
+
const r = await fetch(`${base}${path}`, {
|
|
215
|
+
...init,
|
|
216
|
+
headers: {
|
|
217
|
+
Authorization: authHeader,
|
|
218
|
+
Accept: "application/json",
|
|
219
|
+
"Content-Type": "application/json",
|
|
220
|
+
...init?.headers ?? {}
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
if (!r.ok && r.status !== 201) {
|
|
224
|
+
throw new Error(`Jira REST ${init?.method ?? "GET"} ${path} failed: ${r.status} ${await r.text()}`);
|
|
225
|
+
}
|
|
226
|
+
return r;
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
backend: "rest",
|
|
230
|
+
async fetchTicket(key, fields) {
|
|
231
|
+
const want = ["summary", fields.story];
|
|
232
|
+
if (fields.acceptanceCriteria)
|
|
233
|
+
want.push(fields.acceptanceCriteria);
|
|
234
|
+
want.push("attachment");
|
|
235
|
+
const r = await req(`/rest/api/3/issue/${encodeURIComponent(key)}?fields=${want.join(",")}`);
|
|
236
|
+
const json = await r.json();
|
|
237
|
+
const f = json.fields;
|
|
238
|
+
const attachments = Array.isArray(f.attachment) ? f.attachment.map((a) => ({ filename: a.filename, url: a.content })) : [];
|
|
239
|
+
const ticket = {
|
|
240
|
+
key: json.key,
|
|
241
|
+
summary: String(f.summary ?? ""),
|
|
242
|
+
story: String(f[fields.story] ?? ""),
|
|
243
|
+
attachments,
|
|
244
|
+
raw: f
|
|
245
|
+
};
|
|
246
|
+
if (fields.acceptanceCriteria) {
|
|
247
|
+
ticket.acceptanceCriteria = String(f[fields.acceptanceCriteria] ?? "");
|
|
248
|
+
}
|
|
249
|
+
return ticket;
|
|
250
|
+
},
|
|
251
|
+
async postComment(key, body) {
|
|
252
|
+
const r = await req(`/rest/api/3/issue/${encodeURIComponent(key)}/comment`, {
|
|
253
|
+
method: "POST",
|
|
254
|
+
body: JSON.stringify({
|
|
255
|
+
body: { type: "doc", version: 1, content: [{ type: "paragraph", content: [{ type: "text", text: body }] }] }
|
|
256
|
+
})
|
|
257
|
+
});
|
|
258
|
+
const json = await r.json();
|
|
259
|
+
return { id: json.id };
|
|
260
|
+
},
|
|
261
|
+
async transitionStatus(key, statusName) {
|
|
262
|
+
const tr = await req(`/rest/api/3/issue/${encodeURIComponent(key)}/transitions`);
|
|
263
|
+
const json = await tr.json();
|
|
264
|
+
const t = json.transitions.find((x) => x.name === statusName);
|
|
265
|
+
if (!t)
|
|
266
|
+
throw new Error(`No transition named "${statusName}" available for ${key}`);
|
|
267
|
+
await req(`/rest/api/3/issue/${encodeURIComponent(key)}/transitions`, {
|
|
268
|
+
method: "POST",
|
|
269
|
+
body: JSON.stringify({ transition: { id: t.id } })
|
|
270
|
+
});
|
|
271
|
+
},
|
|
272
|
+
async listFields(sampleKey) {
|
|
273
|
+
const r = await req(`/rest/api/3/issue/${encodeURIComponent(sampleKey)}?fields=*all`);
|
|
274
|
+
const json = await r.json();
|
|
275
|
+
return Object.entries(json.fields).map(([id, value]) => ({
|
|
276
|
+
id,
|
|
277
|
+
name: id,
|
|
278
|
+
hasContent: value !== null && value !== undefined && value !== ""
|
|
279
|
+
}));
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// src/jira/client.ts
|
|
285
|
+
async function createJiraClient(opts) {
|
|
286
|
+
if (opts.preferMcp !== false) {
|
|
287
|
+
const mcp = await createMcpBackend(opts.baseUrl);
|
|
288
|
+
if (mcp)
|
|
289
|
+
return mcp;
|
|
290
|
+
}
|
|
291
|
+
if (!opts.rest) {
|
|
292
|
+
throw new Error("Atlassian MCP not connected and no REST credentials provided (JIRA_EMAIL + JIRA_API_TOKEN).");
|
|
293
|
+
}
|
|
294
|
+
return createRestBackend(opts.baseUrl, opts.rest);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// src/bin-internal/fetch.ts
|
|
298
|
+
async function fetchCmd(argv, opts = {}) {
|
|
299
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
300
|
+
const ticket = argv[0];
|
|
301
|
+
if (!ticket) {
|
|
302
|
+
console.error("[xera:fetch] usage: xera-internal fetch <TICKET>");
|
|
303
|
+
return 1;
|
|
304
|
+
}
|
|
305
|
+
const config = await loadConfig(cwd);
|
|
306
|
+
const paths = resolveArtifactPaths(cwd, ticket);
|
|
307
|
+
let t;
|
|
308
|
+
if (process.env.XERA_TEST_JIRA) {
|
|
309
|
+
t = JSON.parse(process.env.XERA_TEST_JIRA);
|
|
310
|
+
} else {
|
|
311
|
+
const client = await createJiraClient({
|
|
312
|
+
baseUrl: config.jira.baseUrl,
|
|
313
|
+
preferMcp: true,
|
|
314
|
+
...process.env.JIRA_EMAIL && process.env.JIRA_API_TOKEN ? { rest: { email: process.env.JIRA_EMAIL, apiToken: process.env.JIRA_API_TOKEN } } : {}
|
|
315
|
+
});
|
|
316
|
+
const fieldMap = config.jira.fields.acceptanceCriteria !== undefined ? { story: config.jira.fields.story, acceptanceCriteria: config.jira.fields.acceptanceCriteria } : { story: config.jira.fields.story };
|
|
317
|
+
t = await client.fetchTicket(ticket, fieldMap);
|
|
318
|
+
}
|
|
319
|
+
const story = renderStory(t);
|
|
320
|
+
mkdirSync3(dirname2(paths.storyPath), { recursive: true });
|
|
321
|
+
writeFileSync3(paths.storyPath, story);
|
|
322
|
+
const existing = readMeta(paths.metaPath);
|
|
323
|
+
writeMeta(paths.metaPath, {
|
|
324
|
+
ticket,
|
|
325
|
+
adapter: "web",
|
|
326
|
+
xera_version: "0.1.0",
|
|
327
|
+
prompts_version: "1.0.0",
|
|
328
|
+
...existing ?? {},
|
|
329
|
+
story_hash: hashString(story),
|
|
330
|
+
fetched_at: new Date().toISOString()
|
|
331
|
+
});
|
|
332
|
+
console.log(`[xera:fetch] wrote ${paths.storyPath}`);
|
|
333
|
+
return 0;
|
|
334
|
+
}
|
|
335
|
+
function renderStory(t) {
|
|
336
|
+
const lines = [];
|
|
337
|
+
lines.push(`# ${t.key}: ${t.summary}`, "");
|
|
338
|
+
lines.push(`## Story`, "", t.story.trim(), "");
|
|
339
|
+
if (t.acceptanceCriteria && t.acceptanceCriteria.trim()) {
|
|
340
|
+
lines.push(`## Acceptance Criteria`, "", t.acceptanceCriteria.trim(), "");
|
|
341
|
+
}
|
|
342
|
+
if (t.attachments.length > 0) {
|
|
343
|
+
lines.push(`## Attachments`, "", ...t.attachments.map((a) => `- [${a.filename}](${a.url})`), "");
|
|
344
|
+
}
|
|
345
|
+
return lines.join(`
|
|
346
|
+
`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// src/bin-internal/validate-feature.ts
|
|
350
|
+
import { readFileSync as readFileSync4, existsSync as existsSync5 } from "fs";
|
|
351
|
+
import { validateGherkin } from "@xera-ai/web";
|
|
352
|
+
async function validateFeatureCmd(argv) {
|
|
353
|
+
const ticket = argv[0];
|
|
354
|
+
if (!ticket) {
|
|
355
|
+
console.error("[xera:validate-feature] usage: validate-feature <TICKET>");
|
|
356
|
+
return 1;
|
|
357
|
+
}
|
|
358
|
+
const paths = resolveArtifactPaths(process.cwd(), ticket);
|
|
359
|
+
if (!existsSync5(paths.featurePath)) {
|
|
360
|
+
console.error(`[xera:validate-feature] missing ${paths.featurePath}`);
|
|
361
|
+
return 1;
|
|
362
|
+
}
|
|
363
|
+
const r = validateGherkin(readFileSync4(paths.featurePath, "utf8"));
|
|
364
|
+
if (r.ok) {
|
|
365
|
+
console.log("[xera:validate-feature] ok");
|
|
366
|
+
return 0;
|
|
367
|
+
}
|
|
368
|
+
for (const e of r.errors)
|
|
369
|
+
console.error(`[xera:validate-feature] line ${e.line}: ${e.message}`);
|
|
370
|
+
return 2;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// src/bin-internal/typecheck.ts
|
|
374
|
+
import { typecheckTicket } from "@xera-ai/web";
|
|
375
|
+
async function typecheckCmd(argv) {
|
|
376
|
+
const ticket = argv[0];
|
|
377
|
+
if (!ticket) {
|
|
378
|
+
console.error("[xera:typecheck] usage: typecheck <TICKET>");
|
|
379
|
+
return 1;
|
|
380
|
+
}
|
|
381
|
+
const paths = resolveArtifactPaths(process.cwd(), ticket);
|
|
382
|
+
const r = await typecheckTicket(paths.ticketDir);
|
|
383
|
+
if (r.ok) {
|
|
384
|
+
console.log("[xera:typecheck] ok");
|
|
385
|
+
return 0;
|
|
386
|
+
}
|
|
387
|
+
for (const e of r.errors)
|
|
388
|
+
console.error(`[xera:typecheck] ${e}`);
|
|
389
|
+
return 2;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// src/bin-internal/lint.ts
|
|
393
|
+
import { lintTicket } from "@xera-ai/web";
|
|
394
|
+
async function lintCmd(argv) {
|
|
395
|
+
const ticket = argv[0];
|
|
396
|
+
if (!ticket) {
|
|
397
|
+
console.error("[xera:lint] usage: lint <TICKET>");
|
|
398
|
+
return 1;
|
|
399
|
+
}
|
|
400
|
+
const paths = resolveArtifactPaths(process.cwd(), ticket);
|
|
401
|
+
const r = await lintTicket(paths.ticketDir);
|
|
402
|
+
if (r.ok) {
|
|
403
|
+
console.log("[xera:lint] ok");
|
|
404
|
+
return 0;
|
|
405
|
+
}
|
|
406
|
+
for (const w of r.warnings)
|
|
407
|
+
console.error(`[xera:lint] ${w.file}:${w.line} [${w.rule}] ${w.message}`);
|
|
408
|
+
return 2;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// src/lock/file-lock.ts
|
|
412
|
+
import { existsSync as existsSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync4, unlinkSync, mkdirSync as mkdirSync4 } from "fs";
|
|
413
|
+
import { dirname as dirname3 } from "path";
|
|
414
|
+
import { hostname } from "os";
|
|
415
|
+
function acquireLock(path, runId) {
|
|
416
|
+
if (existsSync6(path))
|
|
417
|
+
return false;
|
|
418
|
+
mkdirSync4(dirname3(path), { recursive: true });
|
|
419
|
+
const data = {
|
|
420
|
+
pid: process.pid,
|
|
421
|
+
hostname: hostname(),
|
|
422
|
+
started_at: new Date().toISOString(),
|
|
423
|
+
run_id: runId
|
|
424
|
+
};
|
|
425
|
+
try {
|
|
426
|
+
writeFileSync4(path, JSON.stringify(data), { flag: "wx" });
|
|
427
|
+
return true;
|
|
428
|
+
} catch {
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
function releaseLock(path) {
|
|
433
|
+
if (existsSync6(path))
|
|
434
|
+
unlinkSync(path);
|
|
435
|
+
}
|
|
436
|
+
function readLock(path) {
|
|
437
|
+
if (!existsSync6(path))
|
|
438
|
+
return null;
|
|
439
|
+
return JSON.parse(readFileSync5(path, "utf8"));
|
|
440
|
+
}
|
|
441
|
+
function isLockStale(path) {
|
|
442
|
+
const lock = readLock(path);
|
|
443
|
+
if (!lock)
|
|
444
|
+
return true;
|
|
445
|
+
if (lock.hostname !== hostname()) {
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
process.kill(lock.pid, 0);
|
|
450
|
+
return false;
|
|
451
|
+
} catch {
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
function forceUnlock(path) {
|
|
456
|
+
releaseLock(path);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// src/logging/ndjson-logger.ts
|
|
460
|
+
import { appendFileSync, mkdirSync as mkdirSync5, existsSync as existsSync7, readFileSync as readFileSync6 } from "fs";
|
|
461
|
+
import { dirname as dirname4 } from "path";
|
|
462
|
+
|
|
463
|
+
class NdjsonLogger {
|
|
464
|
+
path;
|
|
465
|
+
constructor(path) {
|
|
466
|
+
this.path = path;
|
|
467
|
+
mkdirSync5(dirname4(path), { recursive: true });
|
|
468
|
+
}
|
|
469
|
+
log(payload) {
|
|
470
|
+
const entry = { ts: new Date().toISOString(), ...payload };
|
|
471
|
+
appendFileSync(this.path, `${JSON.stringify(entry)}
|
|
472
|
+
`);
|
|
473
|
+
}
|
|
474
|
+
static readAll(path) {
|
|
475
|
+
if (!existsSync7(path))
|
|
476
|
+
return [];
|
|
477
|
+
const txt = readFileSync6(path, "utf8").trim();
|
|
478
|
+
if (!txt)
|
|
479
|
+
return [];
|
|
480
|
+
return txt.split(`
|
|
481
|
+
`).map((line) => JSON.parse(line));
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// src/auth/state.ts
|
|
486
|
+
import { existsSync as existsSync8, readFileSync as readFileSync7, writeFileSync as writeFileSync5, mkdirSync as mkdirSync6 } from "fs";
|
|
487
|
+
import { join as join4 } from "path";
|
|
488
|
+
import { z as z3 } from "zod";
|
|
489
|
+
|
|
490
|
+
// src/auth/encrypt.ts
|
|
491
|
+
import { randomBytes, createCipheriv, createDecipheriv } from "crypto";
|
|
492
|
+
var ALGO = "aes-256-gcm";
|
|
493
|
+
var KEY_LEN = 32;
|
|
494
|
+
var IV_LEN = 12;
|
|
495
|
+
var TAG_LEN = 16;
|
|
496
|
+
var VERSION = "v1";
|
|
497
|
+
function generateKey() {
|
|
498
|
+
return randomBytes(KEY_LEN).toString("hex");
|
|
499
|
+
}
|
|
500
|
+
function keyToBuf(key) {
|
|
501
|
+
const buf = Buffer.from(key, "hex");
|
|
502
|
+
if (buf.length !== KEY_LEN)
|
|
503
|
+
throw new Error(`Key must be ${KEY_LEN} bytes (got ${buf.length})`);
|
|
504
|
+
return buf;
|
|
505
|
+
}
|
|
506
|
+
function encrypt(plaintext, keyHex) {
|
|
507
|
+
const key = keyToBuf(keyHex);
|
|
508
|
+
const iv = randomBytes(IV_LEN);
|
|
509
|
+
const cipher = createCipheriv(ALGO, key, iv);
|
|
510
|
+
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
511
|
+
const tag = cipher.getAuthTag();
|
|
512
|
+
return `${VERSION}:${iv.toString("base64")}:${tag.toString("base64")}:${ct.toString("base64")}`;
|
|
513
|
+
}
|
|
514
|
+
function decrypt(ciphertext, keyHex) {
|
|
515
|
+
const [version, ivB64, tagB64, ctB64] = ciphertext.split(":");
|
|
516
|
+
if (version !== VERSION)
|
|
517
|
+
throw new Error(`Unsupported ciphertext version: ${version}`);
|
|
518
|
+
if (!ivB64 || !tagB64 || !ctB64)
|
|
519
|
+
throw new Error("Malformed ciphertext");
|
|
520
|
+
const key = keyToBuf(keyHex);
|
|
521
|
+
const iv = Buffer.from(ivB64, "base64");
|
|
522
|
+
const tag = Buffer.from(tagB64, "base64");
|
|
523
|
+
const ct = Buffer.from(ctB64, "base64");
|
|
524
|
+
if (tag.length !== TAG_LEN)
|
|
525
|
+
throw new Error("Bad auth tag length");
|
|
526
|
+
const decipher = createDecipheriv(ALGO, key, iv);
|
|
527
|
+
decipher.setAuthTag(tag);
|
|
528
|
+
return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// src/auth/key.ts
|
|
532
|
+
var AUTH_KEY_ENV = "XERA_AUTH_KEY";
|
|
533
|
+
function resolveAuthKey() {
|
|
534
|
+
const key = process.env[AUTH_KEY_ENV];
|
|
535
|
+
if (!key) {
|
|
536
|
+
throw new Error(`${AUTH_KEY_ENV} not set. It is auto-generated by \`xera init\` and saved to .env. ` + `If you deleted .env, regenerate it by running \`xera init --update\` \u2014 note that any cached auth state will be invalidated.`);
|
|
537
|
+
}
|
|
538
|
+
if (!/^[0-9a-f]{64}$/i.test(key)) {
|
|
539
|
+
throw new Error(`${AUTH_KEY_ENV} must be a 64-character hex string (32 bytes).`);
|
|
540
|
+
}
|
|
541
|
+
return key;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// src/auth/state.ts
|
|
545
|
+
var AuthStateEntrySchema = z3.object({
|
|
546
|
+
role: z3.string(),
|
|
547
|
+
strategy: z3.enum(["storageState", "apiToken"]),
|
|
548
|
+
created_at: z3.string(),
|
|
549
|
+
expires_at: z3.string(),
|
|
550
|
+
payload: z3.record(z3.string(), z3.unknown())
|
|
551
|
+
});
|
|
552
|
+
function pathFor(authDir, role) {
|
|
553
|
+
return join4(authDir, `${role}.json`);
|
|
554
|
+
}
|
|
555
|
+
function writeAuthState(authDir, entry) {
|
|
556
|
+
mkdirSync6(authDir, { recursive: true });
|
|
557
|
+
const ct = encrypt(JSON.stringify(entry), resolveAuthKey());
|
|
558
|
+
writeFileSync5(pathFor(authDir, entry.role), ct);
|
|
559
|
+
}
|
|
560
|
+
function readAuthState(authDir, role) {
|
|
561
|
+
const p = pathFor(authDir, role);
|
|
562
|
+
if (!existsSync8(p))
|
|
563
|
+
return null;
|
|
564
|
+
const txt = readFileSync7(p, "utf8");
|
|
565
|
+
const plain = decrypt(txt, resolveAuthKey());
|
|
566
|
+
return AuthStateEntrySchema.parse(JSON.parse(plain));
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// src/auth/refresh.ts
|
|
570
|
+
var RE = /^(\d+)([hms])$/;
|
|
571
|
+
function parseDuration(d) {
|
|
572
|
+
const m = RE.exec(d);
|
|
573
|
+
if (!m)
|
|
574
|
+
throw new Error(`Bad duration "${d}" \u2014 expected e.g. "8h", "30m", "45s"`);
|
|
575
|
+
const n = Number(m[1]);
|
|
576
|
+
const unit = m[2];
|
|
577
|
+
if (unit === "h")
|
|
578
|
+
return n * 3600 * 1000;
|
|
579
|
+
if (unit === "m")
|
|
580
|
+
return n * 60 * 1000;
|
|
581
|
+
return n * 1000;
|
|
582
|
+
}
|
|
583
|
+
function needsRefresh(entry, policy, now = new Date) {
|
|
584
|
+
if (!entry)
|
|
585
|
+
return true;
|
|
586
|
+
const ttlMs = parseDuration(policy.ttl);
|
|
587
|
+
const bufMs = parseDuration(policy.refreshBuffer);
|
|
588
|
+
const createdAt = new Date(entry.created_at).getTime();
|
|
589
|
+
if (now.getTime() - createdAt > ttlMs)
|
|
590
|
+
return true;
|
|
591
|
+
const expiresAt = new Date(entry.expires_at).getTime();
|
|
592
|
+
if (expiresAt - now.getTime() < bufMs)
|
|
593
|
+
return true;
|
|
594
|
+
return false;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// src/bin-internal/exec.ts
|
|
598
|
+
import { stagePlaywrightState, runAuthSetup, runPlaywright } from "@xera-ai/web";
|
|
599
|
+
import { chromium } from "@playwright/test";
|
|
600
|
+
import { writeFileSync as writeFileSync6, mkdirSync as mkdirSync7, existsSync as existsSync9 } from "fs";
|
|
601
|
+
import { join as join5 } from "path";
|
|
602
|
+
async function execCmd(argv) {
|
|
603
|
+
const ticket = argv[0];
|
|
604
|
+
if (!ticket) {
|
|
605
|
+
console.error("[xera:exec] usage: exec <TICKET>");
|
|
606
|
+
return 1;
|
|
607
|
+
}
|
|
608
|
+
const cwd = process.cwd();
|
|
609
|
+
const config = await loadConfig(cwd);
|
|
610
|
+
const paths = resolveArtifactPaths(cwd, ticket);
|
|
611
|
+
const runId = generateRunId();
|
|
612
|
+
const log = new NdjsonLogger(paths.logPath);
|
|
613
|
+
if (!acquireLock(paths.lockPath, runId)) {
|
|
614
|
+
if (isLockStale(paths.lockPath)) {
|
|
615
|
+
console.error(`[xera:exec] stale lock detected; force unlocking. Run \`xera-internal unlock ${ticket}\` to clear manually.`);
|
|
616
|
+
forceUnlock(paths.lockPath);
|
|
617
|
+
acquireLock(paths.lockPath, runId);
|
|
618
|
+
} else {
|
|
619
|
+
const existing = readLock(paths.lockPath);
|
|
620
|
+
console.error(`[xera:exec] another run in progress (PID ${existing?.pid} on ${existing?.hostname}, started ${existing?.started_at}). Wait or run \`xera-internal unlock ${ticket}\`.`);
|
|
621
|
+
return 1;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
const t0 = Date.now();
|
|
625
|
+
try {
|
|
626
|
+
if (config.web.auth.strategy === "storageState" && config.web.auth.setupScript) {
|
|
627
|
+
const browser = await chromium.launch();
|
|
628
|
+
try {
|
|
629
|
+
for (const [roleName, roleCreds] of Object.entries(config.web.auth.roles)) {
|
|
630
|
+
const entry = readAuthState(paths.authDir, roleName);
|
|
631
|
+
if (needsRefresh(entry, { ttl: config.web.auth.ttl, refreshBuffer: config.web.auth.refreshBuffer })) {
|
|
632
|
+
const email = process.env[roleCreds.envEmail];
|
|
633
|
+
const password = process.env[roleCreds.envPassword];
|
|
634
|
+
if (!email || !password) {
|
|
635
|
+
console.error(`[xera:exec] missing env ${roleCreds.envEmail} or ${roleCreds.envPassword} for role "${roleName}"`);
|
|
636
|
+
return 1;
|
|
637
|
+
}
|
|
638
|
+
await runAuthSetup({
|
|
639
|
+
role: roleName,
|
|
640
|
+
creds: { email, password },
|
|
641
|
+
setupScriptPath: join5(cwd, config.web.auth.setupScript),
|
|
642
|
+
authDir: paths.authDir,
|
|
643
|
+
browser
|
|
644
|
+
});
|
|
645
|
+
log.log({ step: "auth-refresh", role: roleName });
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
} finally {
|
|
649
|
+
await browser.close();
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
const stagedRoles = {};
|
|
653
|
+
if (config.web.auth.strategy === "storageState") {
|
|
654
|
+
for (const roleName of Object.keys(config.web.auth.roles)) {
|
|
655
|
+
if (readAuthState(paths.authDir, roleName)) {
|
|
656
|
+
stagedRoles[roleName] = stagePlaywrightState(paths.authDir, roleName);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
const cfgPath = join5(paths.ticketDir, "playwright.config.ts");
|
|
661
|
+
if (!existsSync9(cfgPath)) {
|
|
662
|
+
writeFileSync6(cfgPath, renderPlaywrightConfig({
|
|
663
|
+
baseUrl: config.web.baseUrl[config.web.defaultEnv],
|
|
664
|
+
storageStatePathPerRole: stagedRoles
|
|
665
|
+
}));
|
|
666
|
+
}
|
|
667
|
+
const runDir = paths.runPath(runId).runDir;
|
|
668
|
+
mkdirSync7(runDir, { recursive: true });
|
|
669
|
+
log.log({ step: "exec.start", runId });
|
|
670
|
+
const r = await runPlaywright({ specPath: paths.specPath, configPath: cfgPath, outputDir: runDir });
|
|
671
|
+
log.log({ step: "exec.done", runId, exit: r.exitCode, ms: Date.now() - t0 });
|
|
672
|
+
console.log(`[xera:exec] runId=${runId} outcome=${r.outcome}`);
|
|
673
|
+
return r.outcome === "PASS" ? 0 : 3;
|
|
674
|
+
} finally {
|
|
675
|
+
releaseLock(paths.lockPath);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
function renderPlaywrightConfig(opts) {
|
|
679
|
+
const projects = Object.entries(opts.storageStatePathPerRole).map(([role, path]) => ` { name: '${role}', use: { ...devices['Desktop Chromium'], storageState: '${path}' } }`);
|
|
680
|
+
if (projects.length === 0)
|
|
681
|
+
projects.push(` { name: 'default', use: { ...devices['Desktop Chromium'] } }`);
|
|
682
|
+
return `import { defineConfig, devices } from '@playwright/test';
|
|
683
|
+
export default defineConfig({
|
|
684
|
+
use: { baseURL: '${opts.baseUrl}', trace: 'on' },
|
|
685
|
+
projects: [
|
|
686
|
+
${projects.join(`,
|
|
687
|
+
`)}
|
|
688
|
+
],
|
|
689
|
+
});
|
|
690
|
+
`;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// src/bin-internal/normalize.ts
|
|
694
|
+
import { normalizeRun } from "@xera-ai/web";
|
|
695
|
+
import { readdirSync, existsSync as existsSync10 } from "fs";
|
|
696
|
+
import { join as join6 } from "path";
|
|
697
|
+
async function normalizeCmd(argv) {
|
|
698
|
+
const ticket = argv[0];
|
|
699
|
+
if (!ticket) {
|
|
700
|
+
console.error("[xera:normalize] usage: normalize <TICKET> [--run=<runId>]");
|
|
701
|
+
return 1;
|
|
702
|
+
}
|
|
703
|
+
const paths = resolveArtifactPaths(process.cwd(), ticket);
|
|
704
|
+
const runArg = argv.find((a) => a.startsWith("--run="));
|
|
705
|
+
const runId = runArg ? runArg.split("=")[1] : readdirSync(paths.runsDir).filter((n) => !n.startsWith(".")).sort().pop();
|
|
706
|
+
if (!runId) {
|
|
707
|
+
console.error("[xera:normalize] no run found");
|
|
708
|
+
return 1;
|
|
709
|
+
}
|
|
710
|
+
const runDir = join6(paths.runsDir, runId);
|
|
711
|
+
if (!existsSync10(runDir)) {
|
|
712
|
+
console.error(`[xera:normalize] runs/${runId} missing`);
|
|
713
|
+
return 1;
|
|
714
|
+
}
|
|
715
|
+
const r = await normalizeRun({ runId, runDir });
|
|
716
|
+
console.log(`[xera:normalize] wrote normalized.json (scrubbed_fields_count=${r.scrubbed_fields_count})`);
|
|
717
|
+
return 0;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// src/bin-internal/report.ts
|
|
721
|
+
import { readFileSync as readFileSync9, writeFileSync as writeFileSync8 } from "fs";
|
|
722
|
+
import { join as join7 } from "path";
|
|
723
|
+
|
|
724
|
+
// src/classifier/aggregate.ts
|
|
725
|
+
var CLASS_PRIORITY = [
|
|
726
|
+
"REAL_BUG",
|
|
727
|
+
"TEST_BUG",
|
|
728
|
+
"SELECTOR_DRIFT",
|
|
729
|
+
"FLAKY",
|
|
730
|
+
"PASS"
|
|
731
|
+
];
|
|
732
|
+
var CONF_RANK = { low: 1, medium: 2, high: 3 };
|
|
733
|
+
function aggregateScenarios(scenarios) {
|
|
734
|
+
if (scenarios.length === 0) {
|
|
735
|
+
return { overall: "PASS", overallConfidence: "low", scenarios: [] };
|
|
736
|
+
}
|
|
737
|
+
if (scenarios.every((s) => s.outcome === "PASS")) {
|
|
738
|
+
return { overall: "PASS", overallConfidence: "high", scenarios };
|
|
739
|
+
}
|
|
740
|
+
let chosen = "PASS";
|
|
741
|
+
for (const cls of CLASS_PRIORITY) {
|
|
742
|
+
if (scenarios.some((s) => s.class === cls)) {
|
|
743
|
+
chosen = cls;
|
|
744
|
+
break;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
const matching = scenarios.filter((s) => s.class === chosen);
|
|
748
|
+
const minConf = matching.reduce((acc, s) => CONF_RANK[s.confidence] < CONF_RANK[acc] ? s.confidence : acc, "high");
|
|
749
|
+
return { overall: chosen, overallConfidence: minConf, scenarios };
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// src/reporter/status-writer.ts
|
|
753
|
+
import { existsSync as existsSync12 } from "fs";
|
|
754
|
+
|
|
755
|
+
// src/artifact/status.ts
|
|
756
|
+
import { existsSync as existsSync11, readFileSync as readFileSync8, writeFileSync as writeFileSync7, mkdirSync as mkdirSync8 } from "fs";
|
|
757
|
+
import { dirname as dirname5 } from "path";
|
|
758
|
+
import { z as z4 } from "zod";
|
|
759
|
+
var ClassificationEnum = z4.enum(["PASS", "REAL_BUG", "SELECTOR_DRIFT", "FLAKY", "TEST_BUG"]);
|
|
760
|
+
var ResultEnum = z4.enum(["PASS", "FAIL"]);
|
|
761
|
+
var ConfidenceEnum = z4.enum(["low", "medium", "high"]);
|
|
762
|
+
var HistoryEntrySchema = z4.object({
|
|
763
|
+
ts: z4.string(),
|
|
764
|
+
result: ResultEnum,
|
|
765
|
+
class: ClassificationEnum
|
|
766
|
+
});
|
|
767
|
+
var StatusJsonSchema = z4.object({
|
|
768
|
+
ticket: z4.string(),
|
|
769
|
+
lastRun: z4.string(),
|
|
770
|
+
result: ResultEnum,
|
|
771
|
+
classification: ClassificationEnum,
|
|
772
|
+
confidence: ConfidenceEnum,
|
|
773
|
+
scenarios: z4.object({
|
|
774
|
+
total: z4.number().int().nonnegative(),
|
|
775
|
+
passed: z4.number().int().nonnegative(),
|
|
776
|
+
failed: z4.number().int().nonnegative(),
|
|
777
|
+
skipped: z4.number().int().nonnegative()
|
|
778
|
+
}),
|
|
779
|
+
history: z4.array(HistoryEntrySchema).default([]),
|
|
780
|
+
last_jira_comment_id: z4.string().optional()
|
|
781
|
+
});
|
|
782
|
+
var HISTORY_CAP = 20;
|
|
783
|
+
function readStatus(path) {
|
|
784
|
+
if (!existsSync11(path))
|
|
785
|
+
return null;
|
|
786
|
+
return StatusJsonSchema.parse(JSON.parse(readFileSync8(path, "utf8")));
|
|
787
|
+
}
|
|
788
|
+
function writeStatus(path, status) {
|
|
789
|
+
mkdirSync8(dirname5(path), { recursive: true });
|
|
790
|
+
writeFileSync7(path, JSON.stringify(status, null, 2));
|
|
791
|
+
}
|
|
792
|
+
function appendHistory(path, entry) {
|
|
793
|
+
const s = readStatus(path);
|
|
794
|
+
if (!s) {
|
|
795
|
+
throw new Error(`status.json not found at ${path}`);
|
|
796
|
+
}
|
|
797
|
+
s.history = [entry, ...s.history].slice(0, HISTORY_CAP);
|
|
798
|
+
writeStatus(path, s);
|
|
799
|
+
return s;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// src/reporter/status-writer.ts
|
|
803
|
+
function writeStatusFromClassification(path, input) {
|
|
804
|
+
const result = input.classification.overall === "PASS" ? "PASS" : "FAIL";
|
|
805
|
+
const entry = { ts: input.runTs, result, class: input.classification.overall };
|
|
806
|
+
if (!existsSync12(path)) {
|
|
807
|
+
writeStatus(path, {
|
|
808
|
+
ticket: input.ticket,
|
|
809
|
+
lastRun: input.runTs,
|
|
810
|
+
result,
|
|
811
|
+
classification: input.classification.overall,
|
|
812
|
+
confidence: input.classification.overallConfidence,
|
|
813
|
+
scenarios: input.scenarioCounts,
|
|
814
|
+
history: [entry]
|
|
815
|
+
});
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
const cur = readStatus(path);
|
|
819
|
+
writeStatus(path, {
|
|
820
|
+
...cur,
|
|
821
|
+
lastRun: input.runTs,
|
|
822
|
+
result,
|
|
823
|
+
classification: input.classification.overall,
|
|
824
|
+
confidence: input.classification.overallConfidence,
|
|
825
|
+
scenarios: input.scenarioCounts
|
|
826
|
+
});
|
|
827
|
+
appendHistory(path, entry);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// src/reporter/jira-comment.ts
|
|
831
|
+
function buildJiraComment(input) {
|
|
832
|
+
const passed = input.scenarios.filter((s) => s.outcome === "PASS").length;
|
|
833
|
+
const total = input.scenarios.length;
|
|
834
|
+
const icon = input.overall === "PASS" ? "\uD83D\uDFE2" : "\uD83D\uDD34";
|
|
835
|
+
const header = `## ${icon} xera test ${input.overall === "PASS" ? "PASSED" : "FAILED"} \u2014 ${input.ticket} (run ${input.runId})`;
|
|
836
|
+
const meta = `**Classification:** ${input.overall} (confidence: ${input.overallConfidence})
|
|
837
|
+
**Scenarios:** ${passed} / ${total} passed`;
|
|
838
|
+
const failingBlocks = input.scenarios.filter((s) => s.outcome === "FAIL").map((s) => `### Scenario: ${s.name}
|
|
839
|
+
- **Classification:** ${s.class} (confidence: ${s.confidence})
|
|
840
|
+
- **Diagnosis:** ${s.rationale}`).join(`
|
|
841
|
+
|
|
842
|
+
`);
|
|
843
|
+
const reproduce = `### Reproduce locally
|
|
844
|
+
|
|
845
|
+
\`\`\`
|
|
846
|
+
bunx xera-internal exec ${input.ticket} --replay=${input.runId}
|
|
847
|
+
\`\`\``;
|
|
848
|
+
const next = input.overall === "PASS" ? "" : `### Suggested next action
|
|
849
|
+
- Review the failing scenarios above.
|
|
850
|
+
- Re-run after changes: open Claude Code and run \`/xera-run ${input.ticket}\`.
|
|
851
|
+
|
|
852
|
+
`;
|
|
853
|
+
const footer = `---
|
|
854
|
+
xera v${input.xeraVersion} \u2022 prompts v${input.promptsVersion}`;
|
|
855
|
+
return [header, "", meta, "", failingBlocks, "", next, reproduce, "", footer].filter(Boolean).join(`
|
|
856
|
+
`);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// src/bin-internal/report.ts
|
|
860
|
+
async function reportCmd(argv) {
|
|
861
|
+
const ticket = argv[0];
|
|
862
|
+
const inputArg = argv.find((a) => a.startsWith("--input="));
|
|
863
|
+
if (!ticket || !inputArg) {
|
|
864
|
+
console.error("[xera:report] usage: report <TICKET> --input=<classifier-output.json>");
|
|
865
|
+
return 1;
|
|
866
|
+
}
|
|
867
|
+
const paths = resolveArtifactPaths(process.cwd(), ticket);
|
|
868
|
+
const input = JSON.parse(readFileSync9(inputArg.slice("--input=".length), "utf8"));
|
|
869
|
+
const aggregated = aggregateScenarios(input.scenarios);
|
|
870
|
+
const ts = new Date().toISOString();
|
|
871
|
+
writeStatusFromClassification(paths.statusPath, {
|
|
872
|
+
ticket,
|
|
873
|
+
runTs: ts,
|
|
874
|
+
classification: aggregated,
|
|
875
|
+
scenarioCounts: input.scenarioCounts
|
|
876
|
+
});
|
|
877
|
+
const md = buildJiraComment({
|
|
878
|
+
ticket,
|
|
879
|
+
runId: input.runId,
|
|
880
|
+
overall: aggregated.overall,
|
|
881
|
+
overallConfidence: aggregated.overallConfidence,
|
|
882
|
+
scenarios: aggregated.scenarios,
|
|
883
|
+
xeraVersion: "0.1.0",
|
|
884
|
+
promptsVersion: "1.0.0"
|
|
885
|
+
});
|
|
886
|
+
const draftPath = join7(paths.ticketDir, "jira-comment.draft.md");
|
|
887
|
+
writeFileSync8(draftPath, md);
|
|
888
|
+
console.log(`[xera:report] wrote status.json and ${draftPath}`);
|
|
889
|
+
return 0;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// src/bin-internal/post.ts
|
|
893
|
+
import { readFileSync as readFileSync10, existsSync as existsSync13 } from "fs";
|
|
894
|
+
import { join as join8 } from "path";
|
|
895
|
+
async function postCmd(argv) {
|
|
896
|
+
const ticket = argv[0];
|
|
897
|
+
if (!ticket) {
|
|
898
|
+
console.error("[xera:post] usage: post <TICKET>");
|
|
899
|
+
return 1;
|
|
900
|
+
}
|
|
901
|
+
const cwd = process.cwd();
|
|
902
|
+
const config = await loadConfig(cwd);
|
|
903
|
+
if (!config.reporting.postToJira) {
|
|
904
|
+
console.log("[xera:post] postToJira disabled in config; skipping");
|
|
905
|
+
return 0;
|
|
906
|
+
}
|
|
907
|
+
const paths = resolveArtifactPaths(cwd, ticket);
|
|
908
|
+
const draftPath = join8(paths.ticketDir, "jira-comment.draft.md");
|
|
909
|
+
if (!existsSync13(draftPath)) {
|
|
910
|
+
console.error(`[xera:post] no draft at ${draftPath}; run \`xera-internal report\` first.`);
|
|
911
|
+
return 1;
|
|
912
|
+
}
|
|
913
|
+
const body = readFileSync10(draftPath, "utf8");
|
|
914
|
+
const client = await createJiraClient({
|
|
915
|
+
baseUrl: config.jira.baseUrl,
|
|
916
|
+
preferMcp: true,
|
|
917
|
+
...process.env.JIRA_EMAIL && process.env.JIRA_API_TOKEN ? { rest: { email: process.env.JIRA_EMAIL, apiToken: process.env.JIRA_API_TOKEN } } : {}
|
|
918
|
+
});
|
|
919
|
+
const r = await client.postComment(ticket, body);
|
|
920
|
+
console.log(`[xera:post] posted comment id=${r.id}`);
|
|
921
|
+
const s = readStatus(paths.statusPath);
|
|
922
|
+
if (s)
|
|
923
|
+
writeStatus(paths.statusPath, { ...s, last_jira_comment_id: r.id });
|
|
924
|
+
return 0;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// src/bin-internal/status-cmd.ts
|
|
928
|
+
async function statusCmd(argv) {
|
|
929
|
+
const ticket = argv[0];
|
|
930
|
+
if (!ticket) {
|
|
931
|
+
console.error("[xera:status] usage: status <TICKET>");
|
|
932
|
+
return 1;
|
|
933
|
+
}
|
|
934
|
+
const paths = resolveArtifactPaths(process.cwd(), ticket);
|
|
935
|
+
const s = readStatus(paths.statusPath);
|
|
936
|
+
if (!s) {
|
|
937
|
+
console.log(`[xera:status] no status yet for ${ticket}`);
|
|
938
|
+
return 0;
|
|
939
|
+
}
|
|
940
|
+
console.log(`${ticket}: ${s.result} (${s.classification}, conf=${s.confidence}) \u2014 ${s.scenarios.passed}/${s.scenarios.total} passed, last run ${s.lastRun}`);
|
|
941
|
+
for (const h of s.history.slice(0, 5))
|
|
942
|
+
console.log(` ${h.ts} ${h.result} ${h.class}`);
|
|
943
|
+
return 0;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// src/bin-internal/unlock.ts
|
|
947
|
+
async function unlockCmd(argv) {
|
|
948
|
+
const ticket = argv[0];
|
|
949
|
+
if (!ticket) {
|
|
950
|
+
console.error("[xera:unlock] usage: unlock <TICKET> [--force]");
|
|
951
|
+
return 1;
|
|
952
|
+
}
|
|
953
|
+
const paths = resolveArtifactPaths(process.cwd(), ticket);
|
|
954
|
+
const lock = readLock(paths.lockPath);
|
|
955
|
+
if (!lock) {
|
|
956
|
+
console.log(`[xera:unlock] no lock for ${ticket}`);
|
|
957
|
+
return 0;
|
|
958
|
+
}
|
|
959
|
+
const force = argv.includes("--force");
|
|
960
|
+
if (!force && !isLockStale(paths.lockPath)) {
|
|
961
|
+
console.error(`[xera:unlock] lock is held by PID ${lock.pid} on ${lock.hostname} (active). Pass --force to override.`);
|
|
962
|
+
return 1;
|
|
963
|
+
}
|
|
964
|
+
forceUnlock(paths.lockPath);
|
|
965
|
+
console.log(`[xera:unlock] released`);
|
|
966
|
+
return 0;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// src/bin-internal/promote.ts
|
|
970
|
+
import { promotePom } from "@xera-ai/web";
|
|
971
|
+
async function promoteCmd(argv) {
|
|
972
|
+
const [ticket, className] = argv;
|
|
973
|
+
if (!ticket || !className) {
|
|
974
|
+
console.error("[xera:promote] usage: promote <TICKET> <PomClassName>");
|
|
975
|
+
return 1;
|
|
976
|
+
}
|
|
977
|
+
await promotePom({ repoRoot: process.cwd(), ticket, className });
|
|
978
|
+
console.log(`[xera:promote] moved ${className} \u2192 shared/page-objects/`);
|
|
979
|
+
return 0;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// src/bin-internal/index.ts
|
|
983
|
+
var COMMANDS = {
|
|
984
|
+
fetch: fetchCmd,
|
|
985
|
+
"validate-feature": validateFeatureCmd,
|
|
986
|
+
typecheck: typecheckCmd,
|
|
987
|
+
lint: lintCmd,
|
|
988
|
+
exec: execCmd,
|
|
989
|
+
normalize: normalizeCmd,
|
|
990
|
+
report: reportCmd,
|
|
991
|
+
post: postCmd,
|
|
992
|
+
status: statusCmd,
|
|
993
|
+
unlock: unlockCmd,
|
|
994
|
+
promote: promoteCmd
|
|
995
|
+
};
|
|
996
|
+
async function run(argv) {
|
|
997
|
+
const [cmd, ...rest] = argv;
|
|
998
|
+
if (!cmd || !COMMANDS[cmd]) {
|
|
999
|
+
console.error(`Usage: xera-internal <command> [args...]
|
|
1000
|
+
Commands: ${Object.keys(COMMANDS).join(", ")}`);
|
|
1001
|
+
return 1;
|
|
1002
|
+
}
|
|
1003
|
+
try {
|
|
1004
|
+
return await COMMANDS[cmd](rest);
|
|
1005
|
+
} catch (err) {
|
|
1006
|
+
console.error(`[xera:${cmd}] failed: ${err.message}`);
|
|
1007
|
+
return 4;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// bin/internal.ts
|
|
1012
|
+
var code = await run(process.argv.slice(2));
|
|
1013
|
+
process.exit(code);
|