@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.
- package/dist/bin/internal.js +31 -31
- package/dist/bin-internal/exec.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/adapter/types.ts +62 -0
- package/src/artifact/hash.ts +15 -0
- package/src/artifact/meta.ts +40 -0
- package/src/artifact/paths.ts +62 -0
- package/src/artifact/status.ts +55 -0
- package/src/auth/encrypt.ts +40 -0
- package/src/auth/key.ts +15 -0
- package/src/auth/refresh.ts +31 -0
- package/src/auth/state.ts +32 -0
- package/src/bin-internal/exec.ts +107 -0
- package/src/bin-internal/fetch.ts +84 -0
- package/src/bin-internal/index.ts +39 -0
- package/src/bin-internal/lint.ts +12 -0
- package/src/bin-internal/normalize.ts +20 -0
- package/src/bin-internal/post.ts +35 -0
- package/src/bin-internal/promote.ts +12 -0
- package/src/bin-internal/report.ts +47 -0
- package/src/bin-internal/status-cmd.ts +13 -0
- package/src/bin-internal/typecheck.ts +12 -0
- package/src/bin-internal/unlock.ts +18 -0
- package/src/bin-internal/validate-feature.ts +14 -0
- package/src/classifier/aggregate.ts +26 -0
- package/src/classifier/history.ts +27 -0
- package/src/classifier/types.ts +25 -0
- package/src/config/define.ts +2 -0
- package/src/config/load.ts +14 -0
- package/src/config/schema.ts +74 -0
- package/src/index.ts +19 -0
- package/src/jira/client.ts +20 -0
- package/src/jira/fields.ts +21 -0
- package/src/jira/mcp-backend.ts +40 -0
- package/src/jira/rest-backend.ts +79 -0
- package/src/jira/retry.ts +24 -0
- package/src/jira/types.ts +21 -0
- package/src/lock/file-lock.ts +57 -0
- package/src/logging/ndjson-logger.ts +25 -0
- package/src/reporter/jira-comment.ts +31 -0
- package/src/reporter/status-writer.ts +37 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { loadConfig } from '../config/load';
|
|
4
|
+
import { resolveArtifactPaths } from '../artifact/paths';
|
|
5
|
+
import { hashString } from '../artifact/hash';
|
|
6
|
+
import { writeMeta, readMeta } from '../artifact/meta';
|
|
7
|
+
import { createJiraClient } from '../jira/client';
|
|
8
|
+
import type { JiraTicket } from '../jira/types';
|
|
9
|
+
|
|
10
|
+
export interface FetchCmdOpts { cwd?: string; }
|
|
11
|
+
|
|
12
|
+
export async function fetchCmd(argv: string[], opts: FetchCmdOpts = {}): Promise<number> {
|
|
13
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
14
|
+
const ticket = argv[0];
|
|
15
|
+
if (!ticket) {
|
|
16
|
+
console.error('[xera:fetch] usage: xera-internal fetch <TICKET>');
|
|
17
|
+
return 1;
|
|
18
|
+
}
|
|
19
|
+
const config = await loadConfig(cwd);
|
|
20
|
+
const paths = resolveArtifactPaths(cwd, ticket);
|
|
21
|
+
|
|
22
|
+
// Test injection: skip real Jira when XERA_TEST_JIRA env is set.
|
|
23
|
+
let t: JiraTicket;
|
|
24
|
+
if (process.env.XERA_TEST_JIRA) {
|
|
25
|
+
t = JSON.parse(process.env.XERA_TEST_JIRA) as JiraTicket;
|
|
26
|
+
} else {
|
|
27
|
+
const client = await createJiraClient({
|
|
28
|
+
baseUrl: config.jira.baseUrl,
|
|
29
|
+
preferMcp: true,
|
|
30
|
+
...(process.env.JIRA_EMAIL && process.env.JIRA_API_TOKEN
|
|
31
|
+
? { rest: { email: process.env.JIRA_EMAIL, apiToken: process.env.JIRA_API_TOKEN } }
|
|
32
|
+
: {}),
|
|
33
|
+
});
|
|
34
|
+
const fieldMap = config.jira.fields.acceptanceCriteria !== undefined
|
|
35
|
+
? { story: config.jira.fields.story, acceptanceCriteria: config.jira.fields.acceptanceCriteria }
|
|
36
|
+
: { story: config.jira.fields.story };
|
|
37
|
+
t = await client.fetchTicket(ticket, fieldMap);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const story = renderStory(t);
|
|
41
|
+
mkdirSync(dirname(paths.storyPath), { recursive: true });
|
|
42
|
+
writeFileSync(paths.storyPath, story);
|
|
43
|
+
|
|
44
|
+
const existing = readMeta(paths.metaPath);
|
|
45
|
+
writeMeta(paths.metaPath, {
|
|
46
|
+
ticket,
|
|
47
|
+
adapter: 'web',
|
|
48
|
+
xera_version: '0.1.0',
|
|
49
|
+
prompts_version: '1.0.0',
|
|
50
|
+
...(existing ?? {}),
|
|
51
|
+
// Re-stamp the just-fetched fields:
|
|
52
|
+
story_hash: hashString(story),
|
|
53
|
+
fetched_at: new Date().toISOString(),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
console.log(`[xera:fetch] wrote ${paths.storyPath}`);
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function renderStory(t: JiraTicket): string {
|
|
61
|
+
const lines: string[] = [];
|
|
62
|
+
lines.push(`# ${t.key}: ${t.summary}`, '');
|
|
63
|
+
|
|
64
|
+
const story = t.story.trim();
|
|
65
|
+
// Avoid double "## Story" heading when Jira description already starts with it.
|
|
66
|
+
if (/^##\s+story\b/i.test(story)) {
|
|
67
|
+
lines.push(story, '');
|
|
68
|
+
} else {
|
|
69
|
+
lines.push('## Story', '', story, '');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (t.acceptanceCriteria && t.acceptanceCriteria.trim()) {
|
|
73
|
+
const ac = t.acceptanceCriteria.trim();
|
|
74
|
+
if (/^##\s+acceptance\s+criteria\b/i.test(ac)) {
|
|
75
|
+
lines.push(ac, '');
|
|
76
|
+
} else {
|
|
77
|
+
lines.push('## Acceptance Criteria', '', ac, '');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (t.attachments.length > 0) {
|
|
81
|
+
lines.push('## Attachments', '', ...t.attachments.map((a) => `- [${a.filename}](${a.url})`), '');
|
|
82
|
+
}
|
|
83
|
+
return lines.join('\n');
|
|
84
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { fetchCmd } from './fetch';
|
|
2
|
+
import { validateFeatureCmd } from './validate-feature';
|
|
3
|
+
import { typecheckCmd } from './typecheck';
|
|
4
|
+
import { lintCmd } from './lint';
|
|
5
|
+
import { execCmd } from './exec';
|
|
6
|
+
import { normalizeCmd } from './normalize';
|
|
7
|
+
import { reportCmd } from './report';
|
|
8
|
+
import { postCmd } from './post';
|
|
9
|
+
import { statusCmd } from './status-cmd';
|
|
10
|
+
import { unlockCmd } from './unlock';
|
|
11
|
+
import { promoteCmd } from './promote';
|
|
12
|
+
|
|
13
|
+
const COMMANDS: Record<string, (argv: string[]) => Promise<number>> = {
|
|
14
|
+
fetch: fetchCmd,
|
|
15
|
+
'validate-feature': validateFeatureCmd,
|
|
16
|
+
typecheck: typecheckCmd,
|
|
17
|
+
lint: lintCmd,
|
|
18
|
+
exec: execCmd,
|
|
19
|
+
normalize: normalizeCmd,
|
|
20
|
+
report: reportCmd,
|
|
21
|
+
post: postCmd,
|
|
22
|
+
status: statusCmd,
|
|
23
|
+
unlock: unlockCmd,
|
|
24
|
+
promote: promoteCmd,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export async function run(argv: string[]): Promise<number> {
|
|
28
|
+
const [cmd, ...rest] = argv;
|
|
29
|
+
if (!cmd || !COMMANDS[cmd]) {
|
|
30
|
+
console.error(`Usage: xera-internal <command> [args...]\nCommands: ${Object.keys(COMMANDS).join(', ')}`);
|
|
31
|
+
return 1;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
return await COMMANDS[cmd]!(rest);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
console.error(`[xera:${cmd}] failed: ${(err as Error).message}`);
|
|
37
|
+
return 4;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { resolveArtifactPaths } from '../artifact/paths';
|
|
2
|
+
import { lintTicket } from '@xera-ai/web';
|
|
3
|
+
|
|
4
|
+
export async function lintCmd(argv: string[]): Promise<number> {
|
|
5
|
+
const ticket = argv[0];
|
|
6
|
+
if (!ticket) { console.error('[xera:lint] usage: lint <TICKET>'); return 1; }
|
|
7
|
+
const paths = resolveArtifactPaths(process.cwd(), ticket);
|
|
8
|
+
const r = await lintTicket(paths.ticketDir);
|
|
9
|
+
if (r.ok) { console.log('[xera:lint] ok'); return 0; }
|
|
10
|
+
for (const w of r.warnings) console.error(`[xera:lint] ${w.file}:${w.line} [${w.rule}] ${w.message}`);
|
|
11
|
+
return 2;
|
|
12
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { resolveArtifactPaths } from '../artifact/paths';
|
|
2
|
+
import { normalizeRun } from '@xera-ai/web';
|
|
3
|
+
import { readdirSync, existsSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export async function normalizeCmd(argv: string[]): Promise<number> {
|
|
7
|
+
const ticket = argv[0];
|
|
8
|
+
if (!ticket) { console.error('[xera:normalize] usage: normalize <TICKET> [--run=<runId>]'); return 1; }
|
|
9
|
+
const paths = resolveArtifactPaths(process.cwd(), ticket);
|
|
10
|
+
const runArg = argv.find(a => a.startsWith('--run='));
|
|
11
|
+
const runId = runArg
|
|
12
|
+
? runArg.split('=')[1]!
|
|
13
|
+
: readdirSync(paths.runsDir).filter(n => !n.startsWith('.')).sort().pop()!;
|
|
14
|
+
if (!runId) { console.error('[xera:normalize] no run found'); return 1; }
|
|
15
|
+
const runDir = join(paths.runsDir, runId);
|
|
16
|
+
if (!existsSync(runDir)) { console.error(`[xera:normalize] runs/${runId} missing`); return 1; }
|
|
17
|
+
const r = await normalizeRun({ runId, runDir });
|
|
18
|
+
console.log(`[xera:normalize] wrote normalized.json (scrubbed_fields_count=${r.scrubbed_fields_count})`);
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { resolveArtifactPaths } from '../artifact/paths';
|
|
4
|
+
import { loadConfig } from '../config/load';
|
|
5
|
+
import { readStatus, writeStatus } from '../artifact/status';
|
|
6
|
+
import { createJiraClient } from '../jira/client';
|
|
7
|
+
|
|
8
|
+
export async function postCmd(argv: string[]): Promise<number> {
|
|
9
|
+
const ticket = argv[0];
|
|
10
|
+
if (!ticket) { console.error('[xera:post] usage: post <TICKET>'); return 1; }
|
|
11
|
+
const cwd = process.cwd();
|
|
12
|
+
const config = await loadConfig(cwd);
|
|
13
|
+
if (!config.reporting.postToJira) {
|
|
14
|
+
console.log('[xera:post] postToJira disabled in config; skipping');
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
const paths = resolveArtifactPaths(cwd, ticket);
|
|
18
|
+
const draftPath = join(paths.ticketDir, 'jira-comment.draft.md');
|
|
19
|
+
if (!existsSync(draftPath)) { console.error(`[xera:post] no draft at ${draftPath}; run \`xera-internal report\` first.`); return 1; }
|
|
20
|
+
const body = readFileSync(draftPath, 'utf8');
|
|
21
|
+
|
|
22
|
+
const client = await createJiraClient({
|
|
23
|
+
baseUrl: config.jira.baseUrl,
|
|
24
|
+
preferMcp: true,
|
|
25
|
+
...(process.env.JIRA_EMAIL && process.env.JIRA_API_TOKEN
|
|
26
|
+
? { rest: { email: process.env.JIRA_EMAIL, apiToken: process.env.JIRA_API_TOKEN } }
|
|
27
|
+
: {}),
|
|
28
|
+
});
|
|
29
|
+
const r = await client.postComment(ticket, body);
|
|
30
|
+
console.log(`[xera:post] posted comment id=${r.id}`);
|
|
31
|
+
|
|
32
|
+
const s = readStatus(paths.statusPath);
|
|
33
|
+
if (s) writeStatus(paths.statusPath, { ...s, last_jira_comment_id: r.id });
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { promotePom } from '@xera-ai/web';
|
|
2
|
+
|
|
3
|
+
export async function promoteCmd(argv: string[]): Promise<number> {
|
|
4
|
+
const [ticket, className] = argv;
|
|
5
|
+
if (!ticket || !className) {
|
|
6
|
+
console.error('[xera:promote] usage: promote <TICKET> <PomClassName>');
|
|
7
|
+
return 1;
|
|
8
|
+
}
|
|
9
|
+
await promotePom({ repoRoot: process.cwd(), ticket, className });
|
|
10
|
+
console.log(`[xera:promote] moved ${className} → shared/page-objects/`);
|
|
11
|
+
return 0;
|
|
12
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { resolveArtifactPaths } from '../artifact/paths';
|
|
4
|
+
import { aggregateScenarios } from '../classifier/aggregate';
|
|
5
|
+
import { writeStatusFromClassification } from '../reporter/status-writer';
|
|
6
|
+
import { buildJiraComment } from '../reporter/jira-comment';
|
|
7
|
+
import type { ScenarioClassification } from '../classifier/types';
|
|
8
|
+
|
|
9
|
+
interface ReportInput {
|
|
10
|
+
scenarios: ScenarioClassification[];
|
|
11
|
+
scenarioCounts: { total: number; passed: number; failed: number; skipped: number };
|
|
12
|
+
runId: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function reportCmd(argv: string[]): Promise<number> {
|
|
16
|
+
const ticket = argv[0];
|
|
17
|
+
const inputArg = argv.find(a => a.startsWith('--input='));
|
|
18
|
+
if (!ticket || !inputArg) {
|
|
19
|
+
console.error('[xera:report] usage: report <TICKET> --input=<classifier-output.json>');
|
|
20
|
+
return 1;
|
|
21
|
+
}
|
|
22
|
+
const paths = resolveArtifactPaths(process.cwd(), ticket);
|
|
23
|
+
const input = JSON.parse(readFileSync(inputArg.slice('--input='.length), 'utf8')) as ReportInput;
|
|
24
|
+
|
|
25
|
+
const aggregated = aggregateScenarios(input.scenarios);
|
|
26
|
+
const ts = new Date().toISOString();
|
|
27
|
+
writeStatusFromClassification(paths.statusPath, {
|
|
28
|
+
ticket,
|
|
29
|
+
runTs: ts,
|
|
30
|
+
classification: aggregated,
|
|
31
|
+
scenarioCounts: input.scenarioCounts,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const md = buildJiraComment({
|
|
35
|
+
ticket,
|
|
36
|
+
runId: input.runId,
|
|
37
|
+
overall: aggregated.overall,
|
|
38
|
+
overallConfidence: aggregated.overallConfidence,
|
|
39
|
+
scenarios: aggregated.scenarios,
|
|
40
|
+
xeraVersion: '0.1.0',
|
|
41
|
+
promptsVersion: '1.0.0',
|
|
42
|
+
});
|
|
43
|
+
const draftPath = join(paths.ticketDir, 'jira-comment.draft.md');
|
|
44
|
+
writeFileSync(draftPath, md);
|
|
45
|
+
console.log(`[xera:report] wrote status.json and ${draftPath}`);
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { resolveArtifactPaths } from '../artifact/paths';
|
|
2
|
+
import { readStatus } from '../artifact/status';
|
|
3
|
+
|
|
4
|
+
export async function statusCmd(argv: string[]): Promise<number> {
|
|
5
|
+
const ticket = argv[0];
|
|
6
|
+
if (!ticket) { console.error('[xera:status] usage: status <TICKET>'); return 1; }
|
|
7
|
+
const paths = resolveArtifactPaths(process.cwd(), ticket);
|
|
8
|
+
const s = readStatus(paths.statusPath);
|
|
9
|
+
if (!s) { console.log(`[xera:status] no status yet for ${ticket}`); return 0; }
|
|
10
|
+
console.log(`${ticket}: ${s.result} (${s.classification}, conf=${s.confidence}) — ${s.scenarios.passed}/${s.scenarios.total} passed, last run ${s.lastRun}`);
|
|
11
|
+
for (const h of s.history.slice(0, 5)) console.log(` ${h.ts} ${h.result} ${h.class}`);
|
|
12
|
+
return 0;
|
|
13
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { resolveArtifactPaths } from '../artifact/paths';
|
|
2
|
+
import { typecheckTicket } from '@xera-ai/web';
|
|
3
|
+
|
|
4
|
+
export async function typecheckCmd(argv: string[]): Promise<number> {
|
|
5
|
+
const ticket = argv[0];
|
|
6
|
+
if (!ticket) { console.error('[xera:typecheck] usage: typecheck <TICKET>'); return 1; }
|
|
7
|
+
const paths = resolveArtifactPaths(process.cwd(), ticket);
|
|
8
|
+
const r = await typecheckTicket(paths.ticketDir);
|
|
9
|
+
if (r.ok) { console.log('[xera:typecheck] ok'); return 0; }
|
|
10
|
+
for (const e of r.errors) console.error(`[xera:typecheck] ${e}`);
|
|
11
|
+
return 2;
|
|
12
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { resolveArtifactPaths } from '../artifact/paths';
|
|
2
|
+
import { isLockStale, readLock, forceUnlock } from '../lock/file-lock';
|
|
3
|
+
|
|
4
|
+
export async function unlockCmd(argv: string[]): Promise<number> {
|
|
5
|
+
const ticket = argv[0];
|
|
6
|
+
if (!ticket) { console.error('[xera:unlock] usage: unlock <TICKET> [--force]'); return 1; }
|
|
7
|
+
const paths = resolveArtifactPaths(process.cwd(), ticket);
|
|
8
|
+
const lock = readLock(paths.lockPath);
|
|
9
|
+
if (!lock) { console.log(`[xera:unlock] no lock for ${ticket}`); return 0; }
|
|
10
|
+
const force = argv.includes('--force');
|
|
11
|
+
if (!force && !isLockStale(paths.lockPath)) {
|
|
12
|
+
console.error(`[xera:unlock] lock is held by PID ${lock.pid} on ${lock.hostname} (active). Pass --force to override.`);
|
|
13
|
+
return 1;
|
|
14
|
+
}
|
|
15
|
+
forceUnlock(paths.lockPath);
|
|
16
|
+
console.log(`[xera:unlock] released`);
|
|
17
|
+
return 0;
|
|
18
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { resolveArtifactPaths } from '../artifact/paths';
|
|
3
|
+
import { validateGherkin } from '@xera-ai/web';
|
|
4
|
+
|
|
5
|
+
export async function validateFeatureCmd(argv: string[]): Promise<number> {
|
|
6
|
+
const ticket = argv[0];
|
|
7
|
+
if (!ticket) { console.error('[xera:validate-feature] usage: validate-feature <TICKET>'); return 1; }
|
|
8
|
+
const paths = resolveArtifactPaths(process.cwd(), ticket);
|
|
9
|
+
if (!existsSync(paths.featurePath)) { console.error(`[xera:validate-feature] missing ${paths.featurePath}`); return 1; }
|
|
10
|
+
const r = validateGherkin(readFileSync(paths.featurePath, 'utf8'));
|
|
11
|
+
if (r.ok) { console.log('[xera:validate-feature] ok'); return 0; }
|
|
12
|
+
for (const e of r.errors) console.error(`[xera:validate-feature] line ${e.line}: ${e.message}`);
|
|
13
|
+
return 2;
|
|
14
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ClassifyOutput, ScenarioClassification, Confidence } from './types';
|
|
2
|
+
|
|
3
|
+
const CLASS_PRIORITY: Array<ClassifyOutput['overall']> = [
|
|
4
|
+
'REAL_BUG', 'TEST_BUG', 'SELECTOR_DRIFT', 'FLAKY', 'PASS',
|
|
5
|
+
];
|
|
6
|
+
|
|
7
|
+
const CONF_RANK: Record<Confidence, number> = { low: 1, medium: 2, high: 3 };
|
|
8
|
+
|
|
9
|
+
export function aggregateScenarios(scenarios: ScenarioClassification[]): ClassifyOutput {
|
|
10
|
+
if (scenarios.length === 0) {
|
|
11
|
+
return { overall: 'PASS', overallConfidence: 'low', scenarios: [] };
|
|
12
|
+
}
|
|
13
|
+
if (scenarios.every(s => s.outcome === 'PASS')) {
|
|
14
|
+
return { overall: 'PASS', overallConfidence: 'high', scenarios };
|
|
15
|
+
}
|
|
16
|
+
let chosen: ClassifyOutput['overall'] = 'PASS';
|
|
17
|
+
for (const cls of CLASS_PRIORITY) {
|
|
18
|
+
if (scenarios.some(s => s.class === cls)) { chosen = cls; break; }
|
|
19
|
+
}
|
|
20
|
+
const matching = scenarios.filter(s => s.class === chosen);
|
|
21
|
+
const minConf = matching.reduce<Confidence>(
|
|
22
|
+
(acc, s) => CONF_RANK[s.confidence] < CONF_RANK[acc] ? s.confidence : acc,
|
|
23
|
+
'high',
|
|
24
|
+
);
|
|
25
|
+
return { overall: chosen, overallConfidence: minConf, scenarios };
|
|
26
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Classification } from '../artifact/status';
|
|
2
|
+
|
|
3
|
+
export interface HistorySummary {
|
|
4
|
+
firstRun: boolean;
|
|
5
|
+
consecutiveFails: number;
|
|
6
|
+
lastResult: 'PASS' | 'FAIL' | null;
|
|
7
|
+
lastClass: Classification | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function summarizeHistory(
|
|
11
|
+
history: Array<{ ts: string; result: 'PASS' | 'FAIL'; class: Classification }>,
|
|
12
|
+
): HistorySummary {
|
|
13
|
+
if (history.length === 0) {
|
|
14
|
+
return { firstRun: true, consecutiveFails: 0, lastResult: null, lastClass: null };
|
|
15
|
+
}
|
|
16
|
+
let consecutiveFails = 0;
|
|
17
|
+
for (const entry of history) {
|
|
18
|
+
if (entry.result === 'FAIL') consecutiveFails++;
|
|
19
|
+
else break;
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
firstRun: false,
|
|
23
|
+
consecutiveFails,
|
|
24
|
+
lastResult: history[0]!.result,
|
|
25
|
+
lastClass: history[0]!.class,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Classification } from '../artifact/status';
|
|
2
|
+
|
|
3
|
+
export type { Classification };
|
|
4
|
+
export type Confidence = 'low' | 'medium' | 'high';
|
|
5
|
+
|
|
6
|
+
export interface ScenarioClassification {
|
|
7
|
+
name: string;
|
|
8
|
+
outcome: 'PASS' | 'FAIL' | 'SKIPPED';
|
|
9
|
+
class: Classification;
|
|
10
|
+
confidence: Confidence;
|
|
11
|
+
rationale: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ClassifyOutput {
|
|
15
|
+
overall: Classification;
|
|
16
|
+
overallConfidence: Confidence;
|
|
17
|
+
scenarios: ScenarioClassification[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ClassifyContextInput {
|
|
21
|
+
history: Array<{ ts: string; result: 'PASS' | 'FAIL'; class: Classification }>;
|
|
22
|
+
storyHashChanged: boolean;
|
|
23
|
+
specHashChanged: boolean;
|
|
24
|
+
firstRun: boolean;
|
|
25
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { XeraConfigSchema, type XeraConfig } from './schema';
|
|
5
|
+
|
|
6
|
+
export async function loadConfig(cwd: string): Promise<XeraConfig> {
|
|
7
|
+
const path = join(cwd, 'xera.config.ts');
|
|
8
|
+
if (!existsSync(path)) {
|
|
9
|
+
throw new Error(`xera.config.ts not found in ${cwd}`);
|
|
10
|
+
}
|
|
11
|
+
const mod = await import(pathToFileURL(path).href);
|
|
12
|
+
const raw = mod.default ?? mod;
|
|
13
|
+
return XeraConfigSchema.parse(raw);
|
|
14
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
const AuthRoleSchema = z.object({
|
|
4
|
+
envEmail: z.string().min(1),
|
|
5
|
+
envPassword: z.string().min(1),
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
const AuthSchema = z.object({
|
|
9
|
+
strategy: z.enum(['storageState', 'apiToken', 'none']).default('none'),
|
|
10
|
+
ttl: z.string().default('8h'),
|
|
11
|
+
refreshBuffer: z.string().default('30m'),
|
|
12
|
+
setupScript: z.string().optional(),
|
|
13
|
+
roles: z.record(z.string(), AuthRoleSchema).default({}),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const WebSchema = z.object({
|
|
17
|
+
baseUrl: z.record(z.string(), z.string().url()).refine(m => Object.keys(m).length > 0, {
|
|
18
|
+
message: 'baseUrl must have at least one environment',
|
|
19
|
+
}),
|
|
20
|
+
defaultEnv: z.string(),
|
|
21
|
+
auth: AuthSchema.default({}),
|
|
22
|
+
testData: z
|
|
23
|
+
.object({
|
|
24
|
+
users: z.record(z.string(), z.object({ fromAuth: z.string() })).default({}),
|
|
25
|
+
})
|
|
26
|
+
.default({ users: {} }),
|
|
27
|
+
}).refine(w => w.baseUrl[w.defaultEnv] !== undefined, {
|
|
28
|
+
message: 'defaultEnv must exist in baseUrl map',
|
|
29
|
+
path: ['defaultEnv'],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const JiraSchema = z.object({
|
|
33
|
+
baseUrl: z.string().url(),
|
|
34
|
+
projectKeys: z.array(z.string().min(1)).min(1),
|
|
35
|
+
fields: z.object({
|
|
36
|
+
story: z.string().min(1),
|
|
37
|
+
acceptanceCriteria: z.string().optional(),
|
|
38
|
+
attachments: z.string().default('attachment'),
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const AISchema = z.object({
|
|
43
|
+
livePageSnapshot: z.boolean().default(true),
|
|
44
|
+
confidenceThreshold: z.enum(['low', 'medium', 'high']).default('medium'),
|
|
45
|
+
maxRetries: z
|
|
46
|
+
.object({
|
|
47
|
+
typecheck: z.number().int().min(0).max(5).default(2),
|
|
48
|
+
lint: z.number().int().min(0).max(5).default(2),
|
|
49
|
+
validateFeature: z.number().int().min(0).max(5).default(2),
|
|
50
|
+
})
|
|
51
|
+
.default({}),
|
|
52
|
+
}).default({});
|
|
53
|
+
|
|
54
|
+
const ReportingSchema = z.object({
|
|
55
|
+
language: z.enum(['en', 'vi']).default('en'),
|
|
56
|
+
postToJira: z.boolean().default(true),
|
|
57
|
+
transition: z
|
|
58
|
+
.object({
|
|
59
|
+
onPass: z.string().nullable().default(null),
|
|
60
|
+
onFail: z.string().nullable().default(null),
|
|
61
|
+
})
|
|
62
|
+
.default({}),
|
|
63
|
+
artifactLinks: z.enum(['git', 'local']).default('git'),
|
|
64
|
+
}).default({});
|
|
65
|
+
|
|
66
|
+
export const 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
|
+
export type XeraConfig = z.infer<typeof XeraConfigSchema>;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const VERSION = '0.1.0';
|
|
2
|
+
export type * from './adapter/types';
|
|
3
|
+
export * from './config/schema';
|
|
4
|
+
export * from './config/define';
|
|
5
|
+
export * from './config/load';
|
|
6
|
+
export * from './artifact/paths';
|
|
7
|
+
export * from './artifact/hash';
|
|
8
|
+
export * from './artifact/meta';
|
|
9
|
+
export * from './artifact/status';
|
|
10
|
+
export * from './logging/ndjson-logger';
|
|
11
|
+
export * from './lock/file-lock';
|
|
12
|
+
export * from './jira/types';
|
|
13
|
+
export * from './jira/client';
|
|
14
|
+
export * from './jira/fields';
|
|
15
|
+
export * from './jira/retry';
|
|
16
|
+
export * from './auth/encrypt';
|
|
17
|
+
export * from './auth/key';
|
|
18
|
+
export * from './auth/state';
|
|
19
|
+
export * from './auth/refresh';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { JiraClient } from './types';
|
|
2
|
+
import { createMcpBackend } from './mcp-backend';
|
|
3
|
+
import { createRestBackend } from './rest-backend';
|
|
4
|
+
|
|
5
|
+
export interface CreateJiraClientOptions {
|
|
6
|
+
baseUrl: string;
|
|
7
|
+
preferMcp?: boolean;
|
|
8
|
+
rest?: { email: string; apiToken: string };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function createJiraClient(opts: CreateJiraClientOptions): Promise<JiraClient> {
|
|
12
|
+
if (opts.preferMcp !== false) {
|
|
13
|
+
const mcp = await createMcpBackend(opts.baseUrl);
|
|
14
|
+
if (mcp) return mcp;
|
|
15
|
+
}
|
|
16
|
+
if (!opts.rest) {
|
|
17
|
+
throw new Error('Atlassian MCP not connected and no REST credentials provided (JIRA_EMAIL + JIRA_API_TOKEN).');
|
|
18
|
+
}
|
|
19
|
+
return createRestBackend(opts.baseUrl, opts.rest);
|
|
20
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const PREFERRED_STORY_IDS = ['description', 'story'];
|
|
2
|
+
|
|
3
|
+
export interface JiraFieldInfo {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
hasContent: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function rankStoryCandidates(fields: JiraFieldInfo[]): JiraFieldInfo[] {
|
|
10
|
+
return fields
|
|
11
|
+
.filter(f => f.hasContent)
|
|
12
|
+
.filter(f => !['attachment', 'comment', 'created', 'updated', 'reporter', 'creator'].includes(f.id))
|
|
13
|
+
.sort((a, b) => {
|
|
14
|
+
const ai = PREFERRED_STORY_IDS.indexOf(a.id);
|
|
15
|
+
const bi = PREFERRED_STORY_IDS.indexOf(b.id);
|
|
16
|
+
if (ai >= 0 && bi >= 0) return ai - bi;
|
|
17
|
+
if (ai >= 0) return -1;
|
|
18
|
+
if (bi >= 0) return 1;
|
|
19
|
+
return a.id.localeCompare(b.id);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import type { JiraClient, JiraTicket } from './types';
|
|
5
|
+
|
|
6
|
+
const MCP_ENV = 'XERA_MCP_JIRA';
|
|
7
|
+
|
|
8
|
+
export async function createMcpBackend(_baseUrl: string): Promise<JiraClient | null> {
|
|
9
|
+
if (process.env[MCP_ENV] !== '1') return null;
|
|
10
|
+
const tmpDir = join(tmpdir(), 'xera-mcp');
|
|
11
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
backend: 'mcp',
|
|
15
|
+
async fetchTicket(key, _fields): Promise<JiraTicket> {
|
|
16
|
+
const cachePath = join(tmpDir, `${key}.json`);
|
|
17
|
+
if (!existsSync(cachePath)) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`MCP-mode fetch requires the skill to first call mcp__atlassian__getJiraIssue and write ${cachePath}. ` +
|
|
20
|
+
`If you are running this directly, unset ${MCP_ENV} to use REST.`,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
const parsed = JSON.parse(readFileSync(cachePath, 'utf8')) as JiraTicket;
|
|
24
|
+
return parsed;
|
|
25
|
+
},
|
|
26
|
+
async postComment(key, body) {
|
|
27
|
+
const outPath = join(tmpDir, `${key}.comment.json`);
|
|
28
|
+
writeFileSync(outPath, JSON.stringify({ key, body }));
|
|
29
|
+
// The skill will read this file and call mcp__atlassian__addCommentToJiraIssue.
|
|
30
|
+
return { id: 'mcp-pending' };
|
|
31
|
+
},
|
|
32
|
+
async transitionStatus(key, statusName) {
|
|
33
|
+
const outPath = join(tmpDir, `${key}.transition.json`);
|
|
34
|
+
writeFileSync(outPath, JSON.stringify({ key, statusName }));
|
|
35
|
+
},
|
|
36
|
+
async listFields(_sampleKey) {
|
|
37
|
+
throw new Error('listFields is REST-only; init flow uses REST for field discovery.');
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|