canary-test-cli 5.15.0 → 6.0.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/agent/frameworks/registry.json +655 -0
- package/bin/canary.js +20 -15
- package/dist/doctor-manifest.d.ts +94 -0
- package/dist/doctor.d.ts +67 -0
- package/dist/engine/analysis/cli.js +270 -0
- package/dist/engine/analysis/engine.js +146 -0
- package/dist/engine/analysis/reports.js +0 -0
- package/dist/engine/analysis/rows.js +9 -0
- package/dist/engine/cli-commands.js +618 -0
- package/dist/engine/cli-common.js +60 -0
- package/dist/engine/cli.core.js +208 -0
- package/dist/engine/cli.js +31 -0
- package/dist/engine/company-knowledge-cli.js +201 -0
- package/dist/engine/core/ci-env.js +33 -0
- package/dist/engine/core/classifier.js +192 -0
- package/dist/engine/core/company-knowledge.js +765 -0
- package/dist/engine/core/config-validation.js +74 -0
- package/dist/engine/core/detection.js +48 -0
- package/dist/engine/core/domain-scanner.js +212 -0
- package/dist/engine/core/environment-detect.js +410 -0
- package/dist/engine/core/executor.js +181 -0
- package/dist/engine/core/feedback.js +93 -0
- package/dist/engine/core/fixture-scanner.js +173 -0
- package/dist/engine/core/framework-registry.js +123 -0
- package/dist/engine/core/mcp-validator.js +218 -0
- package/dist/engine/core/metadata-scanner.js +147 -0
- package/dist/engine/core/migrator.js +1112 -0
- package/dist/engine/core/overlays.js +176 -0
- package/dist/engine/core/pattern-healer.js +147 -0
- package/dist/engine/core/pattern-matcher.js +255 -0
- package/dist/engine/core/quality-scorer.js +213 -0
- package/dist/engine/core/recommender.js +152 -0
- package/dist/engine/core/reporter.js +211 -0
- package/dist/engine/core/scaffolder.js +236 -0
- package/dist/engine/core/skill-registry.js +522 -0
- package/dist/engine/core/static-linter.js +237 -0
- package/dist/engine/core/ticket-updater.js +639 -0
- package/dist/engine/core/workflow-discovery.js +693 -0
- package/dist/engine/guardian/agent-tier.js +338 -0
- package/dist/engine/guardian/analysis-emit.js +201 -0
- package/dist/engine/guardian/cli.js +787 -0
- package/dist/engine/guardian/coverage.js +1055 -0
- package/dist/engine/guardian/delta-emitter.js +46 -0
- package/dist/engine/guardian/diff-extractor.js +257 -0
- package/dist/engine/guardian/hard-gate.js +373 -0
- package/dist/engine/guardian/impact-mapper.js +121 -0
- package/dist/engine/guardian/pr-check.js +975 -0
- package/dist/engine/guardian/pr-comment.js +200 -0
- package/dist/engine/guardian/summary-emitter.js +94 -0
- package/dist/engine/guardian/tier.js +58 -0
- package/dist/engine/history/cli.js +303 -0
- package/dist/engine/history/detector.js +68 -0
- package/dist/engine/history/ndjson-store.js +177 -0
- package/dist/engine/history/record.js +14 -0
- package/dist/engine/history/schema.js +59 -0
- package/dist/engine/history/store.js +47 -0
- package/dist/engine/history/supabase-store.js +113 -0
- package/dist/engine/main-deps.js +105 -0
- package/dist/engine/mcp-server.js +647 -0
- package/dist/engine/package.json +4 -0
- package/dist/engine/skills-cli.js +181 -0
- package/dist/engine/ui/banner.js +50 -0
- package/dist/engine/util/coalesce.js +12 -0
- package/dist/engine/util/round.js +43 -0
- package/dist/engine/workflow-cli.js +242 -0
- package/dist/engine-checks.d.ts +49 -0
- package/dist/overlay-commands.d.ts +81 -0
- package/dist/overlay-conflicts.d.ts +33 -0
- package/dist/overlay-lint.d.ts +19 -0
- package/dist/overlays-registry.d.ts +74 -0
- package/dist/reporters/testtracker.d.ts +89 -0
- package/dist/reporters/testtracker.js +195 -0
- package/dist/router.d.ts +12 -0
- package/dist/router.js +4 -4
- package/dist/skill-requirements.d.ts +57 -0
- package/dist/source-spec.d.ts +20 -0
- package/package.json +30 -6
- package/bin/canary +0 -0
- package/scripts/install.js +0 -104
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ticket Updater -- posts a structured run comment and optionally transitions
|
|
3
|
+
* the linked ticket after a Canary test run.
|
|
4
|
+
*
|
|
5
|
+
* Faithful TypeScript port of `agent/core/ticket_updater.py`.
|
|
6
|
+
*
|
|
7
|
+
* Canary never hardcodes Jira status names. Transition targets are resolved via
|
|
8
|
+
* the semantic-role mapping persisted by `WorkflowDiscovery`.
|
|
9
|
+
*
|
|
10
|
+
* Python->TS nuances:
|
|
11
|
+
* - **Regex line anchors.** Python `re.MULTILINE` `^` matches only at string
|
|
12
|
+
* start and immediately after `\n`. JS `^` under `/m` also breaks on `\r`,
|
|
13
|
+
* `U+2028`, and `U+2029`, so the frontmatter patterns anchor on `\n`
|
|
14
|
+
* explicitly via `(?:^|(?<=\n))` and drop `/m`. `re.IGNORECASE` -> `/i`.
|
|
15
|
+
* - **subprocess -> child_process** via the injectable {@link SubprocessRun}
|
|
16
|
+
* seam (default `spawnSync` with `maxBuffer: Infinity`); Python's
|
|
17
|
+
* FileNotFoundError/TimeoutExpired -> {@link CommandNotFoundError}/
|
|
18
|
+
* {@link SubprocessTimeoutError}.
|
|
19
|
+
* - **urllib -> fetch seam.** Jira REST calls run through the injectable async
|
|
20
|
+
* {@link HttpClient}. Python's `urlopen` raises `HTTPError` (a `URLError`
|
|
21
|
+
* subclass) on a non-2xx status; the fetch seam does not, so callers treat
|
|
22
|
+
* `!resp.ok` exactly as Python's caught-exception path.
|
|
23
|
+
* - **JSON payload shape** mirrors `json.dumps` with library-default
|
|
24
|
+
* `ensure_ascii=True` reproduced by {@link ensureAscii}.
|
|
25
|
+
* - **Python truthiness** (`""`/`[]`/`None` falsy) via {@link pyTruthy}.
|
|
26
|
+
* - **`{value!r}`** reproduced by {@link pyRepr} for the one user-facing
|
|
27
|
+
* `repr()` in the "unrecognised key" message.
|
|
28
|
+
* - `duration_s` prints via JS `String(number)`; unlike Python's `float`
|
|
29
|
+
* `repr`, an integral value like `12` yields `"12"`, not `"12.0"` (JS has no
|
|
30
|
+
* int/float distinction). Non-integral values match.
|
|
31
|
+
*/
|
|
32
|
+
import { atlassianUrlFor, CommandNotFoundError, defaultHttpClient, defaultSubprocess, resolveRole, SubprocessTimeoutError, } from './workflow-discovery.js';
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Python-compatibility helpers (copied locally per-module, matching reporter.ts)
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
/**
|
|
37
|
+
* Python-truthiness for the values used here: `None`/`undefined`, `false`, `0`,
|
|
38
|
+
* `""`, empty array, and empty object are all falsy (mirrors `if x:`).
|
|
39
|
+
*/
|
|
40
|
+
function pyTruthy(value) {
|
|
41
|
+
if (value === null || value === undefined || value === false)
|
|
42
|
+
return false;
|
|
43
|
+
if (value === 0 || value === '')
|
|
44
|
+
return false;
|
|
45
|
+
if (Array.isArray(value))
|
|
46
|
+
return value.length > 0;
|
|
47
|
+
if (typeof value === 'object')
|
|
48
|
+
return Object.keys(value).length > 0;
|
|
49
|
+
return Boolean(value);
|
|
50
|
+
}
|
|
51
|
+
/** Python `dict.get(key, default)`: default only on a missing key. */
|
|
52
|
+
function pyGet(obj, key, fallback) {
|
|
53
|
+
return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : fallback;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Reproduce Python's `json.dumps(..., ensure_ascii=True)` (the library default)
|
|
57
|
+
* on `JSON.stringify` output: escape every code point >= 0x80 as `\uXXXX`.
|
|
58
|
+
*/
|
|
59
|
+
function ensureAscii(json) {
|
|
60
|
+
return json.replace(/[\u0080-\uffff]/g, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'));
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Python `json.dumps(obj)` (no indent) with library-default separators
|
|
64
|
+
* `(', ', ': ')` -- a space after every ',' and ':'. JS `JSON.stringify` emits
|
|
65
|
+
* none. A regex over stringify output would corrupt separators inside string
|
|
66
|
+
* values (comment text contains ':' and ','), so serialize structurally. Wrap
|
|
67
|
+
* the result in {@link ensureAscii} for `ensure_ascii=True` parity. These
|
|
68
|
+
* payloads carry no floats, so the `str(float)` `.0` question does not arise.
|
|
69
|
+
*/
|
|
70
|
+
function pyJsonDumps(value) {
|
|
71
|
+
if (value === null || typeof value !== 'object')
|
|
72
|
+
return JSON.stringify(value);
|
|
73
|
+
if (Array.isArray(value)) {
|
|
74
|
+
return '[' + value.map(pyJsonDumps).join(', ') + ']';
|
|
75
|
+
}
|
|
76
|
+
const parts = Object.entries(value).map(([k, v]) => `${JSON.stringify(k)}: ${pyJsonDumps(v)}`);
|
|
77
|
+
return '{' + parts.join(', ') + '}';
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Python `str(float)`: an integral float still renders a trailing `.0`
|
|
81
|
+
* (`str(12.0) === '12.0'`, `str(0.0) === '0.0'`), but JS `String(12)` -> `'12'`.
|
|
82
|
+
* duration_s is a float interpolated into the posted comment, and its default
|
|
83
|
+
* (`0.0`) and whole-second values are integral and reachable, so restore `.0`.
|
|
84
|
+
* Non-integral values match `String(d)` for the realistic seconds domain.
|
|
85
|
+
*/
|
|
86
|
+
function pyFloatStr(value) {
|
|
87
|
+
return Number.isInteger(value) ? value.toFixed(1) : String(value);
|
|
88
|
+
}
|
|
89
|
+
/** Python `str.rstrip(ch)` -- remove all trailing runs of `ch`. */
|
|
90
|
+
function rstripChar(s, ch) {
|
|
91
|
+
let end = s.length;
|
|
92
|
+
while (end > 0 && s[end - 1] === ch)
|
|
93
|
+
end--;
|
|
94
|
+
return s.slice(0, end);
|
|
95
|
+
}
|
|
96
|
+
/** Python `str.lstrip(ch)` -- remove all leading runs of `ch`. */
|
|
97
|
+
function lstripChar(s, ch) {
|
|
98
|
+
let start = 0;
|
|
99
|
+
while (start < s.length && s[start] === ch)
|
|
100
|
+
start++;
|
|
101
|
+
return s.slice(start);
|
|
102
|
+
}
|
|
103
|
+
/** Minimal Python `repr()` for a string (single-quoted unless it needs double). */
|
|
104
|
+
function pyRepr(value) {
|
|
105
|
+
if (value.includes("'") && !value.includes('"')) {
|
|
106
|
+
return '"' + value.replace(/\\/g, '\\\\') + '"';
|
|
107
|
+
}
|
|
108
|
+
return "'" + value.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";
|
|
109
|
+
}
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Result types
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
/** Python: `TransitionResult` dataclass. */
|
|
114
|
+
export class TransitionResult {
|
|
115
|
+
attempted;
|
|
116
|
+
succeeded;
|
|
117
|
+
from_status;
|
|
118
|
+
to_status;
|
|
119
|
+
reason; // human-readable explanation
|
|
120
|
+
constructor(attempted, succeeded, fromStatus, toStatus, reason) {
|
|
121
|
+
this.attempted = attempted;
|
|
122
|
+
this.succeeded = succeeded;
|
|
123
|
+
this.from_status = fromStatus;
|
|
124
|
+
this.to_status = toStatus;
|
|
125
|
+
this.reason = reason;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Python: `UpdateResult` dataclass. */
|
|
129
|
+
export class UpdateResult {
|
|
130
|
+
ticket_key;
|
|
131
|
+
project_key;
|
|
132
|
+
linkage_source; // "frontmatter" | "tag" | "branch" | "none"
|
|
133
|
+
comment_posted;
|
|
134
|
+
transition;
|
|
135
|
+
dry_run;
|
|
136
|
+
messages;
|
|
137
|
+
constructor(init) {
|
|
138
|
+
this.ticket_key = init.ticket_key;
|
|
139
|
+
this.project_key = init.project_key;
|
|
140
|
+
this.linkage_source = init.linkage_source;
|
|
141
|
+
this.comment_posted = init.comment_posted;
|
|
142
|
+
this.transition = init.transition;
|
|
143
|
+
this.dry_run = init.dry_run;
|
|
144
|
+
this.messages = init.messages ?? [];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Run summary
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
/** Python: `RunSummary` dataclass. Describes a completed Canary test run. */
|
|
151
|
+
export class RunSummary {
|
|
152
|
+
suite_name;
|
|
153
|
+
env;
|
|
154
|
+
result;
|
|
155
|
+
passed;
|
|
156
|
+
total;
|
|
157
|
+
flaky_count;
|
|
158
|
+
duration_s;
|
|
159
|
+
test_file;
|
|
160
|
+
report_url;
|
|
161
|
+
passed_names;
|
|
162
|
+
failed_names; // (name, failure_category) pairs
|
|
163
|
+
ticket_key;
|
|
164
|
+
project_key;
|
|
165
|
+
linkage_source;
|
|
166
|
+
constructor(init) {
|
|
167
|
+
this.suite_name = init.suite_name;
|
|
168
|
+
this.env = init.env;
|
|
169
|
+
this.result = init.result;
|
|
170
|
+
this.passed = init.passed;
|
|
171
|
+
this.total = init.total;
|
|
172
|
+
this.flaky_count = init.flaky_count;
|
|
173
|
+
this.duration_s = init.duration_s;
|
|
174
|
+
this.test_file = init.test_file;
|
|
175
|
+
this.report_url = init.report_url;
|
|
176
|
+
this.passed_names = init.passed_names;
|
|
177
|
+
this.failed_names = init.failed_names;
|
|
178
|
+
this.ticket_key = init.ticket_key ?? null;
|
|
179
|
+
this.project_key = init.project_key ?? null;
|
|
180
|
+
this.linkage_source = init.linkage_source ?? 'none';
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Patterns for ticket linkage detection.
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Python `re.MULTILINE` `^` anchors on `\n` only -- reproduced via
|
|
187
|
+
// `(?:^|(?<=\n))` (JS `/m` would also break on `\r`/`U+2028`/`U+2029`).
|
|
188
|
+
const FRONTMATTER_TICKET = /(?:^|(?<=\n))#\s*canary:ticket:\s*(\S+)/;
|
|
189
|
+
const FRONTMATTER_PROJECT = /(?:^|(?<=\n))#\s*canary:project:\s*(\S+)/;
|
|
190
|
+
// No `^`/`$` anchors, so `re.MULTILINE` was a no-op -- ported without `/m`.
|
|
191
|
+
const TAG_TICKET = /@(?:ticket|jira):([A-Z][A-Z0-9]*-\d+)/;
|
|
192
|
+
const BRANCH_TICKET = /(?:feature|fix|chore)\/([A-Z][A-Z0-9]*-\d+)/;
|
|
193
|
+
const TICKET_PROJECT = /^([A-Z][A-Z0-9]*)-\d+$/;
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Main class
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
/** Posts a run comment and optionally transitions the linked ticket. */
|
|
198
|
+
export class TicketUpdater {
|
|
199
|
+
canaryDir;
|
|
200
|
+
http;
|
|
201
|
+
subprocess;
|
|
202
|
+
constructor(canaryDir, deps = {}) {
|
|
203
|
+
this.canaryDir =
|
|
204
|
+
canaryDir !== undefined && canaryDir !== null
|
|
205
|
+
? canaryDir
|
|
206
|
+
: joinCwdCanary();
|
|
207
|
+
this.http = deps.http ?? defaultHttpClient;
|
|
208
|
+
this.subprocess = deps.subprocess ?? defaultSubprocess;
|
|
209
|
+
}
|
|
210
|
+
// -- public ----------------------------------------------------------------
|
|
211
|
+
/** Python: `TicketUpdater.update`. */
|
|
212
|
+
async update(summary, opts = {}) {
|
|
213
|
+
const dryRun = opts.dryRun ?? false;
|
|
214
|
+
const commentOnly = opts.commentOnly ?? false;
|
|
215
|
+
const transitionOnly = opts.transitionOnly ?? false;
|
|
216
|
+
const messages = [];
|
|
217
|
+
// 1. Resolve linkage if not already set.
|
|
218
|
+
// Strip a trailing newline from a caller-supplied ticket key. Python's `$`
|
|
219
|
+
// (no MULTILINE) leniently matches before a trailing \n, so "ABC-123\n"
|
|
220
|
+
// routes to Jira there; JS `$` does not, so the port would misroute it to
|
|
221
|
+
// "unrecognised". Normalizing at ingest makes routing match AND keeps the
|
|
222
|
+
// key clean for the request URL (Python would 404 on the raw newline).
|
|
223
|
+
let ticketKey = typeof summary.ticket_key === 'string'
|
|
224
|
+
? rstripChar(summary.ticket_key, '\n')
|
|
225
|
+
: summary.ticket_key;
|
|
226
|
+
let projectKey = summary.project_key;
|
|
227
|
+
let linkageSource = summary.linkage_source;
|
|
228
|
+
if (!pyTruthy(ticketKey) && pyTruthy(summary.test_file)) {
|
|
229
|
+
[ticketKey, projectKey, linkageSource] = this.detectLinkage(summary.test_file);
|
|
230
|
+
}
|
|
231
|
+
// 2. Safety gate -- no ticket found.
|
|
232
|
+
if (!pyTruthy(ticketKey)) {
|
|
233
|
+
messages.push('No ticket linkage found \u2014 skipping comment and transition.\n' +
|
|
234
|
+
"Add '# canary:ticket: PROJ-123' to the test file frontmatter, " +
|
|
235
|
+
"a '@ticket:PROJ-123' tag, or run from a branch named " +
|
|
236
|
+
'feature/PROJ-123.');
|
|
237
|
+
return new UpdateResult({
|
|
238
|
+
ticket_key: null,
|
|
239
|
+
project_key: null,
|
|
240
|
+
linkage_source: linkageSource,
|
|
241
|
+
comment_posted: false,
|
|
242
|
+
transition: new TransitionResult(false, false, null, null, 'no ticket linkage'),
|
|
243
|
+
dry_run: dryRun,
|
|
244
|
+
messages,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
// Infer project_key from ticket_key if not set.
|
|
248
|
+
if (!pyTruthy(projectKey)) {
|
|
249
|
+
const m = TICKET_PROJECT.exec(ticketKey);
|
|
250
|
+
projectKey = m ? m[1] : null;
|
|
251
|
+
}
|
|
252
|
+
// 3. Build run comment.
|
|
253
|
+
const commentBody = this.buildComment(summary);
|
|
254
|
+
// 4. Post comment.
|
|
255
|
+
let commentPosted = false;
|
|
256
|
+
if (!transitionOnly) {
|
|
257
|
+
// Determine surface: Jira for PROJ-NNN keys, GitHub for owner/repo#NNN.
|
|
258
|
+
if (/^[A-Z][A-Z0-9]*-\d+$/.test(ticketKey)) {
|
|
259
|
+
commentPosted = await this.postJiraComment(ticketKey, commentBody, dryRun);
|
|
260
|
+
}
|
|
261
|
+
else if (/^#\d+$/.test(ticketKey) || /^\d+$/.test(ticketKey)) {
|
|
262
|
+
// GitHub issue -- needs project_key as "owner/repo".
|
|
263
|
+
const issueRef = pyTruthy(projectKey)
|
|
264
|
+
? `${projectKey}#${lstripChar(ticketKey, '#')}`
|
|
265
|
+
: ticketKey;
|
|
266
|
+
commentPosted = this.postGithubComment(issueRef, commentBody, dryRun);
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
messages.push(`Unrecognised ticket key format: ${pyRepr(ticketKey)}. ` +
|
|
270
|
+
'Expected PROJ-NNN (Jira) or #NNN (GitHub Issue).');
|
|
271
|
+
}
|
|
272
|
+
if (dryRun) {
|
|
273
|
+
messages.push(`Would post comment to ${ticketKey} ` +
|
|
274
|
+
`(${ticketKey.includes('-') ? 'Jira' : 'GitHub Issue'}):\n` +
|
|
275
|
+
`${commentBody}`);
|
|
276
|
+
commentPosted = true; // flagged as would-post
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// 5. Transition.
|
|
280
|
+
let transitionResult = new TransitionResult(false, false, null, null, 'skipped (comment-only mode)');
|
|
281
|
+
if (!commentOnly) {
|
|
282
|
+
transitionResult = await this.transitionJira(ticketKey, pyTruthy(projectKey) ? projectKey : '', summary.result, dryRun);
|
|
283
|
+
if (dryRun && transitionResult.attempted) {
|
|
284
|
+
messages.push(`Would transition ${ticketKey}:\n` +
|
|
285
|
+
` "${transitionResult.from_status}" \u2192 "${transitionResult.to_status}"\n` +
|
|
286
|
+
' (resolved via qa_passed role in ' +
|
|
287
|
+
`.canary/workflow-${projectKey}.json)\n\n` +
|
|
288
|
+
'Re-run without --dry-run to apply.');
|
|
289
|
+
}
|
|
290
|
+
else if (!transitionResult.attempted) {
|
|
291
|
+
messages.push(transitionResult.reason);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return new UpdateResult({
|
|
295
|
+
ticket_key: ticketKey,
|
|
296
|
+
project_key: projectKey,
|
|
297
|
+
linkage_source: linkageSource,
|
|
298
|
+
comment_posted: commentPosted,
|
|
299
|
+
transition: transitionResult,
|
|
300
|
+
dry_run: dryRun,
|
|
301
|
+
messages,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
/** Python: `TicketUpdater.detect_linkage`. */
|
|
305
|
+
detectLinkage(testFile) {
|
|
306
|
+
if (!existsSync(testFile)) {
|
|
307
|
+
return this.branchTicket();
|
|
308
|
+
}
|
|
309
|
+
const content = readFileSync(testFile, 'utf-8');
|
|
310
|
+
// Priority 1: YAML frontmatter comments.
|
|
311
|
+
const mTicket = FRONTMATTER_TICKET.exec(content);
|
|
312
|
+
if (mTicket) {
|
|
313
|
+
const ticketKey = mTicket[1];
|
|
314
|
+
const mProject = FRONTMATTER_PROJECT.exec(content);
|
|
315
|
+
let projectKey = mProject ? mProject[1] : null;
|
|
316
|
+
if (projectKey === null) {
|
|
317
|
+
const pm = TICKET_PROJECT.exec(ticketKey);
|
|
318
|
+
projectKey = pm ? pm[1] : null;
|
|
319
|
+
}
|
|
320
|
+
return [ticketKey, projectKey, 'frontmatter'];
|
|
321
|
+
}
|
|
322
|
+
// Priority 2: @ticket / @jira tag annotations.
|
|
323
|
+
const mTag = TAG_TICKET.exec(content);
|
|
324
|
+
if (mTag) {
|
|
325
|
+
const ticketKey = mTag[1];
|
|
326
|
+
const pm = TICKET_PROJECT.exec(ticketKey);
|
|
327
|
+
const projectKey = pm ? pm[1] : null;
|
|
328
|
+
return [ticketKey, projectKey, 'tag'];
|
|
329
|
+
}
|
|
330
|
+
// Priority 3: branch name (comment only, not for transition).
|
|
331
|
+
return this.branchTicket();
|
|
332
|
+
}
|
|
333
|
+
// -- private: comment building ---------------------------------------------
|
|
334
|
+
/** Python: `TicketUpdater._build_comment`. */
|
|
335
|
+
buildComment(summary) {
|
|
336
|
+
const flags = `--result ${summary.result.toLowerCase()}`;
|
|
337
|
+
const lines = [
|
|
338
|
+
`\u{1F9EA} Canary Test Run \u2014 ${summary.suite_name}`,
|
|
339
|
+
'',
|
|
340
|
+
`Environment: ${summary.env}`,
|
|
341
|
+
`Result: ${summary.result} (${summary.passed}/${summary.total} tests)`,
|
|
342
|
+
`Flaky: ${summary.flaky_count}`,
|
|
343
|
+
`Duration: ${pyFloatStr(summary.duration_s)}s`,
|
|
344
|
+
`Run by: canary report ${flags}`,
|
|
345
|
+
'',
|
|
346
|
+
`Test file: ${summary.test_file}`,
|
|
347
|
+
];
|
|
348
|
+
if (pyTruthy(summary.report_url)) {
|
|
349
|
+
lines.push(`Report: ${summary.report_url}`);
|
|
350
|
+
}
|
|
351
|
+
lines.push('', '---');
|
|
352
|
+
if (pyTruthy(summary.passed_names)) {
|
|
353
|
+
lines.push('Passed:');
|
|
354
|
+
for (const name of summary.passed_names) {
|
|
355
|
+
lines.push(` \u2713 ${name}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (pyTruthy(summary.failed_names)) {
|
|
359
|
+
lines.push('Failed:');
|
|
360
|
+
for (const [name, category] of summary.failed_names) {
|
|
361
|
+
lines.push(` \u2717 ${name} \u2014 ${category}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return lines.join('\n');
|
|
365
|
+
}
|
|
366
|
+
// -- private: Jira ---------------------------------------------------------
|
|
367
|
+
/** Python: `TicketUpdater._post_jira_comment`. */
|
|
368
|
+
async postJiraComment(ticketKey, body, dryRun) {
|
|
369
|
+
if (dryRun) {
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
// Infer project key from ticket key to select the right Atlassian URL.
|
|
373
|
+
const pm = TICKET_PROJECT.exec(ticketKey);
|
|
374
|
+
const projectKey = pm ? pm[1] : null;
|
|
375
|
+
const [baseUrl, authHeader] = this.jiraAuth(projectKey, this.canaryDir);
|
|
376
|
+
if (baseUrl === null) {
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
const url = `${baseUrl}/rest/api/3/issue/${ticketKey}/comment`;
|
|
380
|
+
const payload = ensureAscii(pyJsonDumps({
|
|
381
|
+
body: {
|
|
382
|
+
type: 'doc',
|
|
383
|
+
version: 1,
|
|
384
|
+
content: [
|
|
385
|
+
{
|
|
386
|
+
type: 'paragraph',
|
|
387
|
+
content: [{ type: 'text', text: body }],
|
|
388
|
+
},
|
|
389
|
+
],
|
|
390
|
+
},
|
|
391
|
+
}));
|
|
392
|
+
try {
|
|
393
|
+
const resp = await this.http({
|
|
394
|
+
url,
|
|
395
|
+
method: 'POST',
|
|
396
|
+
headers: {
|
|
397
|
+
Authorization: authHeader,
|
|
398
|
+
'Content-Type': 'application/json',
|
|
399
|
+
Accept: 'application/json',
|
|
400
|
+
},
|
|
401
|
+
body: payload,
|
|
402
|
+
timeout: 10,
|
|
403
|
+
});
|
|
404
|
+
return resp.ok;
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
/** Python: `TicketUpdater._post_github_comment`. */
|
|
411
|
+
postGithubComment(issueRef, body, dryRun) {
|
|
412
|
+
if (dryRun) {
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
415
|
+
// Parse owner/repo#NNN or bare NNN.
|
|
416
|
+
const m = /^([^#]+)#(\d+)$/.exec(issueRef);
|
|
417
|
+
let repo;
|
|
418
|
+
let number;
|
|
419
|
+
if (m) {
|
|
420
|
+
repo = m[1];
|
|
421
|
+
number = m[2];
|
|
422
|
+
}
|
|
423
|
+
else if (/^\d+$/.test(issueRef)) {
|
|
424
|
+
repo = '';
|
|
425
|
+
number = issueRef;
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
const cmd = ['gh', 'issue', 'comment', number, '--body', body];
|
|
431
|
+
if (pyTruthy(repo)) {
|
|
432
|
+
cmd.push('--repo', repo);
|
|
433
|
+
}
|
|
434
|
+
try {
|
|
435
|
+
const result = this.subprocess(cmd, { timeout: 15 });
|
|
436
|
+
return result.returncode === 0;
|
|
437
|
+
}
|
|
438
|
+
catch (exc) {
|
|
439
|
+
if (exc instanceof CommandNotFoundError ||
|
|
440
|
+
exc instanceof SubprocessTimeoutError) {
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
throw exc;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
/** Python: `TicketUpdater._transition_jira`. */
|
|
447
|
+
async transitionJira(ticketKey, projectKey, result, dryRun) {
|
|
448
|
+
// Block transition on non-PASS results.
|
|
449
|
+
if (result !== 'PASS') {
|
|
450
|
+
return new TransitionResult(false, false, null, null, `Run result is ${result} \u2014 ticket NOT transitioned to qa_passed. ` +
|
|
451
|
+
'Transition only happens on PASS.');
|
|
452
|
+
}
|
|
453
|
+
// Resolve target status name from workflow mapping.
|
|
454
|
+
const targetStatus = resolveRole(projectKey, 'qa_passed', this.canaryDir);
|
|
455
|
+
if (targetStatus === null) {
|
|
456
|
+
return new TransitionResult(false, false, null, null, `\u26A0 No workflow mapping found for project ${projectKey}.\n` +
|
|
457
|
+
` Run \`canary workflow-discover --project ${projectKey}\` first.\n` +
|
|
458
|
+
' Comment was posted. Transition was NOT attempted.');
|
|
459
|
+
}
|
|
460
|
+
// Need Jira creds -- prefer URL stored in mapping for this project.
|
|
461
|
+
const [baseUrl, authHeader] = this.jiraAuth(projectKey, this.canaryDir);
|
|
462
|
+
if (baseUrl === null) {
|
|
463
|
+
return new TransitionResult(false, false, null, null, 'Jira credentials not configured (ATLASSIAN_URL, ' +
|
|
464
|
+
'ATLASSIAN_USER, ATLASSIAN_TOKEN). ' +
|
|
465
|
+
'Transition was NOT attempted.');
|
|
466
|
+
}
|
|
467
|
+
// Fetch ticket's current status.
|
|
468
|
+
const currentStatus = await this.jiraCurrentStatus(baseUrl, authHeader, ticketKey);
|
|
469
|
+
if (currentStatus === null) {
|
|
470
|
+
return new TransitionResult(false, false, null, targetStatus, `Could not fetch current status for ${ticketKey}.`);
|
|
471
|
+
}
|
|
472
|
+
// Find the transition ID that leads to target_status.
|
|
473
|
+
const transitionId = await this.jiraFindTransition(baseUrl, authHeader, ticketKey, targetStatus);
|
|
474
|
+
if (transitionId === null) {
|
|
475
|
+
return new TransitionResult(true, false, currentStatus, targetStatus, `Transition to "${targetStatus}" is not reachable from ` +
|
|
476
|
+
`"${currentStatus}" for ${ticketKey}. ` +
|
|
477
|
+
'No transition attempted.');
|
|
478
|
+
}
|
|
479
|
+
// Dry-run: return what would happen.
|
|
480
|
+
if (dryRun) {
|
|
481
|
+
return new TransitionResult(true, false, // not actually done
|
|
482
|
+
currentStatus, targetStatus, 'dry-run');
|
|
483
|
+
}
|
|
484
|
+
// Execute transition.
|
|
485
|
+
const ok = await this.jiraDoTransition(baseUrl, authHeader, ticketKey, transitionId);
|
|
486
|
+
return new TransitionResult(true, ok, currentStatus, targetStatus, ok ? 'transition executed' : 'transition API call failed');
|
|
487
|
+
}
|
|
488
|
+
// -- private: injectable helper seams (Python module functions) ------------
|
|
489
|
+
/** Python: module `_jira_auth` (instance-method seam for test injection). */
|
|
490
|
+
jiraAuth(projectKey, canaryDir) {
|
|
491
|
+
return jiraAuth(projectKey, canaryDir);
|
|
492
|
+
}
|
|
493
|
+
/** Python: module `_jira_current_status`. */
|
|
494
|
+
async jiraCurrentStatus(baseUrl, authHeader, ticketKey) {
|
|
495
|
+
const url = `${baseUrl}/rest/api/3/issue/${ticketKey}?fields=status`;
|
|
496
|
+
try {
|
|
497
|
+
const resp = await this.http({
|
|
498
|
+
url,
|
|
499
|
+
method: 'GET',
|
|
500
|
+
headers: { Authorization: authHeader, Accept: 'application/json' },
|
|
501
|
+
timeout: 10,
|
|
502
|
+
});
|
|
503
|
+
if (!resp.ok) {
|
|
504
|
+
// Python: urlopen raises HTTPError (URLError subclass) -> caught -> None.
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
const data = JSON.parse(resp.text);
|
|
508
|
+
const fields = pyGet(data, 'fields', {});
|
|
509
|
+
const status = pyGet(fields, 'status', {});
|
|
510
|
+
return pyGet(status, 'name', null) ?? null;
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
/** Python: module `_jira_find_transition`. */
|
|
517
|
+
async jiraFindTransition(baseUrl, authHeader, ticketKey, targetStatus) {
|
|
518
|
+
const url = `${baseUrl}/rest/api/3/issue/${ticketKey}/transitions`;
|
|
519
|
+
let data;
|
|
520
|
+
try {
|
|
521
|
+
const resp = await this.http({
|
|
522
|
+
url,
|
|
523
|
+
method: 'GET',
|
|
524
|
+
headers: { Authorization: authHeader, Accept: 'application/json' },
|
|
525
|
+
timeout: 10,
|
|
526
|
+
});
|
|
527
|
+
if (!resp.ok) {
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
data = JSON.parse(resp.text);
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
for (const t of pyGet(data, 'transitions', [])) {
|
|
536
|
+
const to = pyGet(t, 'to', {});
|
|
537
|
+
const toName = pyGet(to, 'name', '') ?? '';
|
|
538
|
+
if (toName.toLowerCase() === targetStatus.toLowerCase()) {
|
|
539
|
+
return String(t['id']);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
/** Python: module `_jira_do_transition`. */
|
|
545
|
+
async jiraDoTransition(baseUrl, authHeader, ticketKey, transitionId) {
|
|
546
|
+
const url = `${baseUrl}/rest/api/3/issue/${ticketKey}/transitions`;
|
|
547
|
+
const payload = ensureAscii(pyJsonDumps({ transition: { id: transitionId } }));
|
|
548
|
+
try {
|
|
549
|
+
const resp = await this.http({
|
|
550
|
+
url,
|
|
551
|
+
method: 'POST',
|
|
552
|
+
headers: {
|
|
553
|
+
Authorization: authHeader,
|
|
554
|
+
'Content-Type': 'application/json',
|
|
555
|
+
},
|
|
556
|
+
body: payload,
|
|
557
|
+
timeout: 10,
|
|
558
|
+
});
|
|
559
|
+
return resp.ok;
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
/** Python: module `_branch_ticket` (instance-method seam for test injection). */
|
|
566
|
+
branchTicket() {
|
|
567
|
+
return branchTicket(this.subprocess);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
// ---------------------------------------------------------------------------
|
|
571
|
+
// Helpers (Python module-level functions)
|
|
572
|
+
// ---------------------------------------------------------------------------
|
|
573
|
+
/** Default `.canary` directory: `<cwd>/.canary`. */
|
|
574
|
+
function joinCwdCanary() {
|
|
575
|
+
return `${process.cwd()}/.canary`;
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Python: `_jira_auth`. Return `[base_url, auth_header]` for the given
|
|
579
|
+
* `projectKey`, or `[null, null]` when credentials are missing.
|
|
580
|
+
*
|
|
581
|
+
* Resolution order for base_url:
|
|
582
|
+
* 1. `atlassian_url` stored in the per-project mapping file.
|
|
583
|
+
* 2. `ATLASSIAN_URL` environment variable.
|
|
584
|
+
*/
|
|
585
|
+
export function jiraAuth(projectKey = null, canaryDir) {
|
|
586
|
+
// Prefer the URL stored in the per-project mapping.
|
|
587
|
+
let baseUrl = '';
|
|
588
|
+
if (pyTruthy(projectKey)) {
|
|
589
|
+
const stored = atlassianUrlFor(projectKey, canaryDir);
|
|
590
|
+
if (pyTruthy(stored)) {
|
|
591
|
+
baseUrl = rstripChar(stored, '/');
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (!pyTruthy(baseUrl)) {
|
|
595
|
+
baseUrl = rstripChar(process.env['ATLASSIAN_URL'] ?? '', '/');
|
|
596
|
+
}
|
|
597
|
+
const user = process.env['ATLASSIAN_USER'] ?? '';
|
|
598
|
+
const token = process.env['ATLASSIAN_TOKEN'] ?? '';
|
|
599
|
+
if (!pyTruthy(baseUrl) || !pyTruthy(user) || !pyTruthy(token)) {
|
|
600
|
+
return [null, null];
|
|
601
|
+
}
|
|
602
|
+
const auth = Buffer.from(`${user}:${token}`).toString('base64');
|
|
603
|
+
return [baseUrl, `Basic ${auth}`];
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Python: `_branch_ticket`. Extract a ticket key from the current git branch
|
|
607
|
+
* name, or `[null, null, "none"]` when the branch does not match the
|
|
608
|
+
* convention (or git is unavailable).
|
|
609
|
+
*/
|
|
610
|
+
export function branchTicket(subprocess = defaultSubprocess) {
|
|
611
|
+
let branch = '';
|
|
612
|
+
try {
|
|
613
|
+
const result = subprocess(['git', 'branch', '--show-current'], {
|
|
614
|
+
timeout: 5,
|
|
615
|
+
});
|
|
616
|
+
branch = result.returncode === 0 ? result.stdout.trim() : '';
|
|
617
|
+
}
|
|
618
|
+
catch (exc) {
|
|
619
|
+
if (exc instanceof CommandNotFoundError ||
|
|
620
|
+
exc instanceof SubprocessTimeoutError) {
|
|
621
|
+
return [null, null, 'none'];
|
|
622
|
+
}
|
|
623
|
+
throw exc;
|
|
624
|
+
}
|
|
625
|
+
const m = BRANCH_TICKET.exec(branch);
|
|
626
|
+
if (m) {
|
|
627
|
+
const ticketKey = m[1];
|
|
628
|
+
const pm = TICKET_PROJECT.exec(ticketKey);
|
|
629
|
+
const projectKey = pm ? pm[1] : null;
|
|
630
|
+
return [ticketKey, projectKey, 'branch'];
|
|
631
|
+
}
|
|
632
|
+
return [null, null, 'none'];
|
|
633
|
+
}
|
|
634
|
+
// ---------------------------------------------------------------------------
|
|
635
|
+
// Local fs imports (kept at the bottom to mirror the Python top-level imports
|
|
636
|
+
// while keeping the seam wiring above readable).
|
|
637
|
+
// ---------------------------------------------------------------------------
|
|
638
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
639
|
+
//# sourceMappingURL=ticket-updater.js.map
|