@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.
- package/README.md +12 -2
- package/bin/confluence +9 -0
- package/bin/confluence-fetch +9 -0
- package/bin/jira +9 -0
- package/dist/engineering-pa-core/knowledge/content-voice/banned-ai-tells-taxonomy.md +1 -1
- package/dist/engineering-pa-core/knowledge/knowledge-memory/staging-and-observations.md +3 -3
- package/dist/engineering-pa-core/knowledge/platform-tooling/workspace-extensions.md +62 -0
- package/dist/engineering-pa-core-extensions-atlassian/README.md +81 -16
- package/dist/engineering-pa-core-extensions-atlassian/bin/confluence +2 -0
- package/dist/engineering-pa-core-extensions-atlassian/bin/confluence-fetch +2 -0
- package/dist/engineering-pa-core-extensions-atlassian/bin/jira +2 -0
- package/dist/engineering-pa-core-extensions-atlassian/config/jira-workflow-chains.default.json +4 -0
- package/dist/engineering-pa-core-extensions-atlassian/index.mjs +17 -6
- package/dist/engineering-pa-core-extensions-atlassian/package.json +11 -4
- package/dist/engineering-pa-core-extensions-atlassian/src/common/invalidate-client.mjs +137 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/common/pa-name.mjs +58 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/common/secrets.mjs +158 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/cli.mjs +165 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/client.mjs +294 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/auth.mjs +59 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/comment.mjs +174 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/create.mjs +136 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/label.mjs +98 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mine.mjs +105 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-folder.mjs +487 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-page.mjs +351 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/page.mjs +127 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/pages.mjs +107 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/recent.mjs +107 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/search.mjs +123 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/spaces.mjs +82 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/test.mjs +155 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/tree.mjs +103 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/update.mjs +134 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/format.mjs +260 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/mirror.mjs +353 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/storage.mjs +880 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence-fetch/cli.mjs +313 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence-fetch/fetch.mjs +327 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/adf.mjs +388 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/bug-fields.mjs +294 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/cli.mjs +197 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/client.mjs +350 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/advance.mjs +486 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/attach.mjs +129 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/auth.mjs +53 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/boards.mjs +88 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/bulk-transition.mjs +223 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/cancel.mjs +135 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/comment.mjs +169 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/create.mjs +376 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/issue.mjs +105 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/link.mjs +204 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/mine.mjs +97 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/projects.mjs +91 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/search.mjs +80 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sprint.mjs +111 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sync-statuses.mjs +313 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/test.mjs +142 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/transition.mjs +197 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/update.mjs +284 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/worklog.mjs +380 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/format.mjs +267 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/invalidate.mjs +72 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/metadata.mjs +225 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/required-fields.mjs +334 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-chains.mjs +405 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-map.mjs +107 -0
- package/dist/engineering-pa-scaffold/README.md +32 -1
- package/dist/engineering-pa-scaffold/src/cli.mjs +83 -6
- package/dist/engineering-pa-scaffold/src/commands/init-workspace-extension.mjs +234 -0
- package/dist/engineering-pa-scaffold/src/commands/init.mjs +6 -5
- package/dist/engineering-pa-scaffold/src/generator.mjs +240 -4
- package/dist/engineering-pa-scaffold/templates/CLAUDE.md.template +7 -7
- package/dist/engineering-pa-scaffold/templates/operator-README.md.template +4 -4
- package/dist/engineering-pa-scaffold/templates/root-CLAUDE.md.template +13 -0
- package/dist/engineering-pa-types-engineering/index.mjs +14 -6
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/create-a-small-app.md +81 -0
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/for-agents.md +25 -0
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/index.md +34 -0
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/reference.md +16 -0
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/wire-your-agent.md +17 -0
- package/dist/engineering-pa-types-engineering/package.json +2 -2
- package/dist/engineering-pa-types-engineering/roles/rcf-lite-engineer/definition.md +183 -0
- package/dist/engineering-pa-workspace/app/home.html +7 -7
- package/dist/engineering-pa-workspace/app/onboarding.html +4 -4
- package/dist/engineering-pa-workspace/src/api.mjs +1 -1
- package/dist/engineering-pa-workspace/src/server.mjs +1 -1
- package/docs/installing.md +78 -0
- package/docs/onboarding-solo.md +162 -0
- package/docs/what-it-can-do.md +85 -0
- 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
|
+
}
|