@xera-ai/core 0.1.4 → 0.1.6

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.
Files changed (41) hide show
  1. package/dist/bin/internal.js +31 -31
  2. package/dist/bin-internal/exec.d.ts.map +1 -1
  3. package/package.json +3 -3
  4. package/src/adapter/types.ts +62 -0
  5. package/src/artifact/hash.ts +15 -0
  6. package/src/artifact/meta.ts +40 -0
  7. package/src/artifact/paths.ts +62 -0
  8. package/src/artifact/status.ts +55 -0
  9. package/src/auth/encrypt.ts +40 -0
  10. package/src/auth/key.ts +15 -0
  11. package/src/auth/refresh.ts +31 -0
  12. package/src/auth/state.ts +32 -0
  13. package/src/bin-internal/exec.ts +107 -0
  14. package/src/bin-internal/fetch.ts +84 -0
  15. package/src/bin-internal/index.ts +39 -0
  16. package/src/bin-internal/lint.ts +12 -0
  17. package/src/bin-internal/normalize.ts +20 -0
  18. package/src/bin-internal/post.ts +35 -0
  19. package/src/bin-internal/promote.ts +12 -0
  20. package/src/bin-internal/report.ts +47 -0
  21. package/src/bin-internal/status-cmd.ts +13 -0
  22. package/src/bin-internal/typecheck.ts +12 -0
  23. package/src/bin-internal/unlock.ts +18 -0
  24. package/src/bin-internal/validate-feature.ts +14 -0
  25. package/src/classifier/aggregate.ts +26 -0
  26. package/src/classifier/history.ts +27 -0
  27. package/src/classifier/types.ts +25 -0
  28. package/src/config/define.ts +2 -0
  29. package/src/config/load.ts +14 -0
  30. package/src/config/schema.ts +74 -0
  31. package/src/index.ts +19 -0
  32. package/src/jira/client.ts +20 -0
  33. package/src/jira/fields.ts +21 -0
  34. package/src/jira/mcp-backend.ts +40 -0
  35. package/src/jira/rest-backend.ts +79 -0
  36. package/src/jira/retry.ts +24 -0
  37. package/src/jira/types.ts +21 -0
  38. package/src/lock/file-lock.ts +57 -0
  39. package/src/logging/ndjson-logger.ts +25 -0
  40. package/src/reporter/jira-comment.ts +31 -0
  41. package/src/reporter/status-writer.ts +37 -0
@@ -0,0 +1,79 @@
1
+ import type { JiraClient, JiraFieldMap, JiraTicket } from './types';
2
+
3
+ interface RestCreds { email: string; apiToken: string; }
4
+
5
+ export function createRestBackend(baseUrl: string, creds: RestCreds): JiraClient {
6
+ const authHeader = `Basic ${Buffer.from(`${creds.email}:${creds.apiToken}`).toString('base64')}`;
7
+ const base = baseUrl.replace(/\/$/, '');
8
+
9
+ async function req(path: string, init?: RequestInit): Promise<Response> {
10
+ const r = await fetch(`${base}${path}`, {
11
+ ...init,
12
+ headers: {
13
+ Authorization: authHeader,
14
+ Accept: 'application/json',
15
+ 'Content-Type': 'application/json',
16
+ ...(init?.headers ?? {}),
17
+ },
18
+ });
19
+ if (!r.ok && r.status !== 201) {
20
+ throw new Error(`Jira REST ${init?.method ?? 'GET'} ${path} failed: ${r.status} ${await r.text()}`);
21
+ }
22
+ return r;
23
+ }
24
+
25
+ return {
26
+ backend: 'rest',
27
+ async fetchTicket(key, fields): Promise<JiraTicket> {
28
+ const want = ['summary', fields.story];
29
+ if (fields.acceptanceCriteria) want.push(fields.acceptanceCriteria);
30
+ want.push('attachment');
31
+ const r = await req(`/rest/api/3/issue/${encodeURIComponent(key)}?fields=${want.join(',')}`);
32
+ const json = (await r.json()) as { key: string; fields: Record<string, unknown> };
33
+ const f = json.fields;
34
+ const attachments = Array.isArray(f.attachment)
35
+ ? (f.attachment as Array<{ filename: string; content: string }>).map(a => ({ filename: a.filename, url: a.content }))
36
+ : [];
37
+ const ticket: JiraTicket = {
38
+ key: json.key,
39
+ summary: String(f.summary ?? ''),
40
+ story: String(f[fields.story] ?? ''),
41
+ attachments,
42
+ raw: f,
43
+ };
44
+ if (fields.acceptanceCriteria) {
45
+ ticket.acceptanceCriteria = String(f[fields.acceptanceCriteria] ?? '');
46
+ }
47
+ return ticket;
48
+ },
49
+ async postComment(key, body) {
50
+ const r = await req(`/rest/api/3/issue/${encodeURIComponent(key)}/comment`, {
51
+ method: 'POST',
52
+ body: JSON.stringify({
53
+ body: { type: 'doc', version: 1, content: [{ type: 'paragraph', content: [{ type: 'text', text: body }] }] },
54
+ }),
55
+ });
56
+ const json = (await r.json()) as { id: string };
57
+ return { id: json.id };
58
+ },
59
+ async transitionStatus(key, statusName) {
60
+ const tr = await req(`/rest/api/3/issue/${encodeURIComponent(key)}/transitions`);
61
+ const json = (await tr.json()) as { transitions: Array<{ id: string; name: string }> };
62
+ const t = json.transitions.find(x => x.name === statusName);
63
+ if (!t) throw new Error(`No transition named "${statusName}" available for ${key}`);
64
+ await req(`/rest/api/3/issue/${encodeURIComponent(key)}/transitions`, {
65
+ method: 'POST',
66
+ body: JSON.stringify({ transition: { id: t.id } }),
67
+ });
68
+ },
69
+ async listFields(sampleKey) {
70
+ const r = await req(`/rest/api/3/issue/${encodeURIComponent(sampleKey)}?fields=*all`);
71
+ const json = (await r.json()) as { fields: Record<string, unknown> };
72
+ return Object.entries(json.fields).map(([id, value]) => ({
73
+ id,
74
+ name: id,
75
+ hasContent: value !== null && value !== undefined && value !== '',
76
+ }));
77
+ },
78
+ };
79
+ }
@@ -0,0 +1,24 @@
1
+ export interface RetryOptions {
2
+ maxAttempts: number;
3
+ baseMs: number;
4
+ factor: number;
5
+ shouldRetry?: (err: unknown) => boolean;
6
+ }
7
+
8
+ export async function withRetry<T>(fn: () => Promise<T>, opts: RetryOptions): Promise<T> {
9
+ let attempt = 0;
10
+ let lastErr: unknown;
11
+ while (attempt < opts.maxAttempts) {
12
+ try {
13
+ return await fn();
14
+ } catch (err) {
15
+ lastErr = err;
16
+ if (opts.shouldRetry && !opts.shouldRetry(err)) throw err;
17
+ attempt++;
18
+ if (attempt >= opts.maxAttempts) break;
19
+ const delay = opts.baseMs * Math.pow(opts.factor, attempt - 1);
20
+ await new Promise(r => setTimeout(r, delay));
21
+ }
22
+ }
23
+ throw lastErr;
24
+ }
@@ -0,0 +1,21 @@
1
+ export interface JiraTicket {
2
+ key: string;
3
+ summary: string;
4
+ story: string;
5
+ acceptanceCriteria?: string;
6
+ attachments: Array<{ filename: string; url: string }>;
7
+ raw: Record<string, unknown>;
8
+ }
9
+
10
+ export interface JiraFieldMap {
11
+ story: string;
12
+ acceptanceCriteria?: string;
13
+ }
14
+
15
+ export interface JiraClient {
16
+ readonly backend: 'mcp' | 'rest';
17
+ fetchTicket(key: string, fields: JiraFieldMap): Promise<JiraTicket>;
18
+ postComment(key: string, body: string): Promise<{ id: string }>;
19
+ transitionStatus(key: string, statusName: string): Promise<void>;
20
+ listFields(sampleKey: string): Promise<Array<{ id: string; name: string; hasContent: boolean }>>;
21
+ }
@@ -0,0 +1,57 @@
1
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { hostname } from 'node:os';
4
+
5
+ export interface LockData {
6
+ pid: number;
7
+ hostname: string;
8
+ started_at: string;
9
+ run_id: string;
10
+ }
11
+
12
+ export function acquireLock(path: string, runId: string): boolean {
13
+ if (existsSync(path)) return false;
14
+ mkdirSync(dirname(path), { recursive: true });
15
+ const data: LockData = {
16
+ pid: process.pid,
17
+ hostname: hostname(),
18
+ started_at: new Date().toISOString(),
19
+ run_id: runId,
20
+ };
21
+ // Use 'wx' flag for atomic-ish create-only.
22
+ try {
23
+ writeFileSync(path, JSON.stringify(data), { flag: 'wx' });
24
+ return true;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ export function releaseLock(path: string): void {
31
+ if (existsSync(path)) unlinkSync(path);
32
+ }
33
+
34
+ export function readLock(path: string): LockData | null {
35
+ if (!existsSync(path)) return null;
36
+ return JSON.parse(readFileSync(path, 'utf8')) as LockData;
37
+ }
38
+
39
+ export function isLockStale(path: string): boolean {
40
+ const lock = readLock(path);
41
+ if (!lock) return true;
42
+ if (lock.hostname !== hostname()) {
43
+ // Cannot verify a remote PID; treat as not stale.
44
+ return false;
45
+ }
46
+ try {
47
+ // Signal 0 = "check if process exists". Throws if not.
48
+ process.kill(lock.pid, 0);
49
+ return false;
50
+ } catch {
51
+ return true;
52
+ }
53
+ }
54
+
55
+ export function forceUnlock(path: string): void {
56
+ releaseLock(path);
57
+ }
@@ -0,0 +1,25 @@
1
+ import { appendFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+
4
+ export interface LogEntry {
5
+ ts: string;
6
+ [key: string]: unknown;
7
+ }
8
+
9
+ export class NdjsonLogger {
10
+ constructor(private readonly path: string) {
11
+ mkdirSync(dirname(path), { recursive: true });
12
+ }
13
+
14
+ log(payload: Record<string, unknown>): void {
15
+ const entry: LogEntry = { ts: new Date().toISOString(), ...payload };
16
+ appendFileSync(this.path, `${JSON.stringify(entry)}\n`);
17
+ }
18
+
19
+ static readAll(path: string): LogEntry[] {
20
+ if (!existsSync(path)) return [];
21
+ const txt = readFileSync(path, 'utf8').trim();
22
+ if (!txt) return [];
23
+ return txt.split('\n').map(line => JSON.parse(line) as LogEntry);
24
+ }
25
+ }
@@ -0,0 +1,31 @@
1
+ import type { ClassifyOutput } from '../classifier/types';
2
+
3
+ export interface JiraCommentInput extends ClassifyOutput {
4
+ ticket: string;
5
+ runId: string;
6
+ xeraVersion: string;
7
+ promptsVersion: string;
8
+ }
9
+
10
+ export function buildJiraComment(input: JiraCommentInput): string {
11
+ const passed = input.scenarios.filter(s => s.outcome === 'PASS').length;
12
+ const total = input.scenarios.length;
13
+ const icon = input.overall === 'PASS' ? '🟢' : '🔴';
14
+ const header = `## ${icon} xera test ${input.overall === 'PASS' ? 'PASSED' : 'FAILED'} — ${input.ticket} (run ${input.runId})`;
15
+ const meta = `**Classification:** ${input.overall} (confidence: ${input.overallConfidence})\n**Scenarios:** ${passed} / ${total} passed`;
16
+
17
+ const failingBlocks = input.scenarios
18
+ .filter(s => s.outcome === 'FAIL')
19
+ .map(s => `### Scenario: ${s.name}\n- **Classification:** ${s.class} (confidence: ${s.confidence})\n- **Diagnosis:** ${s.rationale}`)
20
+ .join('\n\n');
21
+
22
+ const reproduce = `### Reproduce locally\n\n\`\`\`\nbunx xera-internal exec ${input.ticket} --replay=${input.runId}\n\`\`\``;
23
+
24
+ const next = input.overall === 'PASS'
25
+ ? ''
26
+ : `### Suggested next action\n- Review the failing scenarios above.\n- Re-run after changes: open Claude Code and run \`/xera-run ${input.ticket}\`.\n\n`;
27
+
28
+ const footer = `---\nxera v${input.xeraVersion} • prompts v${input.promptsVersion}`;
29
+
30
+ return [header, '', meta, '', failingBlocks, '', next, reproduce, '', footer].filter(Boolean).join('\n');
31
+ }
@@ -0,0 +1,37 @@
1
+ import type { ClassifyOutput } from '../classifier/types';
2
+ import { existsSync } from 'node:fs';
3
+ import { readStatus, writeStatus, appendHistory, type StatusJson } from '../artifact/status';
4
+
5
+ export interface StatusWriterInput {
6
+ ticket: string;
7
+ runTs: string;
8
+ classification: ClassifyOutput;
9
+ scenarioCounts: { total: number; passed: number; failed: number; skipped: number };
10
+ }
11
+
12
+ export function writeStatusFromClassification(path: string, input: StatusWriterInput): void {
13
+ const result: StatusJson['result'] = input.classification.overall === 'PASS' ? 'PASS' : 'FAIL';
14
+ const entry = { ts: input.runTs, result, class: input.classification.overall };
15
+ if (!existsSync(path)) {
16
+ writeStatus(path, {
17
+ ticket: input.ticket,
18
+ lastRun: input.runTs,
19
+ result,
20
+ classification: input.classification.overall,
21
+ confidence: input.classification.overallConfidence,
22
+ scenarios: input.scenarioCounts,
23
+ history: [entry],
24
+ });
25
+ return;
26
+ }
27
+ const cur = readStatus(path)!;
28
+ writeStatus(path, {
29
+ ...cur,
30
+ lastRun: input.runTs,
31
+ result,
32
+ classification: input.classification.overall,
33
+ confidence: input.classification.overallConfidence,
34
+ scenarios: input.scenarioCounts,
35
+ });
36
+ appendHistory(path, entry);
37
+ }