@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,486 @@
|
|
|
1
|
+
// advance.mjs - Walk an issue along its configured workflow chain.
|
|
2
|
+
//
|
|
3
|
+
// `jira transition` moves one step and needs the exact transition verb.
|
|
4
|
+
// Some Jira workflows are long chains whose verbs do not match
|
|
5
|
+
// the statuses they land on ("For Merge" → Merge, "Testing Successful" →
|
|
6
|
+
// Ready for PROD), so advancing a ticket several states used to mean
|
|
7
|
+
// probing transitions one hop at a time. `jira advance` computes the
|
|
8
|
+
// path from the chain config (the tool-shipped default, or an operator
|
|
9
|
+
// override at operator/config/jira-workflow-chains.json) and executes
|
|
10
|
+
// every hop, verifying the landing status after each.
|
|
11
|
+
//
|
|
12
|
+
// Gated steps (the AI workflow validates required fields when leaving
|
|
13
|
+
// Qualification, but its transition screen rejects field flags) are
|
|
14
|
+
// honoured by applying the fields via issue update BEFORE transitioning.
|
|
15
|
+
//
|
|
16
|
+
// Three routing modes, from the workflow-chain design:
|
|
17
|
+
//
|
|
18
|
+
// --next one forward edge, never a shortcut. "Move it on."
|
|
19
|
+
// --close shortest route to Done, shortcuts included. "Close it."
|
|
20
|
+
// <TARGET> route to a named status, shortcuts avoided unless the
|
|
21
|
+
// target is otherwise unreachable.
|
|
22
|
+
//
|
|
23
|
+
// Issue types whose status is an operator judgement rather than a
|
|
24
|
+
// mechanical consequence of the work (Epics) declare a consent policy in
|
|
25
|
+
// the chain config and are refused without --operator-consent.
|
|
26
|
+
|
|
27
|
+
import { createClient, JiraError } from '../client.mjs';
|
|
28
|
+
import { textToAdf } from '../adf.mjs';
|
|
29
|
+
import { bold, green, yellow, dim, cyan } from '../format.mjs';
|
|
30
|
+
import {
|
|
31
|
+
buildRequiredFieldsPatch,
|
|
32
|
+
maybeWrapRequiredFieldError,
|
|
33
|
+
discoverFieldId,
|
|
34
|
+
} from '../required-fields.mjs';
|
|
35
|
+
import {
|
|
36
|
+
loadChains,
|
|
37
|
+
chainsSearchPaths,
|
|
38
|
+
getChainConfig,
|
|
39
|
+
getChainEdges,
|
|
40
|
+
getConsentPolicy,
|
|
41
|
+
getTicketTypeFieldId,
|
|
42
|
+
listConfiguredIssueTypes,
|
|
43
|
+
chainStatuses,
|
|
44
|
+
resolveTargetStatus,
|
|
45
|
+
resolveDoneStatus,
|
|
46
|
+
computePath,
|
|
47
|
+
computeNextEdge,
|
|
48
|
+
isShortcutEdge,
|
|
49
|
+
reachableStatuses,
|
|
50
|
+
} from '../workflow-chains.mjs';
|
|
51
|
+
import { notifyWorkspaceAsync } from '../invalidate.mjs';
|
|
52
|
+
|
|
53
|
+
const HELP = `jira advance - Walk an issue along its configured workflow chain
|
|
54
|
+
|
|
55
|
+
Usage:
|
|
56
|
+
jira advance <KEY> <TARGET-STATUS> [--dry-run] [FIELD FLAGS]
|
|
57
|
+
jira advance <KEY> --next [--dry-run] [FIELD FLAGS]
|
|
58
|
+
jira advance <KEY> --close [--dry-run] [FIELD FLAGS]
|
|
59
|
+
|
|
60
|
+
Arguments:
|
|
61
|
+
KEY Issue key (e.g. AI-123)
|
|
62
|
+
TARGET-STATUS Status to advance to (case-insensitive; unique partial
|
|
63
|
+
match accepted, e.g. "peer" for "Peer review")
|
|
64
|
+
|
|
65
|
+
Modes (exactly one; a target status and a mode flag are exclusive):
|
|
66
|
+
<TARGET-STATUS> Route to a named status. Shortcut edges are avoided so
|
|
67
|
+
the ticket passes through the states you asked for;
|
|
68
|
+
they are used only if the target is otherwise
|
|
69
|
+
unreachable.
|
|
70
|
+
--next Advance exactly ONE forward step - the next logical
|
|
71
|
+
status. Never takes a shortcut edge, so it cannot skip
|
|
72
|
+
review/merge states. Refuses when the current status
|
|
73
|
+
has more than one forward step (name the target).
|
|
74
|
+
--close Take the most direct encoded route to Done, shortcut
|
|
75
|
+
edges included.
|
|
76
|
+
|
|
77
|
+
Options:
|
|
78
|
+
--dry-run Print the planned path and gate requirements
|
|
79
|
+
without executing anything. Exits 0 when the
|
|
80
|
+
plan is executable, 1 when gate fields are
|
|
81
|
+
missing.
|
|
82
|
+
--operator-consent Required for issue types whose chain declares
|
|
83
|
+
a consent policy (Epics). Pass it only when
|
|
84
|
+
the operator has explicitly asked for this
|
|
85
|
+
transition.
|
|
86
|
+
--ticket-type VALUE Set the Ticket Type select customfield (e.g.
|
|
87
|
+
"Tech Roadmap", "Incident Response") - required
|
|
88
|
+
by gated steps when not already populated.
|
|
89
|
+
--affected-services NAME Set Affected services (resolved to ARI).
|
|
90
|
+
--original-estimate DUR Set timetracking.originalEstimate ("2h", "1d").
|
|
91
|
+
--field K=V Set an arbitrary field via update (repeatable).
|
|
92
|
+
--field-adf K=text Set an ADF-typed field via update (repeatable).
|
|
93
|
+
|
|
94
|
+
Exit codes: 0 success, 1 usage/gate, 2 chain or state problem,
|
|
95
|
+
4 refused for want of operator consent.
|
|
96
|
+
|
|
97
|
+
The chain is data, not code: jira-workflow-chains.json maps project + issue
|
|
98
|
+
type to a list of forward edges. The tool ships a default; an estate can
|
|
99
|
+
override it at operator/config/jira-workflow-chains.json, which takes
|
|
100
|
+
precedence when present. Gated edges declare fields that must be populated
|
|
101
|
+
BEFORE the transition (the transition screen rejects them); advance applies
|
|
102
|
+
supplied field flags via issue update first, then walks the chain, verifying
|
|
103
|
+
the landing status after every hop.
|
|
104
|
+
|
|
105
|
+
Examples:
|
|
106
|
+
jira advance AI-123 --next
|
|
107
|
+
jira advance AI-123 --close --dry-run
|
|
108
|
+
jira advance AI-123 "In Progress"
|
|
109
|
+
jira advance AI-123 --close --ticket-type "Tech Roadmap" --original-estimate 2h
|
|
110
|
+
jira advance AI-1578 --close --operator-consent
|
|
111
|
+
`;
|
|
112
|
+
|
|
113
|
+
// Exit code for a transition refused because the issue type's chain
|
|
114
|
+
// declares a consent policy. Distinct from 1 (usage) and 2 (chain/state)
|
|
115
|
+
// so a caller can tell "you passed bad args" from "you are not allowed to
|
|
116
|
+
// do this without being asked to".
|
|
117
|
+
const EXIT_CONSENT_REQUIRED = 4;
|
|
118
|
+
|
|
119
|
+
// Gate field kinds → how each is satisfied. `flag` is what the operator
|
|
120
|
+
// passes; `describe` names the field in errors.
|
|
121
|
+
const GATE_FIELD_KINDS = {
|
|
122
|
+
ticketType: { flag: '--ticket-type "<option>"', describe: 'Ticket Type' },
|
|
123
|
+
affectedServices: { flag: '--affected-services "<service>"', describe: 'Affected Services' },
|
|
124
|
+
originalEstimate: { flag: '--original-estimate <dur>', describe: 'Original Estimate' },
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Run the advance command.
|
|
129
|
+
*
|
|
130
|
+
* @param {string[]} argv Remaining args after 'advance'
|
|
131
|
+
* @param {object} [deps] Dependency overrides (client, chains, log, logError,
|
|
132
|
+
* notifyWorkspace)
|
|
133
|
+
* @returns {Promise<number>} Exit code
|
|
134
|
+
*/
|
|
135
|
+
export async function run(argv = [], deps = {}) {
|
|
136
|
+
const log = deps.log || console.log;
|
|
137
|
+
const logError = deps.logError || console.error;
|
|
138
|
+
const { parseArgs } = await import('../cli.mjs');
|
|
139
|
+
const args = parseArgs(argv);
|
|
140
|
+
|
|
141
|
+
if (args.help || args.h) {
|
|
142
|
+
log(HELP);
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const key = args._[0];
|
|
147
|
+
const targetInput = args._.slice(1).join(' ');
|
|
148
|
+
const dryRun = Boolean(args['dry-run']);
|
|
149
|
+
const wantsNext = Boolean(args.next);
|
|
150
|
+
const wantsClose = Boolean(args.close);
|
|
151
|
+
const hasConsent = Boolean(args['operator-consent']);
|
|
152
|
+
|
|
153
|
+
// ── Mode selection ─────────────────────────────────────────────────
|
|
154
|
+
// Exactly one of: named target, --next, --close. Nothing is inferred
|
|
155
|
+
// from a missing argument - an omitted target is ambiguous input, and
|
|
156
|
+
// this command mutates, so it asks rather than guesses.
|
|
157
|
+
if (!key) {
|
|
158
|
+
logUsage(logError);
|
|
159
|
+
return 1;
|
|
160
|
+
}
|
|
161
|
+
if (wantsNext && wantsClose) {
|
|
162
|
+
logError('jira advance: --next and --close are mutually exclusive.');
|
|
163
|
+
logError(' --next advances one step; --close goes all the way to Done.');
|
|
164
|
+
return 1;
|
|
165
|
+
}
|
|
166
|
+
if ((wantsNext || wantsClose) && targetInput) {
|
|
167
|
+
const flag = wantsNext ? '--next' : '--close';
|
|
168
|
+
logError(`jira advance: ${flag} takes no target status (got "${targetInput}").`);
|
|
169
|
+
logError('Pass a target status OR a mode flag, not both.');
|
|
170
|
+
return 1;
|
|
171
|
+
}
|
|
172
|
+
if (!wantsNext && !wantsClose && !targetInput) {
|
|
173
|
+
logError('jira advance: no target status and no mode flag.');
|
|
174
|
+
logError(' jira advance <KEY> <TARGET-STATUS> route to a named status');
|
|
175
|
+
logError(' jira advance <KEY> --next advance one step');
|
|
176
|
+
logError(' jira advance <KEY> --close go to Done, shortcuts included');
|
|
177
|
+
return 1;
|
|
178
|
+
}
|
|
179
|
+
const mode = wantsNext ? 'next' : wantsClose ? 'close' : 'target';
|
|
180
|
+
|
|
181
|
+
// ── Load chain config ──────────────────────────────────────────────
|
|
182
|
+
let chains = deps.chains;
|
|
183
|
+
if (!chains) {
|
|
184
|
+
try {
|
|
185
|
+
chains = await loadChains(deps.projectRoot);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
logError(`jira advance: cannot load workflow chain config: ${err.message}`);
|
|
188
|
+
logError('Searched, in order:');
|
|
189
|
+
for (const p of chainsSearchPaths(deps.projectRoot)) logError(` - ${p}`);
|
|
190
|
+
return 2;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const client = deps.client || createClient();
|
|
195
|
+
const ticketTypeFieldId = getTicketTypeFieldId(chains);
|
|
196
|
+
const projectKey = String(key).split('-')[0].toUpperCase();
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
// ── Current state ────────────────────────────────────────────────
|
|
200
|
+
const { data: issueData } = await client.get(
|
|
201
|
+
`/rest/api/3/issue/${encodeURIComponent(key)}?fields=status,issuetype`
|
|
202
|
+
);
|
|
203
|
+
const currentStatus = issueData.fields?.status?.name;
|
|
204
|
+
const issueType = issueData.fields?.issuetype?.name;
|
|
205
|
+
if (!currentStatus || !issueType) {
|
|
206
|
+
logError(`jira advance: could not read status/issuetype for ${key}`);
|
|
207
|
+
return 2;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Chain lookup ─────────────────────────────────────────────────
|
|
211
|
+
const chainConfig = getChainConfig(chains, projectKey, issueType);
|
|
212
|
+
const edges = getChainEdges(chains, projectKey, issueType);
|
|
213
|
+
if (!edges) {
|
|
214
|
+
const configured = listConfiguredIssueTypes(chains, projectKey);
|
|
215
|
+
logError(`jira advance: no workflow chain configured for project ${projectKey} issue type "${issueType}".`);
|
|
216
|
+
if (configured.length > 0) {
|
|
217
|
+
logError(`Configured issue types for ${projectKey}: ${configured.join(', ')}`);
|
|
218
|
+
} else {
|
|
219
|
+
logError(`No chains configured for project ${projectKey}.`);
|
|
220
|
+
}
|
|
221
|
+
logError('Add the chain to the shipped jira-workflow-chains.json (or an operator override), or use `jira transition` directly.');
|
|
222
|
+
return 2;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── Route (per mode) ─────────────────────────────────────────────
|
|
226
|
+
let target;
|
|
227
|
+
let path;
|
|
228
|
+
|
|
229
|
+
if (mode === 'next') {
|
|
230
|
+
let edge;
|
|
231
|
+
try {
|
|
232
|
+
edge = computeNextEdge(edges, currentStatus);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
// More than one forward step - the operator has to choose.
|
|
235
|
+
logError(`jira advance --next: ${err.message}`);
|
|
236
|
+
return 1;
|
|
237
|
+
}
|
|
238
|
+
if (!edge) {
|
|
239
|
+
logError(`jira advance --next: "${currentStatus}" has no forward step in the ${projectKey} ${issueType} chain.`);
|
|
240
|
+
logError(`Chain statuses: ${chainStatuses(edges).join(' → ')}`);
|
|
241
|
+
logError('The status may be terminal, or off-chain (e.g. Product Backlog) - use `jira transition`.');
|
|
242
|
+
return 2;
|
|
243
|
+
}
|
|
244
|
+
target = edge.to;
|
|
245
|
+
path = [edge];
|
|
246
|
+
} else if (mode === 'close') {
|
|
247
|
+
target = resolveDoneStatus(edges, chainConfig);
|
|
248
|
+
if (!target) {
|
|
249
|
+
logError(`jira advance --close: cannot determine the Done status for the ${projectKey} ${issueType} chain.`);
|
|
250
|
+
logError(`Chain statuses: ${chainStatuses(edges).join(' → ')}`);
|
|
251
|
+
logError('Add a "doneStatus" to the issue type in jira-workflow-chains.json, or name the target status explicitly.');
|
|
252
|
+
return 2;
|
|
253
|
+
}
|
|
254
|
+
path = computePath(edges, currentStatus, target, { allowShortcuts: true });
|
|
255
|
+
} else {
|
|
256
|
+
try {
|
|
257
|
+
target = resolveTargetStatus(edges, targetInput);
|
|
258
|
+
} catch (err) {
|
|
259
|
+
logError(`jira advance: ${err.message}`);
|
|
260
|
+
return 1;
|
|
261
|
+
}
|
|
262
|
+
if (!target) {
|
|
263
|
+
logError(`jira advance: "${targetInput}" is not a status in the ${projectKey} ${issueType} chain.`);
|
|
264
|
+
logError(`Chain statuses: ${chainStatuses(edges).join(' → ')}`);
|
|
265
|
+
return 1;
|
|
266
|
+
}
|
|
267
|
+
// Shortcut-free first: a named target should be reached through the
|
|
268
|
+
// states the operator asked to pass through. Fall back to shortcuts
|
|
269
|
+
// only when there is no other way there.
|
|
270
|
+
path = computePath(edges, currentStatus, target);
|
|
271
|
+
if (path === null) {
|
|
272
|
+
path = computePath(edges, currentStatus, target, { allowShortcuts: true });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (path === null) {
|
|
277
|
+
const reachable = reachableStatuses(edges, currentStatus);
|
|
278
|
+
logError(`jira advance: cannot reach "${target}" from "${currentStatus}" via the configured ${issueType} chain.`);
|
|
279
|
+
if (reachable.length > 0) {
|
|
280
|
+
logError(`Reachable from ${currentStatus}: ${reachable.join(', ')}`);
|
|
281
|
+
} else {
|
|
282
|
+
logError(`"${currentStatus}" has no forward edges in the chain (it may be off-chain, e.g. Product Backlog).`);
|
|
283
|
+
logError('Use `jira transition` to bring the issue back onto the chain.');
|
|
284
|
+
}
|
|
285
|
+
return 2;
|
|
286
|
+
}
|
|
287
|
+
if (path.length === 0) {
|
|
288
|
+
log(green(`${bold(key)} is already at ${currentStatus} - nothing to do.`));
|
|
289
|
+
return 0;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── Operator-consent guard ───────────────────────────────────────
|
|
293
|
+
// Some issue types' status is a judgement, not a consequence. The
|
|
294
|
+
// chain config says which, and why; refuse rather than infer. Placed
|
|
295
|
+
// after the no-op check so "already there" stays a harmless no-op.
|
|
296
|
+
const consent = getConsentPolicy(chainConfig);
|
|
297
|
+
if (consent.required && !hasConsent) {
|
|
298
|
+
logError(yellow(`jira advance: refusing to transition ${key} - ${issueType} transitions need explicit operator consent.`));
|
|
299
|
+
if (consent.reason) logError(`Why: ${consent.reason}`);
|
|
300
|
+
logError(`Refused plan: ${currentStatus} → ${target} (${path.length} step(s)).`);
|
|
301
|
+
logError('If - and only if - the operator has asked for this specific transition, re-run with --operator-consent.');
|
|
302
|
+
logError('Do not pass it because the children are done, the sprint closed, or the plan says so. Ask.');
|
|
303
|
+
return EXIT_CONSENT_REQUIRED;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── Gate analysis ────────────────────────────────────────────────
|
|
307
|
+
// Union of gate field kinds across the path.
|
|
308
|
+
const gateKinds = [];
|
|
309
|
+
for (const edge of path) {
|
|
310
|
+
for (const kind of edge.gate?.fields || []) {
|
|
311
|
+
if (!gateKinds.includes(kind)) gateKinds.push(kind);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Fields supplied via flags (applied via update before walking).
|
|
316
|
+
const supplied = await buildRequiredFieldsPatch(args, client, textToAdf);
|
|
317
|
+
if (args['ticket-type']) {
|
|
318
|
+
supplied.fields[ticketTypeFieldId] = { value: String(args['ticket-type']) };
|
|
319
|
+
supplied.changed.push('ticket-type');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// For each gate kind: satisfied by a supplied flag, or by a value
|
|
323
|
+
// already on the issue. Only fetch issue field values when gates exist.
|
|
324
|
+
const missing = [];
|
|
325
|
+
if (gateKinds.length > 0) {
|
|
326
|
+
const fieldIdByKind = { ticketType: ticketTypeFieldId, originalEstimate: 'timetracking' };
|
|
327
|
+
if (gateKinds.includes('affectedServices')) {
|
|
328
|
+
fieldIdByKind.affectedServices = await discoverFieldId(client, 'Affected services');
|
|
329
|
+
}
|
|
330
|
+
const wanted = gateKinds.map((k) => fieldIdByKind[k]).filter(Boolean);
|
|
331
|
+
const { data: gateData } = await client.get(
|
|
332
|
+
`/rest/api/3/issue/${encodeURIComponent(key)}?fields=${wanted.join(',')}`
|
|
333
|
+
);
|
|
334
|
+
const existing = gateData.fields || {};
|
|
335
|
+
|
|
336
|
+
for (const kind of gateKinds) {
|
|
337
|
+
if (isGateSatisfiedByFlags(kind, supplied.fields, fieldIdByKind)) continue;
|
|
338
|
+
if (isGateSatisfiedOnIssue(kind, existing, fieldIdByKind)) continue;
|
|
339
|
+
missing.push(kind);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ── Dry run ──────────────────────────────────────────────────────
|
|
344
|
+
if (dryRun) {
|
|
345
|
+
log(bold(`${key} (${issueType}): ${currentStatus} → ${target} - ${path.length} step(s)`) + dim(` [mode: ${mode}]`));
|
|
346
|
+
path.forEach((edge, i) => {
|
|
347
|
+
const notes = [];
|
|
348
|
+
if (edge.gate) notes.push(yellow('[gated: update-before-transition]'));
|
|
349
|
+
if (isShortcutEdge(edge)) {
|
|
350
|
+
const skips = (edge.skips || []).join(', ');
|
|
351
|
+
notes.push(yellow(`[shortcut${skips ? `: skips ${skips}` : ''}]`));
|
|
352
|
+
}
|
|
353
|
+
const suffix = notes.length > 0 ? ` ${notes.join(' ')}` : '';
|
|
354
|
+
log(` ${i + 1}. ${edge.from} ${dim('--(')}${cyan(edge.transition)}${dim(')-->')} ${edge.to}${suffix}`);
|
|
355
|
+
});
|
|
356
|
+
if (supplied.changed.length > 0) {
|
|
357
|
+
log(`Fields to set via update first: ${supplied.changed.join(', ')}`);
|
|
358
|
+
}
|
|
359
|
+
if (missing.length > 0) {
|
|
360
|
+
logError(yellow('Gate fields missing - supply before this plan can execute:'));
|
|
361
|
+
for (const kind of missing) {
|
|
362
|
+
const info = GATE_FIELD_KINDS[kind] || { flag: kind, describe: kind };
|
|
363
|
+
logError(` - ${bold(info.describe)}: ${info.flag}`);
|
|
364
|
+
}
|
|
365
|
+
return 1;
|
|
366
|
+
}
|
|
367
|
+
log(dim('Dry run - nothing executed.'));
|
|
368
|
+
return 0;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ── Missing gate fields block execution ──────────────────────────
|
|
372
|
+
if (missing.length > 0) {
|
|
373
|
+
logError(yellow(`Cannot advance ${key}: gated step(s) on the path require fields that are neither set on the issue nor supplied:`));
|
|
374
|
+
for (const kind of missing) {
|
|
375
|
+
const info = GATE_FIELD_KINDS[kind] || { flag: kind, describe: kind };
|
|
376
|
+
logError(` - ${bold(info.describe)}: ${info.flag}`);
|
|
377
|
+
}
|
|
378
|
+
logError('These fields cannot be set on the transition itself - advance applies them via update first.');
|
|
379
|
+
return 1;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ── Apply supplied fields via update BEFORE transitioning ────────
|
|
383
|
+
if (Object.keys(supplied.fields).length > 0) {
|
|
384
|
+
await client.put(
|
|
385
|
+
`/rest/api/3/issue/${encodeURIComponent(key)}`,
|
|
386
|
+
{ fields: supplied.fields }
|
|
387
|
+
);
|
|
388
|
+
log(`${bold(key)}: fields set via update - ${supplied.changed.join(', ')}`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ── Walk the chain ───────────────────────────────────────────────
|
|
392
|
+
for (const edge of path) {
|
|
393
|
+
const { data: transData } = await client.get(
|
|
394
|
+
`/rest/api/3/issue/${encodeURIComponent(key)}/transitions`
|
|
395
|
+
);
|
|
396
|
+
const transitions = transData.transitions || [];
|
|
397
|
+
const wanted = edge.transition.toLowerCase();
|
|
398
|
+
let match = transitions.find((t) => (t.name || '').toLowerCase() === wanted);
|
|
399
|
+
if (!match) {
|
|
400
|
+
match = transitions.find((t) => (t.name || '').toLowerCase().includes(wanted));
|
|
401
|
+
}
|
|
402
|
+
if (!match) {
|
|
403
|
+
logError(`jira advance: transition "${edge.transition}" not available from "${edge.from}" on ${key}.`);
|
|
404
|
+
logError('Available transitions:');
|
|
405
|
+
for (const t of transitions) {
|
|
406
|
+
logError(` - ${t.name} (id: ${t.id})`);
|
|
407
|
+
}
|
|
408
|
+
logError('The chain config may be stale - update jira-workflow-chains.json.');
|
|
409
|
+
return 2;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
await client.post(
|
|
413
|
+
`/rest/api/3/issue/${encodeURIComponent(key)}/transitions`,
|
|
414
|
+
{ transition: { id: match.id } }
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
// Per-step verification: confirm we landed where the chain says.
|
|
418
|
+
const { data: verifyData } = await client.get(
|
|
419
|
+
`/rest/api/3/issue/${encodeURIComponent(key)}?fields=status`
|
|
420
|
+
);
|
|
421
|
+
const landed = verifyData.fields?.status?.name || 'Unknown';
|
|
422
|
+
if (landed.toLowerCase() !== edge.to.toLowerCase()) {
|
|
423
|
+
logError(`jira advance: after "${edge.transition}", ${key} landed on "${landed}" but the chain expected "${edge.to}".`);
|
|
424
|
+
logError('Stopping here. The chain config may be stale - update jira-workflow-chains.json.');
|
|
425
|
+
return 2;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
log(green(`${bold(key)}: ${edge.from} → ${edge.to}`) + dim(` (via "${edge.transition}")`));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
log(green(`${bold(key)} advanced to ${bold(target)} in ${path.length} step(s).`));
|
|
432
|
+
(deps.notifyWorkspace || notifyWorkspaceAsync)();
|
|
433
|
+
return 0;
|
|
434
|
+
} catch (err) {
|
|
435
|
+
if (err instanceof JiraError) {
|
|
436
|
+
const wrapped = maybeWrapRequiredFieldError(err);
|
|
437
|
+
logError(`jira advance: ${wrapped || err.message}`);
|
|
438
|
+
return err.exitCode;
|
|
439
|
+
}
|
|
440
|
+
throw err;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ── Usage ────────────────────────────────────────────────────────────
|
|
445
|
+
|
|
446
|
+
function logUsage(logError) {
|
|
447
|
+
logError('Usage: jira advance <KEY> <TARGET-STATUS> [--dry-run]');
|
|
448
|
+
logError(' jira advance <KEY> --next [--dry-run]');
|
|
449
|
+
logError(' jira advance <KEY> --close [--dry-run]');
|
|
450
|
+
logError(' [--operator-consent] [--ticket-type V] [--affected-services NAME]');
|
|
451
|
+
logError(' [--original-estimate DUR] [--field K=V] [--field-adf K=text]');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ── Gate satisfaction checks ─────────────────────────────────────────
|
|
455
|
+
|
|
456
|
+
function isGateSatisfiedByFlags(kind, suppliedFields, fieldIdByKind) {
|
|
457
|
+
switch (kind) {
|
|
458
|
+
case 'ticketType':
|
|
459
|
+
return fieldIdByKind.ticketType in suppliedFields;
|
|
460
|
+
case 'affectedServices':
|
|
461
|
+
return fieldIdByKind.affectedServices in suppliedFields;
|
|
462
|
+
case 'originalEstimate':
|
|
463
|
+
return Boolean(suppliedFields.timetracking?.originalEstimate);
|
|
464
|
+
default:
|
|
465
|
+
// Unknown gate kind: treat generic --field coverage as unknowable;
|
|
466
|
+
// require it explicitly so the operator sees the gap.
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function isGateSatisfiedOnIssue(kind, existingFields, fieldIdByKind) {
|
|
472
|
+
switch (kind) {
|
|
473
|
+
case 'ticketType': {
|
|
474
|
+
const v = existingFields[fieldIdByKind.ticketType];
|
|
475
|
+
return v !== null && v !== undefined;
|
|
476
|
+
}
|
|
477
|
+
case 'affectedServices': {
|
|
478
|
+
const v = existingFields[fieldIdByKind.affectedServices];
|
|
479
|
+
return Array.isArray(v) ? v.length > 0 : Boolean(v);
|
|
480
|
+
}
|
|
481
|
+
case 'originalEstimate':
|
|
482
|
+
return Boolean(existingFields.timetracking?.originalEstimate);
|
|
483
|
+
default:
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// attach.mjs - Attach one or more files to an existing Jira issue.
|
|
2
|
+
//
|
|
3
|
+
// Usage:
|
|
4
|
+
// jira attach <KEY> -f <FILE> [-f <FILE>...]
|
|
5
|
+
//
|
|
6
|
+
// Posts each file to /rest/api/3/issue/{KEY}/attachments. Honours the
|
|
7
|
+
// Atlassian CSRF bypass (X-Atlassian-Token: no-check) - handled inside
|
|
8
|
+
// client.postFile.
|
|
9
|
+
|
|
10
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
11
|
+
import { basename, extname } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { createClient, JiraError } from '../client.mjs';
|
|
14
|
+
import { bold, green } from '../format.mjs';
|
|
15
|
+
import { asList } from '../metadata.mjs';
|
|
16
|
+
import { notifyWorkspaceAsync } from '../invalidate.mjs';
|
|
17
|
+
|
|
18
|
+
const HELP = `jira attach - Attach files to an issue
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
jira attach <KEY> -f <FILE> [-f <FILE>...]
|
|
22
|
+
|
|
23
|
+
Arguments:
|
|
24
|
+
KEY Issue key (e.g. AI-1552)
|
|
25
|
+
|
|
26
|
+
Options:
|
|
27
|
+
-f, --file FILE Path to a file to attach. Repeatable for multiple files.
|
|
28
|
+
|
|
29
|
+
Examples:
|
|
30
|
+
jira attach AI-1552 -f ./plan-1.md
|
|
31
|
+
jira attach AI-1552 -f ./plan-1.md -f ./plan-2.md
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
const MIME_BY_EXT = {
|
|
35
|
+
'.md': 'text/markdown',
|
|
36
|
+
'.txt': 'text/plain',
|
|
37
|
+
'.json': 'application/json',
|
|
38
|
+
'.yaml': 'application/yaml',
|
|
39
|
+
'.yml': 'application/yaml',
|
|
40
|
+
'.html': 'text/html',
|
|
41
|
+
'.csv': 'text/csv',
|
|
42
|
+
'.pdf': 'application/pdf',
|
|
43
|
+
'.png': 'image/png',
|
|
44
|
+
'.jpg': 'image/jpeg',
|
|
45
|
+
'.jpeg': 'image/jpeg',
|
|
46
|
+
'.gif': 'image/gif',
|
|
47
|
+
'.svg': 'image/svg+xml',
|
|
48
|
+
'.log': 'text/plain',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function guessMime(filePath) {
|
|
52
|
+
const ext = extname(filePath).toLowerCase();
|
|
53
|
+
return MIME_BY_EXT[ext] || 'application/octet-stream';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Run the attach command.
|
|
58
|
+
*
|
|
59
|
+
* @param {string[]} argv Args after 'attach'
|
|
60
|
+
* @param {object} [deps] Dependency overrides
|
|
61
|
+
* @returns {Promise<number>}
|
|
62
|
+
*/
|
|
63
|
+
export async function run(argv = [], deps = {}) {
|
|
64
|
+
const log = deps.log || console.log;
|
|
65
|
+
const logError = deps.logError || console.error;
|
|
66
|
+
const { parseArgs } = await import('../cli.mjs');
|
|
67
|
+
const args = parseArgs(argv);
|
|
68
|
+
|
|
69
|
+
if (args.help || args.h) {
|
|
70
|
+
log(HELP);
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const key = args._[0];
|
|
75
|
+
if (!key) {
|
|
76
|
+
logError('Usage: jira attach <KEY> -f <FILE> [-f <FILE>...]');
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const fileArgs = asList(args.file ?? args.f);
|
|
81
|
+
if (fileArgs.length === 0) {
|
|
82
|
+
logError('jira attach: at least one -f FILE is required');
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const client = deps.client || createClient();
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
for (const filePath of fileArgs) {
|
|
90
|
+
try {
|
|
91
|
+
const st = await stat(filePath);
|
|
92
|
+
if (!st.isFile()) {
|
|
93
|
+
throw new JiraError(`Not a regular file: ${filePath}`, 1);
|
|
94
|
+
}
|
|
95
|
+
} catch (err) {
|
|
96
|
+
if (err instanceof JiraError) throw err;
|
|
97
|
+
throw new JiraError(`Cannot read file "${filePath}": ${err.message}`, 1);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const filePath of fileArgs) {
|
|
102
|
+
const buffer = await readFile(filePath);
|
|
103
|
+
const filename = basename(filePath);
|
|
104
|
+
const mime = guessMime(filePath);
|
|
105
|
+
const { data } = await client.postFile(
|
|
106
|
+
`/rest/api/3/issue/${encodeURIComponent(key)}/attachments`,
|
|
107
|
+
buffer,
|
|
108
|
+
filename,
|
|
109
|
+
mime,
|
|
110
|
+
);
|
|
111
|
+
// Jira returns an array of attachment metadata on success.
|
|
112
|
+
const attached = Array.isArray(data) && data[0] ? data[0] : null;
|
|
113
|
+
const id = attached?.id ?? '(unknown id)';
|
|
114
|
+
const size = attached?.size ?? buffer.length;
|
|
115
|
+
log(green(`Attached: ${bold(filename)}`) + ` (id=${id}, ${size} bytes)`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const creds = client.getCredentials();
|
|
119
|
+
log(` URL: https://${creds.domain}.atlassian.net/browse/${key}`);
|
|
120
|
+
(deps.notifyWorkspace || notifyWorkspaceAsync)();
|
|
121
|
+
return 0;
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (err instanceof JiraError) {
|
|
124
|
+
logError(`jira attach: ${err.message}`);
|
|
125
|
+
return err.exitCode;
|
|
126
|
+
}
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// auth.mjs - Verify Jira credentials.
|
|
2
|
+
//
|
|
3
|
+
// Calls GET /rest/api/3/myself and prints the current user's details.
|
|
4
|
+
// Exit 0 on success, 2 on auth failure.
|
|
5
|
+
|
|
6
|
+
import { createClient, JiraError } from '../client.mjs';
|
|
7
|
+
import { bold, green, red } from '../format.mjs';
|
|
8
|
+
|
|
9
|
+
const HELP = `jira auth - Verify Jira credentials
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
jira auth
|
|
13
|
+
|
|
14
|
+
Checks the configured Jira credentials by fetching the current user's profile.
|
|
15
|
+
Prints display name, email, account ID, and timezone.
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Run the auth command.
|
|
20
|
+
*
|
|
21
|
+
* @param {string[]} argv Remaining args after 'auth'
|
|
22
|
+
* @param {object} [deps] Dependency overrides
|
|
23
|
+
* @returns {Promise<number>} Exit code
|
|
24
|
+
*/
|
|
25
|
+
export async function run(argv = [], deps = {}) {
|
|
26
|
+
const log = deps.log || console.log;
|
|
27
|
+
const logError = deps.logError || console.error;
|
|
28
|
+
|
|
29
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
30
|
+
log(HELP);
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const client = deps.client || createClient();
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const { data } = await client.get('/rest/api/3/myself');
|
|
38
|
+
|
|
39
|
+
log(green('Authentication successful.\n'));
|
|
40
|
+
log(` ${bold('Name:')} ${data.displayName || 'N/A'}`);
|
|
41
|
+
log(` ${bold('Email:')} ${data.emailAddress || 'N/A'}`);
|
|
42
|
+
log(` ${bold('Account ID:')} ${data.accountId || 'N/A'}`);
|
|
43
|
+
log(` ${bold('Timezone:')} ${data.timeZone || 'N/A'}`);
|
|
44
|
+
|
|
45
|
+
return 0;
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (err instanceof JiraError) {
|
|
48
|
+
logError(red(`Authentication failed: ${err.message}`));
|
|
49
|
+
return err.exitCode;
|
|
50
|
+
}
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
}
|