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,693 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Discovery -- discovers per-project Jira / GitHub issue workflows
|
|
3
|
+
* and persists the mapping to `.canary/workflow-<key>.json`.
|
|
4
|
+
*
|
|
5
|
+
* Faithful TypeScript port of `agent/core/workflow_discovery.py`.
|
|
6
|
+
*
|
|
7
|
+
* Canary never hardcodes Jira status names or GitHub board columns. Instead, it
|
|
8
|
+
* calls `resolveRole()` which looks up the persisted mapping. If the mapping is
|
|
9
|
+
* missing, `WorkflowDiscovery.discover()` must be called first.
|
|
10
|
+
*
|
|
11
|
+
* Jira REST API is called directly using credentials from the environment
|
|
12
|
+
* (`ATLASSIAN_URL`, `ATLASSIAN_USER`, `ATLASSIAN_TOKEN`). GitHub Projects v2 is
|
|
13
|
+
* called via the `gh` CLI. Both are optional.
|
|
14
|
+
*
|
|
15
|
+
* Python->TS nuances:
|
|
16
|
+
* - **subprocess -> child_process**: Python `subprocess.run(cmd, ...)` maps to
|
|
17
|
+
* Node's `spawnSync(cmd[0], cmd.slice(1), { maxBuffer: Infinity })`. Node's
|
|
18
|
+
* 1 MiB default `maxBuffer` differs from Python (unbounded), so it is
|
|
19
|
+
* lifted. FileNotFoundError/TimeoutExpired become {@link CommandNotFoundError}
|
|
20
|
+
* / {@link SubprocessTimeoutError}.
|
|
21
|
+
* - **urllib -> fetch seam**: Python's synchronous `urllib.request.urlopen`
|
|
22
|
+
* has no Node analog; the HTTP calls are modelled through an injectable
|
|
23
|
+
* async {@link HttpClient} (default {@link defaultHttpClient} over global
|
|
24
|
+
* `fetch`), mirroring how `guardian/hard-gate.ts` injects its REST client.
|
|
25
|
+
* A non-2xx response reproduces Python's `HTTPError` branch; a rejected
|
|
26
|
+
* fetch reproduces the `URLError` branch.
|
|
27
|
+
* - **JSON shape is a contract.** `toJson()` mirrors `json.dumps(indent=2)`
|
|
28
|
+
* with the library-default `ensure_ascii=True` reproduced via
|
|
29
|
+
* {@link ensureAscii}. Object key insertion order preserves Python's field
|
|
30
|
+
* order exactly (see {@link WorkflowMapping.toDict}).
|
|
31
|
+
* - **Python truthiness** (`""`/`[]`/`{}`/`None` falsy) via {@link pyTruthy};
|
|
32
|
+
* missing dict keys via {@link pyGet}.
|
|
33
|
+
* - **String slicing by code point**: `body[:200]` uses `[...s]` so an astral
|
|
34
|
+
* character in an error body is never split mid-surrogate.
|
|
35
|
+
*/
|
|
36
|
+
import { spawnSync } from 'node:child_process';
|
|
37
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
38
|
+
import { join } from 'node:path';
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Python-compatibility helpers (copied locally per-module, matching reporter.ts)
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
/**
|
|
43
|
+
* Python-truthiness for JSON-shaped values: `None`/`undefined`, `false`, `0`,
|
|
44
|
+
* `""`, empty array, and empty object are all falsy (mirrors `if x:`).
|
|
45
|
+
*/
|
|
46
|
+
function pyTruthy(value) {
|
|
47
|
+
if (value === null || value === undefined || value === false)
|
|
48
|
+
return false;
|
|
49
|
+
if (value === 0 || value === '')
|
|
50
|
+
return false;
|
|
51
|
+
if (Array.isArray(value))
|
|
52
|
+
return value.length > 0;
|
|
53
|
+
if (typeof value === 'object')
|
|
54
|
+
return Object.keys(value).length > 0;
|
|
55
|
+
return Boolean(value);
|
|
56
|
+
}
|
|
57
|
+
/** Python `dict.get(key, default)`: default only on a missing key. */
|
|
58
|
+
function pyGet(obj, key, fallback) {
|
|
59
|
+
return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : fallback;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Reproduce Python's `json.dumps(..., ensure_ascii=True)` (the library default)
|
|
63
|
+
* on `JSON.stringify` output: escape every code point >= 0x80 as `\uXXXX`.
|
|
64
|
+
*/
|
|
65
|
+
function ensureAscii(json) {
|
|
66
|
+
return json.replace(/[\u0080-\uffff]/g, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'));
|
|
67
|
+
}
|
|
68
|
+
/** Python `str[:n]` by CODE POINT (never splits a surrogate pair). */
|
|
69
|
+
function codePointSlice(s, n) {
|
|
70
|
+
return [...s].slice(0, n).join('');
|
|
71
|
+
}
|
|
72
|
+
/** Python `str.rstrip(ch)` -- remove all trailing runs of `ch`. */
|
|
73
|
+
function rstripChar(s, ch) {
|
|
74
|
+
let end = s.length;
|
|
75
|
+
while (end > 0 && s[end - 1] === ch)
|
|
76
|
+
end--;
|
|
77
|
+
return s.slice(0, end);
|
|
78
|
+
}
|
|
79
|
+
/** Analog of Python `FileNotFoundError` for a missing executable. */
|
|
80
|
+
export class CommandNotFoundError extends Error {
|
|
81
|
+
}
|
|
82
|
+
/** Analog of Python `subprocess.TimeoutExpired`. */
|
|
83
|
+
export class SubprocessTimeoutError extends Error {
|
|
84
|
+
}
|
|
85
|
+
/** Default HTTP transport over global `fetch`. */
|
|
86
|
+
export const defaultHttpClient = async (req) => {
|
|
87
|
+
const init = { method: req.method ?? 'GET' };
|
|
88
|
+
if (req.headers !== undefined)
|
|
89
|
+
init.headers = req.headers;
|
|
90
|
+
if (req.body !== undefined)
|
|
91
|
+
init.body = req.body;
|
|
92
|
+
const resp = await fetch(req.url, init);
|
|
93
|
+
return { ok: resp.ok, status: resp.status, text: await resp.text() };
|
|
94
|
+
};
|
|
95
|
+
/** Default subprocess runner over `spawnSync` with an unbounded output buffer. */
|
|
96
|
+
export const defaultSubprocess = (cmd, opts = {}) => {
|
|
97
|
+
const result = spawnSync(cmd[0], cmd.slice(1), {
|
|
98
|
+
encoding: 'utf-8',
|
|
99
|
+
timeout: opts.timeout !== undefined ? opts.timeout * 1000 : undefined,
|
|
100
|
+
// Python's subprocess.run has no output ceiling; Node defaults maxBuffer to
|
|
101
|
+
// 1 MiB and kills the child on overflow. Remove the cap for parity.
|
|
102
|
+
maxBuffer: Infinity,
|
|
103
|
+
});
|
|
104
|
+
if (result.error) {
|
|
105
|
+
const err = result.error;
|
|
106
|
+
if (err.code === 'ENOENT') {
|
|
107
|
+
throw new CommandNotFoundError(err.message);
|
|
108
|
+
}
|
|
109
|
+
if (err.code === 'ETIMEDOUT') {
|
|
110
|
+
throw new SubprocessTimeoutError(err.message);
|
|
111
|
+
}
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
returncode: result.status ?? 1,
|
|
116
|
+
stdout: result.stdout ?? '',
|
|
117
|
+
stderr: result.stderr ?? '',
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Schema
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
export const SCHEMA_VERSION = 'https://github.com/bop-clocktower/canary/schemas/workflow-mapping/v1';
|
|
124
|
+
// Word-list used for automatic semantic-role heuristics.
|
|
125
|
+
const ROLE_TRIGGERS = {
|
|
126
|
+
qa_passed: ['qa pass', 'qa passed', 'qa done', 'tested', 'verified'],
|
|
127
|
+
ready_to_deploy: ['deploy', 'release', 'ship', 'done', 'closed', 'merged'],
|
|
128
|
+
in_review: ['review', 'pr open', 'code review', 'awaiting review'],
|
|
129
|
+
in_qa: ['qa', 'testing', 'in test', 'in qa'],
|
|
130
|
+
in_progress: ['progress', 'active', 'started', 'in development'],
|
|
131
|
+
blocked: ['blocked', 'on hold', 'waiting'],
|
|
132
|
+
};
|
|
133
|
+
// Priority order when resolving ambiguous matches (earlier = higher priority).
|
|
134
|
+
const ROLE_PRIORITY = [
|
|
135
|
+
'qa_passed',
|
|
136
|
+
'ready_to_deploy',
|
|
137
|
+
'in_qa',
|
|
138
|
+
'in_review',
|
|
139
|
+
'in_progress',
|
|
140
|
+
'blocked',
|
|
141
|
+
];
|
|
142
|
+
/** Python: `StatusEntry` dataclass. */
|
|
143
|
+
export class StatusEntry {
|
|
144
|
+
id;
|
|
145
|
+
name;
|
|
146
|
+
category; // "new" | "indeterminate" | "done"
|
|
147
|
+
constructor(id, name, category) {
|
|
148
|
+
this.id = id;
|
|
149
|
+
this.name = name;
|
|
150
|
+
this.category = category;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** Python: `TransitionEntry` dataclass. */
|
|
154
|
+
export class TransitionEntry {
|
|
155
|
+
id;
|
|
156
|
+
name;
|
|
157
|
+
from_status;
|
|
158
|
+
to_status;
|
|
159
|
+
constructor(id, name, fromStatus, toStatus) {
|
|
160
|
+
this.id = id;
|
|
161
|
+
this.name = name;
|
|
162
|
+
this.from_status = fromStatus;
|
|
163
|
+
this.to_status = toStatus;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Python: `IssueType` dataclass. */
|
|
167
|
+
export class IssueType {
|
|
168
|
+
id;
|
|
169
|
+
name;
|
|
170
|
+
statuses;
|
|
171
|
+
transitions;
|
|
172
|
+
constructor(id, name, statuses = [], transitions = []) {
|
|
173
|
+
this.id = id;
|
|
174
|
+
this.name = name;
|
|
175
|
+
this.statuses = statuses;
|
|
176
|
+
this.transitions = transitions;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/** Python: `SemanticRole` dataclass. */
|
|
180
|
+
export class SemanticRole {
|
|
181
|
+
status_name;
|
|
182
|
+
issue_type;
|
|
183
|
+
constructor(statusName, issueType) {
|
|
184
|
+
this.status_name = statusName;
|
|
185
|
+
this.issue_type = issueType;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** Python: `WorkflowMapping` dataclass + (de)serialisation. */
|
|
189
|
+
export class WorkflowMapping {
|
|
190
|
+
project_key;
|
|
191
|
+
source; // "jira" | "github"
|
|
192
|
+
discovered_at;
|
|
193
|
+
issue_types;
|
|
194
|
+
semantic_roles;
|
|
195
|
+
role_annotations_confirmed;
|
|
196
|
+
atlassian_url; // per-project Jira base URL; overrides env
|
|
197
|
+
constructor(init) {
|
|
198
|
+
this.project_key = init.project_key;
|
|
199
|
+
this.source = init.source;
|
|
200
|
+
this.discovered_at = init.discovered_at;
|
|
201
|
+
this.issue_types = init.issue_types ?? [];
|
|
202
|
+
this.semantic_roles = init.semantic_roles ?? {};
|
|
203
|
+
this.role_annotations_confirmed = init.role_annotations_confirmed ?? false;
|
|
204
|
+
this.atlassian_url = init.atlassian_url ?? null;
|
|
205
|
+
}
|
|
206
|
+
/** Python: `WorkflowMapping.to_dict`. Key insertion order is a contract. */
|
|
207
|
+
toDict() {
|
|
208
|
+
const d = {
|
|
209
|
+
$schema: SCHEMA_VERSION,
|
|
210
|
+
project_key: this.project_key,
|
|
211
|
+
source: this.source,
|
|
212
|
+
discovered_at: this.discovered_at,
|
|
213
|
+
issue_types: [],
|
|
214
|
+
semantic_roles: {},
|
|
215
|
+
role_annotations_confirmed: this.role_annotations_confirmed,
|
|
216
|
+
};
|
|
217
|
+
// atlassian_url is appended LAST, only when truthy (Python `if self.atlassian_url`).
|
|
218
|
+
if (pyTruthy(this.atlassian_url)) {
|
|
219
|
+
d['atlassian_url'] = this.atlassian_url;
|
|
220
|
+
}
|
|
221
|
+
const issueTypes = d['issue_types'];
|
|
222
|
+
for (const it of this.issue_types) {
|
|
223
|
+
issueTypes.push({
|
|
224
|
+
id: it.id,
|
|
225
|
+
name: it.name,
|
|
226
|
+
statuses: it.statuses.map((s) => ({
|
|
227
|
+
id: s.id,
|
|
228
|
+
name: s.name,
|
|
229
|
+
category: s.category,
|
|
230
|
+
})),
|
|
231
|
+
transitions: it.transitions.map((t) => ({
|
|
232
|
+
id: t.id,
|
|
233
|
+
name: t.name,
|
|
234
|
+
from: t.from_status,
|
|
235
|
+
to: t.to_status,
|
|
236
|
+
})),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
const roles = d['semantic_roles'];
|
|
240
|
+
for (const [role, sr] of Object.entries(this.semantic_roles)) {
|
|
241
|
+
roles[role] = { status_name: sr.status_name, issue_type: sr.issue_type };
|
|
242
|
+
}
|
|
243
|
+
return d;
|
|
244
|
+
}
|
|
245
|
+
/** Python: `WorkflowMapping.to_json`. */
|
|
246
|
+
toJson(indent = 2) {
|
|
247
|
+
return ensureAscii(JSON.stringify(this.toDict(), null, indent));
|
|
248
|
+
}
|
|
249
|
+
/** Python: `WorkflowMapping.from_dict`. Throws on a malformed shape (KeyError). */
|
|
250
|
+
static fromDict(data) {
|
|
251
|
+
if (!Object.prototype.hasOwnProperty.call(data, 'project_key')) {
|
|
252
|
+
throw new Error("missing key 'project_key'");
|
|
253
|
+
}
|
|
254
|
+
const issueTypes = [];
|
|
255
|
+
const rawIssueTypes = pyGet(data, 'issue_types', []);
|
|
256
|
+
for (const itD of rawIssueTypes) {
|
|
257
|
+
const statuses = pyGet(itD, 'statuses', []).map((s) => {
|
|
258
|
+
// Python builds StatusEntry(**s); **-unpacking raises TypeError on a
|
|
259
|
+
// missing key, which _load_cached catches -> returns null. Mirror that
|
|
260
|
+
// so a malformed cache is REJECTED (re-discovered from Jira) rather than
|
|
261
|
+
// served with undefined fields, which would let resolveRole proceed to a
|
|
262
|
+
// real state-changing Jira transition.
|
|
263
|
+
if (!Object.prototype.hasOwnProperty.call(s, 'id') ||
|
|
264
|
+
!Object.prototype.hasOwnProperty.call(s, 'name') ||
|
|
265
|
+
!Object.prototype.hasOwnProperty.call(s, 'category')) {
|
|
266
|
+
throw new Error('malformed status entry');
|
|
267
|
+
}
|
|
268
|
+
return new StatusEntry(s['id'], s['name'], s['category']);
|
|
269
|
+
});
|
|
270
|
+
const transitions = pyGet(itD, 'transitions', []).map((t) => {
|
|
271
|
+
if (!Object.prototype.hasOwnProperty.call(t, 'id') ||
|
|
272
|
+
!Object.prototype.hasOwnProperty.call(t, 'name')) {
|
|
273
|
+
throw new Error('malformed transition');
|
|
274
|
+
}
|
|
275
|
+
return new TransitionEntry(t['id'], t['name'], pyGet(t, 'from', pyGet(t, 'from_status', '')), pyGet(t, 'to', pyGet(t, 'to_status', '')));
|
|
276
|
+
});
|
|
277
|
+
if (!Object.prototype.hasOwnProperty.call(itD, 'id') ||
|
|
278
|
+
!Object.prototype.hasOwnProperty.call(itD, 'name')) {
|
|
279
|
+
throw new Error('malformed issue type');
|
|
280
|
+
}
|
|
281
|
+
issueTypes.push(new IssueType(itD['id'], itD['name'], statuses, transitions));
|
|
282
|
+
}
|
|
283
|
+
const semanticRoles = {};
|
|
284
|
+
const rawRoles = pyGet(data, 'semantic_roles', {});
|
|
285
|
+
for (const [role, srD] of Object.entries(rawRoles)) {
|
|
286
|
+
// Python SemanticRole(**sr_d) raises TypeError on a missing key ->
|
|
287
|
+
// _load_cached returns null. Mirror it so a role missing status_name/
|
|
288
|
+
// issue_type rejects the whole cache instead of resolving to a partial
|
|
289
|
+
// role and attempting a state-changing transition.
|
|
290
|
+
if (!Object.prototype.hasOwnProperty.call(srD, 'status_name') ||
|
|
291
|
+
!Object.prototype.hasOwnProperty.call(srD, 'issue_type')) {
|
|
292
|
+
throw new Error('malformed semantic role');
|
|
293
|
+
}
|
|
294
|
+
semanticRoles[role] = new SemanticRole(srD['status_name'], srD['issue_type']);
|
|
295
|
+
}
|
|
296
|
+
return new WorkflowMapping({
|
|
297
|
+
project_key: data['project_key'],
|
|
298
|
+
source: pyGet(data, 'source', 'jira'),
|
|
299
|
+
discovered_at: pyGet(data, 'discovered_at', ''),
|
|
300
|
+
issue_types: issueTypes,
|
|
301
|
+
semantic_roles: semanticRoles,
|
|
302
|
+
role_annotations_confirmed: pyGet(data, 'role_annotations_confirmed', false),
|
|
303
|
+
atlassian_url: pyGet(data, 'atlassian_url', null) ?? null,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
// Errors
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
/** Raised when discovery cannot proceed due to a configuration problem. */
|
|
311
|
+
export class WorkflowDiscoveryError extends Error {
|
|
312
|
+
}
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
// Main class
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
/** Discovers and caches per-project issue-workflow mappings. */
|
|
317
|
+
export class WorkflowDiscovery {
|
|
318
|
+
canaryDir;
|
|
319
|
+
http;
|
|
320
|
+
subprocess;
|
|
321
|
+
constructor(canaryDir, deps = {}) {
|
|
322
|
+
this.canaryDir =
|
|
323
|
+
canaryDir !== undefined && canaryDir !== null
|
|
324
|
+
? canaryDir
|
|
325
|
+
: join(process.cwd(), '.canary');
|
|
326
|
+
this.http = deps.http ?? defaultHttpClient;
|
|
327
|
+
this.subprocess = deps.subprocess ?? defaultSubprocess;
|
|
328
|
+
}
|
|
329
|
+
// -- public ----------------------------------------------------------------
|
|
330
|
+
/** Python: `WorkflowDiscovery.discover`. */
|
|
331
|
+
async discover(projectKey, opts = {}) {
|
|
332
|
+
const refresh = opts.refresh ?? false;
|
|
333
|
+
const dryRun = opts.dryRun ?? false;
|
|
334
|
+
const cached = refresh ? null : this.loadCached(projectKey);
|
|
335
|
+
if (cached !== null) {
|
|
336
|
+
return cached;
|
|
337
|
+
}
|
|
338
|
+
let mapping;
|
|
339
|
+
if (projectKey.includes('/')) {
|
|
340
|
+
mapping = await this.fetchGithub(projectKey);
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
mapping = await this.fetchJira(projectKey);
|
|
344
|
+
}
|
|
345
|
+
// Preserve user-confirmed semantic roles from any previous mapping.
|
|
346
|
+
if (refresh) {
|
|
347
|
+
const prev = this.loadCached(projectKey);
|
|
348
|
+
if (prev && prev.role_annotations_confirmed) {
|
|
349
|
+
for (const [role, sr] of Object.entries(prev.semantic_roles)) {
|
|
350
|
+
if (!Object.prototype.hasOwnProperty.call(mapping.semantic_roles, role)) {
|
|
351
|
+
mapping.semantic_roles[role] = sr;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
mapping.role_annotations_confirmed = true;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
mapping = this.applyHeuristics(mapping);
|
|
358
|
+
if (!dryRun) {
|
|
359
|
+
this.write(mapping);
|
|
360
|
+
}
|
|
361
|
+
return mapping;
|
|
362
|
+
}
|
|
363
|
+
/** Python: `WorkflowDiscovery.show`. */
|
|
364
|
+
show(projectKey) {
|
|
365
|
+
return this.loadCached(projectKey);
|
|
366
|
+
}
|
|
367
|
+
/** Python: `WorkflowDiscovery.resolve_role`. */
|
|
368
|
+
resolveRole(projectKey, role) {
|
|
369
|
+
const mapping = this.loadCached(projectKey);
|
|
370
|
+
if (mapping === null) {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
const sr = mapping.semantic_roles[role];
|
|
374
|
+
return sr ? sr.status_name : null;
|
|
375
|
+
}
|
|
376
|
+
// -- private: persistence --------------------------------------------------
|
|
377
|
+
/** Python: `WorkflowDiscovery._mapping_path`. */
|
|
378
|
+
mappingPath(projectKey) {
|
|
379
|
+
// `u` flag: an astral code point is ONE unit (one `_`), matching Python's
|
|
380
|
+
// re.sub over code points; without it a surrogate pair becomes two `_`.
|
|
381
|
+
const safeKey = projectKey.replace(/[^A-Za-z0-9_-]/gu, '_');
|
|
382
|
+
return join(this.canaryDir, `workflow-${safeKey}.json`);
|
|
383
|
+
}
|
|
384
|
+
/** Python: `WorkflowDiscovery._load_cached`. */
|
|
385
|
+
loadCached(projectKey) {
|
|
386
|
+
const path = this.mappingPath(projectKey);
|
|
387
|
+
if (!existsSync(path)) {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
try {
|
|
391
|
+
const data = JSON.parse(readFileSync(path, 'utf-8'));
|
|
392
|
+
return WorkflowMapping.fromDict(data);
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
// JSONDecodeError / KeyError / TypeError -> None
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/** Python: `WorkflowDiscovery._write`. */
|
|
400
|
+
write(mapping) {
|
|
401
|
+
mkdirSync(this.canaryDir, { recursive: true });
|
|
402
|
+
const path = this.mappingPath(mapping.project_key);
|
|
403
|
+
writeFileSync(path, mapping.toJson(), 'utf-8');
|
|
404
|
+
}
|
|
405
|
+
// -- private: Jira ---------------------------------------------------------
|
|
406
|
+
/** Python: `WorkflowDiscovery._fetch_jira`. */
|
|
407
|
+
async fetchJira(projectKey) {
|
|
408
|
+
const baseUrl = rstripChar(process.env['ATLASSIAN_URL'] ?? '', '/');
|
|
409
|
+
const user = process.env['ATLASSIAN_USER'] ?? '';
|
|
410
|
+
const token = process.env['ATLASSIAN_TOKEN'] ?? '';
|
|
411
|
+
if (!pyTruthy(baseUrl) || !pyTruthy(user) || !pyTruthy(token)) {
|
|
412
|
+
throw new WorkflowDiscoveryError('Jira credentials not configured. Set ATLASSIAN_URL, ' +
|
|
413
|
+
'ATLASSIAN_USER, and ATLASSIAN_TOKEN environment variables.\n' +
|
|
414
|
+
'Tip: add them to .canary/company.local.json or your shell profile.');
|
|
415
|
+
}
|
|
416
|
+
const auth = Buffer.from(`${user}:${token}`).toString('base64');
|
|
417
|
+
const headers = {
|
|
418
|
+
Authorization: `Basic ${auth}`,
|
|
419
|
+
Accept: 'application/json',
|
|
420
|
+
};
|
|
421
|
+
// Capture the URL so ticket_updater can use it without requiring the env var.
|
|
422
|
+
const discoveredBaseUrl = baseUrl;
|
|
423
|
+
// 1. Get issue types for this project.
|
|
424
|
+
const issueTypesRaw = await this.jiraGet(`${baseUrl}/rest/api/3/project/${projectKey}/issuetypes`, headers);
|
|
425
|
+
if (!Array.isArray(issueTypesRaw) &&
|
|
426
|
+
typeof issueTypesRaw === 'object' &&
|
|
427
|
+
issueTypesRaw !== null &&
|
|
428
|
+
'errorMessages' in issueTypesRaw) {
|
|
429
|
+
throw new WorkflowDiscoveryError(`Jira project ${pyRepr(projectKey)} not found or access denied: ` +
|
|
430
|
+
`${pyRepr(issueTypesRaw['errorMessages'])}`);
|
|
431
|
+
}
|
|
432
|
+
const issueTypes = [];
|
|
433
|
+
const list = Array.isArray(issueTypesRaw)
|
|
434
|
+
? issueTypesRaw
|
|
435
|
+
: [];
|
|
436
|
+
for (const itRaw of list) {
|
|
437
|
+
const itId = String(pyGet(itRaw, 'id', ''));
|
|
438
|
+
const itName = String(pyGet(itRaw, 'name', ''));
|
|
439
|
+
if (!pyTruthy(itName)) {
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
// 2. Get statuses for this issue type.
|
|
443
|
+
const statusesRaw = await this.jiraGet(`${baseUrl}/rest/api/3/project/${projectKey}/statuses`, headers);
|
|
444
|
+
const statuses = this.parseStatuses(statusesRaw, itName);
|
|
445
|
+
// 3. Try to get transitions by sampling one issue of this type.
|
|
446
|
+
const transitions = await this.sampleTransitions(baseUrl, headers, projectKey, itName);
|
|
447
|
+
issueTypes.push(new IssueType(itId, itName, statuses, transitions));
|
|
448
|
+
}
|
|
449
|
+
return new WorkflowMapping({
|
|
450
|
+
project_key: projectKey,
|
|
451
|
+
source: 'jira',
|
|
452
|
+
discovered_at: nowIso(),
|
|
453
|
+
issue_types: issueTypes,
|
|
454
|
+
atlassian_url: discoveredBaseUrl,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
/** Python: `WorkflowDiscovery._parse_statuses`. */
|
|
458
|
+
parseStatuses(statusesRaw, issueTypeName) {
|
|
459
|
+
if (!Array.isArray(statusesRaw)) {
|
|
460
|
+
return [];
|
|
461
|
+
}
|
|
462
|
+
const entries = statusesRaw;
|
|
463
|
+
for (const entry of entries) {
|
|
464
|
+
const entryName = pyGet(entry, 'name', '') ?? '';
|
|
465
|
+
if (entryName.toLowerCase() === issueTypeName.toLowerCase()) {
|
|
466
|
+
return pyGet(entry, 'statuses', []).map((s) => new StatusEntry(String(pyGet(s, 'id', '')), pyGet(s, 'name', ''), pyGet(pyGet(s, 'statusCategory', {}), 'key', 'indeterminate')));
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// Fallback: return all statuses from all types (deduplicated by name).
|
|
470
|
+
const seen = new Set();
|
|
471
|
+
const result = [];
|
|
472
|
+
for (const entry of entries) {
|
|
473
|
+
for (const s of pyGet(entry, 'statuses', [])) {
|
|
474
|
+
const name = pyGet(s, 'name', '');
|
|
475
|
+
if (pyTruthy(name) && !seen.has(name)) {
|
|
476
|
+
seen.add(name);
|
|
477
|
+
result.push(new StatusEntry(String(pyGet(s, 'id', '')), name, pyGet(pyGet(s, 'statusCategory', {}), 'key', 'indeterminate')));
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return result;
|
|
482
|
+
}
|
|
483
|
+
/** Python: `WorkflowDiscovery._sample_transitions`. */
|
|
484
|
+
async sampleTransitions(baseUrl, headers, projectKey, issueTypeName) {
|
|
485
|
+
const jql = `project = ${projectKey} AND issuetype = "${issueTypeName}" ` +
|
|
486
|
+
`AND statusCategory != Done ORDER BY created DESC`;
|
|
487
|
+
const params = new URLSearchParams({
|
|
488
|
+
jql,
|
|
489
|
+
maxResults: '1',
|
|
490
|
+
fields: 'id',
|
|
491
|
+
}).toString();
|
|
492
|
+
let searchResult;
|
|
493
|
+
try {
|
|
494
|
+
searchResult = await this.jiraGet(`${baseUrl}/rest/api/3/issue/search?${params}`, headers);
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
return [];
|
|
498
|
+
}
|
|
499
|
+
const isDict = searchResult !== null &&
|
|
500
|
+
typeof searchResult === 'object' &&
|
|
501
|
+
!Array.isArray(searchResult);
|
|
502
|
+
const issues = isDict
|
|
503
|
+
? pyGet(searchResult, 'issues', [])
|
|
504
|
+
: [];
|
|
505
|
+
if (!pyTruthy(issues)) {
|
|
506
|
+
return [];
|
|
507
|
+
}
|
|
508
|
+
const issueKey = pyGet(issues[0], 'key', '');
|
|
509
|
+
if (!pyTruthy(issueKey)) {
|
|
510
|
+
return [];
|
|
511
|
+
}
|
|
512
|
+
let transitionsRaw;
|
|
513
|
+
try {
|
|
514
|
+
transitionsRaw = await this.jiraGet(`${baseUrl}/rest/api/3/issue/${issueKey}/transitions`, headers);
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
return [];
|
|
518
|
+
}
|
|
519
|
+
if (transitionsRaw === null ||
|
|
520
|
+
typeof transitionsRaw !== 'object' ||
|
|
521
|
+
Array.isArray(transitionsRaw)) {
|
|
522
|
+
return [];
|
|
523
|
+
}
|
|
524
|
+
const rawList = pyGet(transitionsRaw, 'transitions', []);
|
|
525
|
+
return rawList.map((t) => {
|
|
526
|
+
const from = pyGet(t, 'from', null);
|
|
527
|
+
const to = pyGet(t, 'to', null);
|
|
528
|
+
return new TransitionEntry(String(pyGet(t, 'id', '')), pyGet(t, 'name', ''), from !== null && typeof from === 'object'
|
|
529
|
+
? pyGet(from, 'name', '')
|
|
530
|
+
: '', to !== null && typeof to === 'object'
|
|
531
|
+
? pyGet(to, 'name', '')
|
|
532
|
+
: '');
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
/** Python: `WorkflowDiscovery._jira_get`. */
|
|
536
|
+
async jiraGet(url, headers) {
|
|
537
|
+
let resp;
|
|
538
|
+
try {
|
|
539
|
+
resp = await this.http({ url, headers, method: 'GET', timeout: 10 });
|
|
540
|
+
}
|
|
541
|
+
catch (exc) {
|
|
542
|
+
// urllib.error.URLError branch.
|
|
543
|
+
throw new WorkflowDiscoveryError(`Network error calling Jira API: ${reasonOf(exc)}`);
|
|
544
|
+
}
|
|
545
|
+
if (!resp.ok) {
|
|
546
|
+
// urllib.error.HTTPError branch.
|
|
547
|
+
throw new WorkflowDiscoveryError(`Jira API error ${resp.status} for ${url}: ${codePointSlice(resp.text, 200)}`);
|
|
548
|
+
}
|
|
549
|
+
return JSON.parse(resp.text);
|
|
550
|
+
}
|
|
551
|
+
// -- private: GitHub -------------------------------------------------------
|
|
552
|
+
/** Python: `WorkflowDiscovery._fetch_github`. */
|
|
553
|
+
async fetchGithub(repoSlug) {
|
|
554
|
+
let result;
|
|
555
|
+
try {
|
|
556
|
+
result = this.subprocess([
|
|
557
|
+
'gh',
|
|
558
|
+
'api',
|
|
559
|
+
`repos/${repoSlug}/projects`,
|
|
560
|
+
'--jq',
|
|
561
|
+
'.[0].columns_url // empty',
|
|
562
|
+
], { timeout: 10 });
|
|
563
|
+
}
|
|
564
|
+
catch (exc) {
|
|
565
|
+
if (exc instanceof CommandNotFoundError ||
|
|
566
|
+
exc instanceof SubprocessTimeoutError) {
|
|
567
|
+
throw new WorkflowDiscoveryError('GitHub CLI (gh) is not installed or timed out. ' +
|
|
568
|
+
'Install gh and run `gh auth login` before discovering GitHub workflows.');
|
|
569
|
+
}
|
|
570
|
+
throw exc;
|
|
571
|
+
}
|
|
572
|
+
if (result.returncode !== 0 || !pyTruthy(result.stdout.trim())) {
|
|
573
|
+
// No project board found -- synthesize a minimal "open / closed" mapping.
|
|
574
|
+
return new WorkflowMapping({
|
|
575
|
+
project_key: repoSlug,
|
|
576
|
+
source: 'github',
|
|
577
|
+
discovered_at: nowIso(),
|
|
578
|
+
issue_types: [
|
|
579
|
+
new IssueType('github_issue', 'GitHub Issue', [
|
|
580
|
+
new StatusEntry('open', 'Open', 'new'),
|
|
581
|
+
new StatusEntry('closed', 'Closed', 'done'),
|
|
582
|
+
]),
|
|
583
|
+
],
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
const columnsUrl = result.stdout.trim();
|
|
587
|
+
let colResult;
|
|
588
|
+
try {
|
|
589
|
+
colResult = this.subprocess([
|
|
590
|
+
'gh',
|
|
591
|
+
'api',
|
|
592
|
+
columnsUrl,
|
|
593
|
+
'--jq',
|
|
594
|
+
'[.[] | {id: .id|tostring, name: .name}]',
|
|
595
|
+
], { timeout: 10 });
|
|
596
|
+
}
|
|
597
|
+
catch (exc) {
|
|
598
|
+
if (exc instanceof SubprocessTimeoutError) {
|
|
599
|
+
throw new WorkflowDiscoveryError('gh API timed out fetching project columns');
|
|
600
|
+
}
|
|
601
|
+
throw exc;
|
|
602
|
+
}
|
|
603
|
+
const columns = colResult.returncode === 0 ? JSON.parse(colResult.stdout) : [];
|
|
604
|
+
const statuses = columns.map((col) => new StatusEntry(col['id'], col['name'], /done|closed|merged|shipped/i.test(col['name'])
|
|
605
|
+
? 'done'
|
|
606
|
+
: 'indeterminate'));
|
|
607
|
+
return new WorkflowMapping({
|
|
608
|
+
project_key: repoSlug,
|
|
609
|
+
source: 'github',
|
|
610
|
+
discovered_at: nowIso(),
|
|
611
|
+
issue_types: [new IssueType('github_issue', 'GitHub Issue', statuses)],
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
// -- private: heuristics ---------------------------------------------------
|
|
615
|
+
/** Python: `WorkflowDiscovery._apply_heuristics`. */
|
|
616
|
+
applyHeuristics(mapping) {
|
|
617
|
+
// Collect all (issue_type_name, status_name) pairs.
|
|
618
|
+
const candidates = [];
|
|
619
|
+
for (const it of mapping.issue_types) {
|
|
620
|
+
for (const s of it.statuses) {
|
|
621
|
+
candidates.push([it.name, s.name]);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
const assigned = {};
|
|
625
|
+
for (const role of ROLE_PRIORITY) {
|
|
626
|
+
if (Object.prototype.hasOwnProperty.call(mapping.semantic_roles, role)) {
|
|
627
|
+
continue; // Already set (e.g. from a previous confirmed mapping).
|
|
628
|
+
}
|
|
629
|
+
const triggers = ROLE_TRIGGERS[role] ?? [];
|
|
630
|
+
for (const [itName, statusName] of candidates) {
|
|
631
|
+
const low = statusName.toLowerCase();
|
|
632
|
+
if (triggers.some((trigger) => low.includes(trigger))) {
|
|
633
|
+
assigned[role] = new SemanticRole(statusName, itName);
|
|
634
|
+
break; // first match wins for this role
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
mapping.semantic_roles = { ...assigned, ...mapping.semantic_roles };
|
|
639
|
+
return mapping;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
// ---------------------------------------------------------------------------
|
|
643
|
+
// Module-level convenience
|
|
644
|
+
// ---------------------------------------------------------------------------
|
|
645
|
+
/** Python: module-level `resolve_role`. */
|
|
646
|
+
export function resolveRole(projectKey, role, canaryDir) {
|
|
647
|
+
return new WorkflowDiscovery(canaryDir).resolveRole(projectKey, role);
|
|
648
|
+
}
|
|
649
|
+
/** Python: module-level `atlassian_url_for`. */
|
|
650
|
+
export function atlassianUrlFor(projectKey, canaryDir) {
|
|
651
|
+
const mapping = new WorkflowDiscovery(canaryDir).show(projectKey);
|
|
652
|
+
return mapping ? mapping.atlassian_url : null;
|
|
653
|
+
}
|
|
654
|
+
// ---------------------------------------------------------------------------
|
|
655
|
+
// Helpers
|
|
656
|
+
// ---------------------------------------------------------------------------
|
|
657
|
+
/** Python: `_now_iso` -- UTC ISO-8601 to seconds precision (`+00:00` suffix). */
|
|
658
|
+
function nowIso() {
|
|
659
|
+
return new Date().toISOString().replace(/\.\d+Z$/, '+00:00');
|
|
660
|
+
}
|
|
661
|
+
/** Extract a human-readable reason from a thrown value (Python `exc.reason`). */
|
|
662
|
+
function reasonOf(exc) {
|
|
663
|
+
if (exc !== null && typeof exc === 'object' && 'message' in exc) {
|
|
664
|
+
return String(exc.message);
|
|
665
|
+
}
|
|
666
|
+
return String(exc);
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Minimal Python `repr()` for the values that reach the Jira error message
|
|
670
|
+
* (a string project key and a list of error strings). Not a general repr.
|
|
671
|
+
*/
|
|
672
|
+
function pyRepr(value) {
|
|
673
|
+
if (typeof value === 'string') {
|
|
674
|
+
// Python prefers single quotes unless the string has a single quote but no
|
|
675
|
+
// double quote.
|
|
676
|
+
if (value.includes("'") && !value.includes('"')) {
|
|
677
|
+
return '"' + value.replace(/\\/g, '\\\\') + '"';
|
|
678
|
+
}
|
|
679
|
+
return "'" + value.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";
|
|
680
|
+
}
|
|
681
|
+
if (Array.isArray(value)) {
|
|
682
|
+
return '[' + value.map((v) => pyRepr(v)).join(', ') + ']';
|
|
683
|
+
}
|
|
684
|
+
if (value === null || value === undefined) {
|
|
685
|
+
return 'None';
|
|
686
|
+
}
|
|
687
|
+
if (value === true)
|
|
688
|
+
return 'True';
|
|
689
|
+
if (value === false)
|
|
690
|
+
return 'False';
|
|
691
|
+
return String(value);
|
|
692
|
+
}
|
|
693
|
+
//# sourceMappingURL=workflow-discovery.js.map
|