@stravica-ai/digital-operator-pa 0.1.39 → 0.1.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +12 -2
  2. package/bin/confluence +9 -0
  3. package/bin/confluence-fetch +9 -0
  4. package/bin/jira +9 -0
  5. package/dist/engineering-pa-core/knowledge/content-voice/banned-ai-tells-taxonomy.md +1 -1
  6. package/dist/engineering-pa-core/knowledge/knowledge-memory/staging-and-observations.md +3 -3
  7. package/dist/engineering-pa-core/knowledge/platform-tooling/workspace-extensions.md +62 -0
  8. package/dist/engineering-pa-core-extensions-atlassian/README.md +81 -16
  9. package/dist/engineering-pa-core-extensions-atlassian/bin/confluence +2 -0
  10. package/dist/engineering-pa-core-extensions-atlassian/bin/confluence-fetch +2 -0
  11. package/dist/engineering-pa-core-extensions-atlassian/bin/jira +2 -0
  12. package/dist/engineering-pa-core-extensions-atlassian/config/jira-workflow-chains.default.json +4 -0
  13. package/dist/engineering-pa-core-extensions-atlassian/index.mjs +17 -6
  14. package/dist/engineering-pa-core-extensions-atlassian/package.json +11 -4
  15. package/dist/engineering-pa-core-extensions-atlassian/src/common/invalidate-client.mjs +137 -0
  16. package/dist/engineering-pa-core-extensions-atlassian/src/common/pa-name.mjs +58 -0
  17. package/dist/engineering-pa-core-extensions-atlassian/src/common/secrets.mjs +158 -0
  18. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/cli.mjs +165 -0
  19. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/client.mjs +294 -0
  20. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/auth.mjs +59 -0
  21. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/comment.mjs +174 -0
  22. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/create.mjs +136 -0
  23. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/label.mjs +98 -0
  24. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mine.mjs +105 -0
  25. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-folder.mjs +487 -0
  26. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-page.mjs +351 -0
  27. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/page.mjs +127 -0
  28. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/pages.mjs +107 -0
  29. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/recent.mjs +107 -0
  30. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/search.mjs +123 -0
  31. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/spaces.mjs +82 -0
  32. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/test.mjs +155 -0
  33. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/tree.mjs +103 -0
  34. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/update.mjs +134 -0
  35. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/format.mjs +260 -0
  36. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/mirror.mjs +353 -0
  37. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/storage.mjs +880 -0
  38. package/dist/engineering-pa-core-extensions-atlassian/src/confluence-fetch/cli.mjs +313 -0
  39. package/dist/engineering-pa-core-extensions-atlassian/src/confluence-fetch/fetch.mjs +327 -0
  40. package/dist/engineering-pa-core-extensions-atlassian/src/jira/adf.mjs +388 -0
  41. package/dist/engineering-pa-core-extensions-atlassian/src/jira/bug-fields.mjs +294 -0
  42. package/dist/engineering-pa-core-extensions-atlassian/src/jira/cli.mjs +197 -0
  43. package/dist/engineering-pa-core-extensions-atlassian/src/jira/client.mjs +350 -0
  44. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/advance.mjs +486 -0
  45. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/attach.mjs +129 -0
  46. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/auth.mjs +53 -0
  47. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/boards.mjs +88 -0
  48. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/bulk-transition.mjs +223 -0
  49. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/cancel.mjs +135 -0
  50. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/comment.mjs +169 -0
  51. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/create.mjs +376 -0
  52. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/issue.mjs +105 -0
  53. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/link.mjs +204 -0
  54. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/mine.mjs +97 -0
  55. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/projects.mjs +91 -0
  56. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/search.mjs +80 -0
  57. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sprint.mjs +111 -0
  58. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sync-statuses.mjs +313 -0
  59. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/test.mjs +142 -0
  60. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/transition.mjs +197 -0
  61. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/update.mjs +284 -0
  62. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/worklog.mjs +380 -0
  63. package/dist/engineering-pa-core-extensions-atlassian/src/jira/format.mjs +267 -0
  64. package/dist/engineering-pa-core-extensions-atlassian/src/jira/invalidate.mjs +72 -0
  65. package/dist/engineering-pa-core-extensions-atlassian/src/jira/metadata.mjs +225 -0
  66. package/dist/engineering-pa-core-extensions-atlassian/src/jira/required-fields.mjs +334 -0
  67. package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-chains.mjs +405 -0
  68. package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-map.mjs +107 -0
  69. package/dist/engineering-pa-scaffold/README.md +32 -1
  70. package/dist/engineering-pa-scaffold/src/cli.mjs +83 -6
  71. package/dist/engineering-pa-scaffold/src/commands/init-workspace-extension.mjs +234 -0
  72. package/dist/engineering-pa-scaffold/src/commands/init.mjs +6 -5
  73. package/dist/engineering-pa-scaffold/src/generator.mjs +240 -4
  74. package/dist/engineering-pa-scaffold/templates/CLAUDE.md.template +7 -7
  75. package/dist/engineering-pa-scaffold/templates/operator-README.md.template +4 -4
  76. package/dist/engineering-pa-scaffold/templates/root-CLAUDE.md.template +13 -0
  77. package/dist/engineering-pa-types-engineering/index.mjs +14 -6
  78. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/create-a-small-app.md +81 -0
  79. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/for-agents.md +25 -0
  80. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/index.md +34 -0
  81. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/reference.md +16 -0
  82. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/wire-your-agent.md +17 -0
  83. package/dist/engineering-pa-types-engineering/package.json +2 -2
  84. package/dist/engineering-pa-types-engineering/roles/rcf-lite-engineer/definition.md +183 -0
  85. package/dist/engineering-pa-workspace/app/home.html +7 -7
  86. package/dist/engineering-pa-workspace/app/onboarding.html +4 -4
  87. package/dist/engineering-pa-workspace/src/api.mjs +1 -1
  88. package/dist/engineering-pa-workspace/src/server.mjs +1 -1
  89. package/docs/installing.md +78 -0
  90. package/docs/onboarding-solo.md +162 -0
  91. package/docs/what-it-can-do.md +85 -0
  92. package/package.json +6 -2
@@ -0,0 +1,72 @@
1
+ // invalidate.mjs - Notify the workspace server that Jira state has changed.
2
+ //
3
+ // After a Jira write command (create / update / transition / comment / cancel),
4
+ // the CLI POSTs to /__api/jira/invalidate on the local workspace server. The
5
+ // server clears its 60-second cache buckets and pushes an SSE event so any
6
+ // open /jira.html tab re-fetches immediately.
7
+ //
8
+ // Transport lives in the shared client at
9
+ // operator/extensions/tools/invalidate-client/src/client.mjs; this module
10
+ // only binds the jira-specific endpoint, body shape, and disable flag. The
11
+ // contract is unchanged:
12
+ // - Fail-silent. The CLI's primary job is to mutate Jira; the workspace
13
+ // is a nice-to-have. If the workspace server is stopped, the wrong port
14
+ // is configured, or the network call hangs, the CLI must not slow down
15
+ // and must not surface an error to the operator.
16
+ // - Non-blocking by design. Fire the request, wait at most 500ms, then
17
+ // return regardless of outcome.
18
+ // - Zero runtime deps. Uses Node 20+ globalThis.fetch and AbortController.
19
+ //
20
+ // Disable mechanism: set DEX_JIRA_INVALIDATE=0 to skip the notify (useful for
21
+ // test environments or if the workspace is intentionally absent).
22
+
23
+ import {
24
+ notifyInvalidate,
25
+ notifyInvalidateAsync,
26
+ } from '../common/invalidate-client.mjs';
27
+
28
+ const ENDPOINT = '/__api/jira/invalidate';
29
+ const DISABLE_ENV = 'DEX_JIRA_INVALIDATE';
30
+
31
+ function toClientOpts(opts = {}) {
32
+ return {
33
+ endpoint: ENDPOINT,
34
+ disableEnv: DISABLE_ENV,
35
+ body: { views: Array.isArray(opts.views) ? opts.views : [] },
36
+ port: opts.port,
37
+ timeoutMs: opts.timeoutMs,
38
+ fetchImpl: opts.fetchImpl,
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Notify the workspace that Jira state changed. Non-blocking, fail-silent.
44
+ *
45
+ * @param {object} [opts]
46
+ * @param {string[]} [opts.views] Cache buckets to clear. Omit / empty to
47
+ * clear all (the typical case).
48
+ * @param {number} [opts.port] Override the resolved workspace port
49
+ * (test injection).
50
+ * @param {number} [opts.timeoutMs] Override the request timeout.
51
+ * @param {Function} [opts.fetchImpl] Override fetch (test injection).
52
+ * @returns {Promise<{ok: boolean, error?: string}>} Always resolves; never
53
+ * throws.
54
+ */
55
+ export function notifyWorkspace(opts = {}) {
56
+ return notifyInvalidate(toClientOpts(opts));
57
+ }
58
+
59
+ /**
60
+ * Fire-and-forget wrapper. Returns immediately; the notify runs in the
61
+ * background. Suitable for use at the tail of a CLI command where we
62
+ * don't want to delay the user-visible exit by even the timeout window.
63
+ *
64
+ * The returned promise can be awaited in tests to deterministically
65
+ * observe completion; in production the CLI just doesn't await it.
66
+ *
67
+ * @param {object} [opts] Same shape as notifyWorkspace.
68
+ * @returns {Promise<{ok: boolean, error?: string}>}
69
+ */
70
+ export function notifyWorkspaceAsync(opts = {}) {
71
+ return notifyInvalidateAsync(toClientOpts(opts));
72
+ }
@@ -0,0 +1,225 @@
1
+ // metadata.mjs - Shared helpers for resolving Jira metadata fields.
2
+ //
3
+ // Resolves component names → ids (scoped to a project), priority names → ids,
4
+ // and assignee emails → accountIds. Also validates label values.
5
+ //
6
+ // Each resolver takes an injected `client` so it stays testable with mocked
7
+ // fetch via `createClient({ fetchFn })`. None of these helpers touch the vault.
8
+
9
+ import { JiraError } from './client.mjs';
10
+
11
+ // ── Labels ───────────────────────────────────────────────────────────
12
+
13
+ /**
14
+ * Jira labels cannot contain whitespace. Validate locally and throw a
15
+ * usage-error (exit code 1) on first violation so the CLI fails fast
16
+ * without burning an API call.
17
+ *
18
+ * @param {string[]} labels Label values to check
19
+ * @throws {JiraError} exitCode 1 if any label is invalid
20
+ */
21
+ export function validateLabels(labels) {
22
+ for (const label of labels) {
23
+ if (typeof label !== 'string' || label.length === 0) {
24
+ throw new JiraError(`Invalid label: empty value`, 1);
25
+ }
26
+ if (/\s/.test(label)) {
27
+ throw new JiraError(
28
+ `Invalid label "${label}": Jira labels cannot contain whitespace`,
29
+ 1
30
+ );
31
+ }
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Parse a `--set-labels a,b,c` value into an array. Commas separate;
37
+ * surrounding whitespace on each label is trimmed. Empty entries are
38
+ * dropped. Labels themselves cannot contain whitespace (validated
39
+ * downstream).
40
+ *
41
+ * @param {string} raw Raw flag value (comma-separated list)
42
+ * @returns {string[]} Cleaned label list
43
+ */
44
+ export function parseCommaList(raw) {
45
+ if (raw == null) return [];
46
+ return String(raw)
47
+ .split(',')
48
+ .map((s) => s.trim())
49
+ .filter((s) => s.length > 0);
50
+ }
51
+
52
+ // ── Components ───────────────────────────────────────────────────────
53
+
54
+ /**
55
+ * Fetch the components list for a project and return a name→id map.
56
+ * Names are matched case-insensitively; the original casing of the
57
+ * first encountered match is preserved in the map keys.
58
+ *
59
+ * @param {object} client Jira client
60
+ * @param {string} projectKey Project key (e.g. "PROJ")
61
+ * @returns {Promise<Map<string, { id: string, name: string }>>}
62
+ * Map keyed by lowercased name → { id, name }
63
+ */
64
+ export async function listProjectComponents(client, projectKey) {
65
+ const path = `/rest/api/3/project/${encodeURIComponent(projectKey)}/components`;
66
+ const { data } = await client.get(path);
67
+ // The endpoint returns a bare array of components.
68
+ const components = Array.isArray(data) ? data : (data?.values || []);
69
+ const map = new Map();
70
+ for (const c of components) {
71
+ if (c && c.name && c.id != null) {
72
+ map.set(String(c.name).toLowerCase(), { id: String(c.id), name: c.name });
73
+ }
74
+ }
75
+ return map;
76
+ }
77
+
78
+ /**
79
+ * Resolve component names to { id, name } objects via the project's
80
+ * components list. Throws JiraError(2) on the first unknown name, with
81
+ * a message listing the valid component names.
82
+ *
83
+ * @param {object} client Jira client
84
+ * @param {string} projectKey Project key
85
+ * @param {string[]} names Component names to resolve
86
+ * @returns {Promise<{ id: string, name: string }[]>}
87
+ */
88
+ export async function resolveComponents(client, projectKey, names) {
89
+ if (!names || names.length === 0) return [];
90
+ const map = await listProjectComponents(client, projectKey);
91
+ const resolved = [];
92
+ for (const raw of names) {
93
+ const key = String(raw).toLowerCase();
94
+ const hit = map.get(key);
95
+ if (!hit) {
96
+ const valid = [...map.values()].map((c) => c.name).sort();
97
+ const list = valid.length > 0 ? valid.join(', ') : '(none defined on project)';
98
+ throw new JiraError(
99
+ `Unknown component "${raw}" in project ${projectKey}. Valid: ${list}`,
100
+ 2
101
+ );
102
+ }
103
+ resolved.push(hit);
104
+ }
105
+ return resolved;
106
+ }
107
+
108
+ // ── Priorities ───────────────────────────────────────────────────────
109
+
110
+ /**
111
+ * Fetch the global priority list and return a name→{ id, name } map
112
+ * keyed by lowercased name. Numeric priority IDs are also indexed so
113
+ * callers can pass an ID directly.
114
+ *
115
+ * @param {object} client Jira client
116
+ * @returns {Promise<Map<string, { id: string, name: string }>>}
117
+ */
118
+ export async function listPriorities(client) {
119
+ const { data } = await client.get('/rest/api/3/priority');
120
+ const priorities = Array.isArray(data) ? data : (data?.values || []);
121
+ const map = new Map();
122
+ for (const p of priorities) {
123
+ if (p && p.name && p.id != null) {
124
+ map.set(String(p.name).toLowerCase(), { id: String(p.id), name: p.name });
125
+ map.set(String(p.id), { id: String(p.id), name: p.name });
126
+ }
127
+ }
128
+ return map;
129
+ }
130
+
131
+ /**
132
+ * Resolve a single priority name (or numeric ID) to { id, name }.
133
+ * Throws JiraError(2) on unknown.
134
+ *
135
+ * @param {object} client Jira client
136
+ * @param {string} value Priority name (e.g. "High") or numeric id
137
+ * @returns {Promise<{ id: string, name: string }>}
138
+ */
139
+ export async function resolvePriority(client, value) {
140
+ const map = await listPriorities(client);
141
+ const hit = map.get(String(value).toLowerCase());
142
+ if (!hit) {
143
+ // Build a unique, sorted list of names for the error message.
144
+ const seen = new Set();
145
+ const names = [];
146
+ for (const v of map.values()) {
147
+ if (!seen.has(v.name)) {
148
+ seen.add(v.name);
149
+ names.push(v.name);
150
+ }
151
+ }
152
+ throw new JiraError(
153
+ `Unknown priority "${value}". Valid: ${names.sort().join(', ')}`,
154
+ 2
155
+ );
156
+ }
157
+ return hit;
158
+ }
159
+
160
+ // ── Assignee ─────────────────────────────────────────────────────────
161
+
162
+ /**
163
+ * Resolve an assignee identifier to a Jira accountId.
164
+ *
165
+ * - If the value looks like an email (contains @), search by email and
166
+ * return the matching accountId.
167
+ * - If the value is "me", fetch /myself.
168
+ * - Otherwise, treat as an accountId and pass straight through.
169
+ *
170
+ * Throws JiraError(2) if email resolution finds zero or multiple matches.
171
+ *
172
+ * @param {object} client Jira client
173
+ * @param {string} value "me", an email, or an accountId
174
+ * @returns {Promise<string>} accountId
175
+ */
176
+ export async function resolveAssignee(client, value) {
177
+ if (value === 'me') {
178
+ const { data } = await client.get('/rest/api/3/myself');
179
+ if (!data || !data.accountId) {
180
+ throw new JiraError('Failed to resolve current user accountId', 2);
181
+ }
182
+ return data.accountId;
183
+ }
184
+
185
+ if (typeof value === 'string' && value.includes('@')) {
186
+ const path = `/rest/api/3/user/search?query=${encodeURIComponent(value)}`;
187
+ const { data } = await client.get(path);
188
+ const users = Array.isArray(data) ? data : [];
189
+ if (users.length === 0) {
190
+ throw new JiraError(`No user found for email "${value}"`, 2);
191
+ }
192
+ // Prefer an exact emailAddress match; if a single result, use it.
193
+ const exact = users.find((u) => (u.emailAddress || '').toLowerCase() === value.toLowerCase());
194
+ const pick = exact || (users.length === 1 ? users[0] : null);
195
+ if (!pick) {
196
+ const candidates = users.map((u) => `${u.displayName || '?'} <${u.emailAddress || '?'}>`);
197
+ throw new JiraError(
198
+ `Ambiguous user "${value}" - multiple matches: ${candidates.join('; ')}`,
199
+ 2
200
+ );
201
+ }
202
+ if (!pick.accountId) {
203
+ throw new JiraError(`User "${value}" has no accountId`, 2);
204
+ }
205
+ return pick.accountId;
206
+ }
207
+
208
+ // Assume accountId; pass through.
209
+ return value;
210
+ }
211
+
212
+ // ── Helpers for assembling flag values into arrays ───────────────────
213
+
214
+ /**
215
+ * Coerce a repeated-flag value (string | string[] | undefined) into an
216
+ * array of strings. Returns [] for undefined or empty values.
217
+ *
218
+ * @param {string|string[]|undefined} value
219
+ * @returns {string[]}
220
+ */
221
+ export function asList(value) {
222
+ if (value == null) return [];
223
+ if (Array.isArray(value)) return value.map((v) => String(v));
224
+ return [String(value)];
225
+ }
@@ -0,0 +1,334 @@
1
+ // required-fields.mjs - Shared helpers for the sites that require extra fields on ticket create/update.
2
+ //
3
+ // Three concerns:
4
+ //
5
+ // 1. Generic --field / --field-adf parsing (lifted from prior --field work
6
+ // so create, update, and transition all share the same shape).
7
+ //
8
+ // 2. Convenience flags for common required fields (--ticket-type, --scope, --initiative).
9
+ // --affected-services <name> → resolved to customfield_<id> dynamically
10
+ // with service-registry ARI lookup
11
+ // --original-estimate <dur> → timetracking.originalEstimate
12
+ // (Ticket Type is already covered by `-t TYPE` on create.)
13
+ //
14
+ // 3. Error wrapping - when the API returns a "Please complete <X> field"
15
+ // payload, wrap the error with a hint naming the three usual culprits
16
+ // and the exact flags to set them.
17
+ //
18
+ // No live API state is cached across CLI invocations; caching is per-process
19
+ // only (a single jira CLI call may hit the API multiple times during one
20
+ // command, e.g. resolve + post).
21
+
22
+ import { JiraError } from './client.mjs';
23
+
24
+ // ── Reserved keys ────────────────────────────────────────────────────
25
+ //
26
+ // Generic --field is intentionally a back door; some fields are NOT safe to
27
+ // drop through it because the CLI manages them via dedicated, validated
28
+ // flags (labels, components, priority, assignee, etc.). Rejecting the
29
+ // reserved set forces the operator onto the validating path.
30
+
31
+ export const RESERVED_FIELD_KEYS = new Set([
32
+ 'summary',
33
+ 'description',
34
+ 'labels',
35
+ 'components',
36
+ 'priority',
37
+ 'assignee',
38
+ 'project',
39
+ 'issuetype',
40
+ 'timetracking', // managed via --original-estimate
41
+ ]);
42
+
43
+ // ── Field discovery (id ↔ name) ──────────────────────────────────────
44
+
45
+ const fieldCacheByKey = new Map(); // name (lowercased) → field id
46
+
47
+ /**
48
+ * Resolve a Jira field by display name to its id (e.g. customfield_NNNNN).
49
+ * Returns the id string; throws JiraError(2) if no match.
50
+ *
51
+ * Matching: case-insensitive exact match on the field's `name`.
52
+ *
53
+ * @param {object} client Jira client
54
+ * @param {string} fieldName Field display name (e.g. "Affected services")
55
+ * @returns {Promise<string>} Field id
56
+ */
57
+ export async function discoverFieldId(client, fieldName) {
58
+ const key = String(fieldName).toLowerCase();
59
+ if (fieldCacheByKey.has(key)) return fieldCacheByKey.get(key);
60
+
61
+ const { data } = await client.get('/rest/api/3/field');
62
+ const fields = Array.isArray(data) ? data : (data?.values || []);
63
+ for (const f of fields) {
64
+ if (f && f.name && String(f.name).toLowerCase() === key) {
65
+ fieldCacheByKey.set(key, f.id);
66
+ return f.id;
67
+ }
68
+ }
69
+
70
+ throw new JiraError(
71
+ `Unknown Jira field "${fieldName}" - not found in /rest/api/3/field`,
72
+ 2
73
+ );
74
+ }
75
+
76
+ // Test-only: reset the cache between tests. Not exported publicly elsewhere.
77
+ export function _resetFieldCacheForTests() {
78
+ fieldCacheByKey.clear();
79
+ }
80
+
81
+ // ── Service-registry ARI resolution (Affected Services) ──────────────
82
+
83
+ const serviceAriCache = new Map(); // service name (lowercased) → ari
84
+
85
+ /**
86
+ * Resolve an "Affected services" value (a service display name) to its ARI.
87
+ *
88
+ * The Affected Services field is a `service-entity-field` array; values are
89
+ * Atlassian Resource Identifiers (ARIs) of services in the workspace's
90
+ * service registry. We resolve by querying the autocomplete endpoint:
91
+ *
92
+ * GET /rest/service-registry-api/service?query=<name>
93
+ *
94
+ * and matching the response's `name` field (case-insensitive exact). If the
95
+ * caller passes a value that already looks like an ARI, we pass it through
96
+ * unmodified - useful when scripting against known IDs.
97
+ *
98
+ * @param {object} client Jira client
99
+ * @param {string} name Service display name OR an existing ARI
100
+ * @returns {Promise<string>} ARI string
101
+ */
102
+ export async function resolveAffectedServiceAri(client, name) {
103
+ const value = String(name);
104
+
105
+ // Already an ARI? Pass through.
106
+ if (value.startsWith('ari:')) return value;
107
+
108
+ const key = value.toLowerCase();
109
+ if (serviceAriCache.has(key)) return serviceAriCache.get(key);
110
+
111
+ const path = `/rest/service-registry-api/service?query=${encodeURIComponent(value)}`;
112
+ const { data } = await client.get(path);
113
+ const services = Array.isArray(data?.services) ? data.services : (Array.isArray(data) ? data : []);
114
+
115
+ // Prefer exact case-insensitive name match.
116
+ const exact = services.find((s) => s && s.name && String(s.name).toLowerCase() === key);
117
+ if (exact && exact.id) {
118
+ serviceAriCache.set(key, exact.id);
119
+ return exact.id;
120
+ }
121
+
122
+ // No exact hit - list candidates to help the user.
123
+ const candidates = services.map((s) => s?.name).filter(Boolean).slice(0, 10);
124
+ const list = candidates.length > 0 ? candidates.join(', ') : '(none returned)';
125
+ throw new JiraError(
126
+ `Unknown Affected service "${name}". Candidates from registry: ${list}`,
127
+ 2
128
+ );
129
+ }
130
+
131
+ // Test-only.
132
+ export function _resetServiceAriCacheForTests() {
133
+ serviceAriCache.clear();
134
+ }
135
+
136
+ // ── Generic --field / --field-adf parsing ────────────────────────────
137
+
138
+ /**
139
+ * Parse a single `--field K=V` argument into `{ key, value }`.
140
+ *
141
+ * V is auto-detected:
142
+ * - if it parses as JSON and the result is an object/array/number/boolean,
143
+ * it's used as-is;
144
+ * - otherwise it's used as a plain string.
145
+ *
146
+ * `K` cannot be a member of RESERVED_FIELD_KEYS - those are managed by
147
+ * dedicated flags and must not be set via the generic back door.
148
+ *
149
+ * @param {string} raw e.g. "customfield_12345=foo" or 'customfield_12345={"value":"x"}'
150
+ * @returns {{ key: string, value: any }}
151
+ * @throws {JiraError} exitCode 1 on bad shape or reserved key
152
+ */
153
+ export function parseFieldFlag(raw) {
154
+ if (typeof raw !== 'string' || raw.length === 0) {
155
+ throw new JiraError(`--field requires K=V (got empty value)`, 1);
156
+ }
157
+ const eq = raw.indexOf('=');
158
+ if (eq <= 0) {
159
+ throw new JiraError(`--field "${raw}" must be of the form K=V`, 1);
160
+ }
161
+ const key = raw.slice(0, eq).trim();
162
+ const rawValue = raw.slice(eq + 1);
163
+
164
+ if (!key) {
165
+ throw new JiraError(`--field "${raw}" has empty key`, 1);
166
+ }
167
+ if (RESERVED_FIELD_KEYS.has(key)) {
168
+ throw new JiraError(
169
+ `--field "${key}" is reserved; use the dedicated flag instead ` +
170
+ `(e.g. -s/-d/--priority/--add-label/--original-estimate)`,
171
+ 1
172
+ );
173
+ }
174
+
175
+ // Try JSON first; fall back to string.
176
+ let value = rawValue;
177
+ const trimmed = rawValue.trim();
178
+ if (
179
+ trimmed.startsWith('{') ||
180
+ trimmed.startsWith('[') ||
181
+ trimmed === 'true' || trimmed === 'false' || trimmed === 'null' ||
182
+ /^-?\d+(\.\d+)?$/.test(trimmed)
183
+ ) {
184
+ try {
185
+ value = JSON.parse(trimmed);
186
+ } catch {
187
+ // Not JSON - keep as string.
188
+ }
189
+ }
190
+
191
+ return { key, value };
192
+ }
193
+
194
+ /**
195
+ * Parse a single `--field-adf K=text` argument. The text is converted to
196
+ * ADF (via the caller-provided `textToAdf`) and packaged the same way as
197
+ * --field. Reserved keys are rejected for the same reasons.
198
+ *
199
+ * @param {string} raw K=text
200
+ * @param {Function} textToAdf Conversion function (injected for testability)
201
+ * @returns {{ key: string, value: object }}
202
+ */
203
+ export function parseFieldAdfFlag(raw, textToAdf) {
204
+ if (typeof raw !== 'string' || raw.length === 0) {
205
+ throw new JiraError(`--field-adf requires K=text (got empty value)`, 1);
206
+ }
207
+ const eq = raw.indexOf('=');
208
+ if (eq <= 0) {
209
+ throw new JiraError(`--field-adf "${raw}" must be of the form K=text`, 1);
210
+ }
211
+ const key = raw.slice(0, eq).trim();
212
+ const text = raw.slice(eq + 1);
213
+
214
+ if (!key) {
215
+ throw new JiraError(`--field-adf "${raw}" has empty key`, 1);
216
+ }
217
+ if (RESERVED_FIELD_KEYS.has(key)) {
218
+ throw new JiraError(
219
+ `--field-adf "${key}" is reserved; use the dedicated flag instead`,
220
+ 1
221
+ );
222
+ }
223
+
224
+ return { key, value: textToAdf(text) };
225
+ }
226
+
227
+ // ── Required-field patch builder ─────────────────────────────────────
228
+
229
+ /**
230
+ * Build the `fields` object increments contributed by the new flags
231
+ * (--field / --field-adf / --affected-services / --original-estimate).
232
+ *
233
+ * Caller passes in:
234
+ * - args: parsed CLI args (output of parseArgs)
235
+ * - client: Jira client (used for field-id / service-ARI resolution)
236
+ * - textToAdf: ADF conversion fn (injected to keep this module decoupled)
237
+ *
238
+ * Returns `{ fields }` - an object suitable for merging into the existing
239
+ * `fields` payload on create / update / transition.
240
+ *
241
+ * Throws JiraError on bad shape or resolution failure.
242
+ *
243
+ * @param {object} args
244
+ * @param {object} client
245
+ * @param {Function} textToAdf
246
+ * @returns {Promise<{ fields: object, changed: string[] }>}
247
+ */
248
+ export async function buildRequiredFieldsPatch(args, client, textToAdf) {
249
+ const fields = {};
250
+ const changed = [];
251
+
252
+ // --field K=V (repeatable). asList-style coercion.
253
+ const rawFields = toArray(args.field);
254
+ for (const raw of rawFields) {
255
+ const { key, value } = parseFieldFlag(raw);
256
+ fields[key] = value;
257
+ changed.push(`field:${key}`);
258
+ }
259
+
260
+ // --field-adf K=text (repeatable).
261
+ const rawAdfFields = toArray(args['field-adf']);
262
+ for (const raw of rawAdfFields) {
263
+ const { key, value } = parseFieldAdfFlag(raw, textToAdf);
264
+ fields[key] = value;
265
+ changed.push(`field-adf:${key}`);
266
+ }
267
+
268
+ // --affected-services <name> (single, by current Jira field cardinality).
269
+ // We accept repeated flags too in case the field's cardinality ever changes
270
+ // - the cost of treating it as a list is the same and it's forward-safe.
271
+ const services = toArray(args['affected-services']);
272
+ if (services.length > 0) {
273
+ const fieldId = await discoverFieldId(client, 'Affected services');
274
+ const aris = [];
275
+ for (const s of services) {
276
+ const ari = await resolveAffectedServiceAri(client, s);
277
+ aris.push({ id: ari });
278
+ }
279
+ fields[fieldId] = aris;
280
+ changed.push('affected-services');
281
+ }
282
+
283
+ // --original-estimate <duration>. Duration must be a Jira time string.
284
+ if (args['original-estimate']) {
285
+ const duration = String(args['original-estimate']);
286
+ fields.timetracking = { originalEstimate: duration };
287
+ changed.push('original-estimate');
288
+ }
289
+
290
+ return { fields, changed };
291
+ }
292
+
293
+ function toArray(v) {
294
+ if (v == null) return [];
295
+ if (Array.isArray(v)) return v.map((x) => String(x));
296
+ return [String(v)];
297
+ }
298
+
299
+ // ── "Please complete X field" error wrapping ─────────────────────────
300
+
301
+ const COMPLETE_FIELD_RE = /Please complete (?:the )?(.+?) field/i;
302
+
303
+ /**
304
+ * If a JiraError is your site's "Please complete X field" gate,
305
+ * return a wrapped message that names the three usual culprits and the
306
+ * exact flag to set each. Otherwise return null (caller should re-raise
307
+ * the original).
308
+ *
309
+ * @param {Error|JiraError} err
310
+ * @returns {string|null} wrapped message, or null
311
+ */
312
+ export function maybeWrapRequiredFieldError(err) {
313
+ if (!err || typeof err.message !== 'string') return null;
314
+ if (!COMPLETE_FIELD_RE.test(err.message)) return null;
315
+
316
+ const lines = [
317
+ err.message,
318
+ '',
319
+ 'Hint: your site requires these fields populated before',
320
+ 'transitioning past Qualification:',
321
+ ' - Ticket Type - `--field customfield_11535=\'{"value":"Tech Roadmap"}\'`',
322
+ ' (select customfield; valid options include',
323
+ ' "Tech Roadmap", "Technical Debt", "Product Roadmap",',
324
+ ' "Incident Response", "Non Development Tasks";',
325
+ ' distinct from -t issuetype on `create`)',
326
+ ' - Affected Services - `--affected-services "Artificial Intelligence Services"`',
327
+ ' (or e.g. `--affected-services "WESPA (Compass ARI)"`)',
328
+ ' - Original Estimate - `--original-estimate 8h` (Jira duration syntax)',
329
+ '',
330
+ 'Re-run the same command with the missing flag, or backfill via',
331
+ '`jira update <KEY> --original-estimate 8h --affected-services "..."`.',
332
+ ];
333
+ return lines.join('\n');
334
+ }