@xera-ai/core 0.1.3 → 0.1.5
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/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 +111 -0
- package/src/bin-internal/fetch.ts +71 -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,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
|
+
}
|
|
@@ -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
|
+
}
|