@skitterbyte/skitterspec-jira 0.0.0-stage → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MIGRATION.md +1266 -0
- package/README.md +44 -2
- package/assets/claude-md-section.md +85 -0
- package/assets/commands/allow-main.md +33 -0
- package/assets/commands/spec-connect.md +13 -0
- package/assets/commands/spec-live.md +14 -0
- package/assets/commands/spec-remote-review.md +37 -0
- package/assets/commands/spec-skip.md +24 -0
- package/assets/core/SETUP.md +192 -0
- package/assets/core/env.config.json.example +56 -0
- package/assets/core/env.config.md +606 -0
- package/assets/core/gating.config.json.example +4 -0
- package/assets/core/gating.config.md +81 -0
- package/assets/core/jira.config.json.example +18 -0
- package/assets/core/jira.config.md +88 -0
- package/assets/hooks/main-guard.cjs +162 -0
- package/assets/hooks/review-gate.cjs +146 -0
- package/assets/review/page.html +3384 -0
- package/assets/rules/commit-trailers.md +96 -0
- package/assets/rules/negative-checks.md +69 -0
- package/assets/rules/offered-unblocks.md +100 -0
- package/assets/rules/spec-planning.md +702 -0
- package/assets/rules/spec-reports.md +572 -0
- package/assets/skills/no-spec/SKILL.md +249 -0
- package/assets/skills/spec/SKILL.md +714 -0
- package/assets/skills/spec-bug/SKILL.md +585 -0
- package/assets/skills/spec-cancel/SKILL.md +261 -0
- package/assets/skills/spec-complete/SKILL.md +451 -0
- package/assets/skills/spec-diff/SKILL.md +870 -0
- package/assets/skills/spec-hotfix/SKILL.md +607 -0
- package/assets/skills/spec-init/SKILL.md +178 -0
- package/assets/skills/spec-jira-setup/SKILL.md +221 -0
- package/assets/skills/spec-list/SKILL.md +173 -0
- package/assets/skills/spec-next/SKILL.md +678 -0
- package/assets/skills/spec-push/SKILL.md +306 -0
- package/assets/skills/spec-review/SKILL.md +256 -0
- package/assets/skills/spec-reviewed/SKILL.md +284 -0
- package/assets/skills/spec-start/SKILL.md +496 -0
- package/assets/skills/spec-status/SKILL.md +114 -0
- package/assets/skills/spec-sync/SKILL.md +220 -0
- package/assets/skills/spec-to-main/SKILL.md +141 -0
- package/bin/skitterspec-jira.js +68 -0
- package/package.json +40 -4
- package/src/cli.js +5967 -0
- package/src/deprecate.js +138 -0
- package/src/env/bootstrap.js +188 -0
- package/src/env/building.js +143 -0
- package/src/env/classify.js +176 -0
- package/src/env/commitcmd.js +108 -0
- package/src/env/config.js +850 -0
- package/src/env/dev.js +63 -0
- package/src/env/glob.js +34 -0
- package/src/env/hooks.js +226 -0
- package/src/env/hotfix.js +413 -0
- package/src/env/hotfixland.js +302 -0
- package/src/env/integrate.js +167 -0
- package/src/env/live.js +479 -0
- package/src/env/mainguard.js +299 -0
- package/src/env/provision.js +466 -0
- package/src/env/proxy.js +225 -0
- package/src/env/prune.js +119 -0
- package/src/env/registry.js +159 -0
- package/src/env/render.js +17 -0
- package/src/env/resolve.js +716 -0
- package/src/env/review.js +2622 -0
- package/src/env/reviewers/coderabbit.js +228 -0
- package/src/env/reviewers.js +517 -0
- package/src/env/serve.js +1044 -0
- package/src/env/supervise.js +157 -0
- package/src/env/teardown.js +248 -0
- package/src/env/trust.js +87 -0
- package/src/gating.js +155 -0
- package/src/init.js +1150 -0
- package/src/lines-diff.js +114 -0
- package/src/prompts.js +129 -0
- package/src/vendor/jira/adf.js +414 -0
- package/src/vendor/jira/api.js +295 -0
- package/src/vendor/jira/cli-sync.js +1252 -0
- package/src/vendor/jira/commands.js +44 -0
- package/src/vendor/jira/config.js +286 -0
- package/src/vendor/jira/credentials.js +108 -0
- package/src/vendor/jira/dialect.js +45 -0
- package/src/vendor/jira/mcp.js +129 -0
- package/src/vendor/provider-kit/index.js +41 -0
- package/src/vendor/provider-kit/src/adapter.js +23 -0
- package/src/vendor/provider-kit/src/apply.js +207 -0
- package/src/vendor/provider-kit/src/args.js +68 -0
- package/src/vendor/provider-kit/src/assignment.js +277 -0
- package/src/vendor/provider-kit/src/config.js +251 -0
- package/src/vendor/provider-kit/src/credentials.js +404 -0
- package/src/vendor/provider-kit/src/doctor.js +35 -0
- package/src/vendor/provider-kit/src/http.js +63 -0
- package/src/vendor/provider-kit/src/identity.js +93 -0
- package/src/vendor/provider-kit/src/list-assignee.js +71 -0
- package/src/vendor/provider-kit/src/list-rows.js +35 -0
- package/src/vendor/provider-kit/src/md-compare.js +39 -0
- package/src/vendor/provider-kit/src/md-tables.js +94 -0
- package/src/vendor/provider-kit/src/prompt.js +50 -0
- package/src/vendor/provider-kit/src/released.js +219 -0
- package/src/vendor/provider-kit/src/specs.js +248 -0
- package/src/vendor/provider-kit/src/transport.js +22 -0
- package/src/vendor/sync-core/index.js +92 -0
- package/src/vendor/sync-core/src/base.js +81 -0
- package/src/vendor/sync-core/src/compare.js +338 -0
- package/src/vendor/sync-core/src/dialect.js +51 -0
- package/src/vendor/sync-core/src/fields.js +36 -0
- package/src/vendor/sync-core/src/legacy.js +100 -0
- package/src/vendor/sync-core/src/normalize.js +971 -0
- package/src/vendor/sync-core/src/push.js +85 -0
- package/src/vendor/sync-core/src/retarget.js +278 -0
- package/src/vendor/sync-core/src/task-block.js +308 -0
- package/src/vendor/sync-core/src/verify.js +67 -0
- package/src/vendor/sync-core/src/write.js +191 -0
|
@@ -0,0 +1,1252 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `skitterspec-jira spec-sync <sub>` — the Jira provider's engine CLI.
|
|
5
|
+
*
|
|
6
|
+
* Built on the provider kit: spec resolution, transport precedence, the flag
|
|
7
|
+
* parser with its unknown-flag refusal, the credentials prompts and the apply
|
|
8
|
+
* orchestration are all shared machinery; what this file owns is Jira's — the
|
|
9
|
+
* status-by-transition ops, the JQL, the config file it names in refusals,
|
|
10
|
+
* and which operations the MCP transport cannot express.
|
|
11
|
+
*
|
|
12
|
+
* Exit-code semantics mirror the Linear provider's table: `ref` prints the
|
|
13
|
+
* bare identifier on stdout ONLY on success (so `$(spec-sync ref)` cannot
|
|
14
|
+
* splice an error into a commit body); `verify` warns and never fails on
|
|
15
|
+
* losses; `preserve` exits 0 on every cannot-tell; dry-runs are the default
|
|
16
|
+
* wherever a write exists; an unknown `--flag` refuses before anything runs.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('node:fs')
|
|
20
|
+
const path = require('node:path')
|
|
21
|
+
|
|
22
|
+
const {
|
|
23
|
+
push,
|
|
24
|
+
recordPush,
|
|
25
|
+
readBase,
|
|
26
|
+
isEmptyPlan,
|
|
27
|
+
validateStates,
|
|
28
|
+
stateSuggestions,
|
|
29
|
+
remoteDescriptionEdited,
|
|
30
|
+
writeFrontmatter,
|
|
31
|
+
stampSubIssueId,
|
|
32
|
+
listPhaseFiles,
|
|
33
|
+
normalizeLocal,
|
|
34
|
+
} = require('../sync-core')
|
|
35
|
+
const kit = require('../provider-kit')
|
|
36
|
+
const { BUCKETS, findSpecFolder, currentBranch } = require('../../env/resolve.js')
|
|
37
|
+
|
|
38
|
+
const { loadJiraConfig, mergeConfig, defaults, CONFIG_FILE, LIFECYCLE_BUCKETS, DEFAULT_CONFIG } = require('./config.js')
|
|
39
|
+
const { resolveCredential, writeCredential, removeCredential, storePath, storeMode, fingerprint } = require('./credentials.js')
|
|
40
|
+
const { makeApiAdapter } = require('./api.js')
|
|
41
|
+
|
|
42
|
+
const PROGRAM = 'spec-sync'
|
|
43
|
+
const specHelpers = kit.makeSpecHelpers({ program: PROGRAM })
|
|
44
|
+
const { resolveSnapshotDir, specIdentifier, listSpecs, resolveOrExit, warnToErr, verifyLines } = specHelpers
|
|
45
|
+
|
|
46
|
+
// An issue identifier: project key, dash, number.
|
|
47
|
+
const ID_RE = /^[A-Za-z][A-Za-z0-9]*-\d+$/
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// The state gate. Jira refuses an unknown STATUS at transition time — after
|
|
51
|
+
// the description write has already landed — so a half-typed `states` table
|
|
52
|
+
// would mirror the prose and strand the workflow move. Same discipline as the
|
|
53
|
+
// Linear provider: validate against the project's real statuses first.
|
|
54
|
+
function stateCheckFailure(config, flags) {
|
|
55
|
+
if (flags.skipStateCheck) return null
|
|
56
|
+
if (!flags.workspaceStates) {
|
|
57
|
+
return [
|
|
58
|
+
`${PROGRAM} plan: refusing — the configured issue states have not been validated`,
|
|
59
|
+
" pass --workspace-states <file> (a JSON array of the project's status",
|
|
60
|
+
' names — `spec-sync states --json` prints exactly that), or',
|
|
61
|
+
' --skip-state-check to push anyway.',
|
|
62
|
+
' Jira refuses an unknown status only at transition time, after the',
|
|
63
|
+
' description write has landed: the push would half-apply.',
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
if (!fs.existsSync(flags.workspaceStates)) {
|
|
67
|
+
return [`${PROGRAM} plan: refusing — no such --workspace-states file: ${flags.workspaceStates}`]
|
|
68
|
+
}
|
|
69
|
+
let names
|
|
70
|
+
try {
|
|
71
|
+
names = JSON.parse(fs.readFileSync(flags.workspaceStates, 'utf-8'))
|
|
72
|
+
} catch (error) {
|
|
73
|
+
return [`${PROGRAM} plan: refusing — --workspace-states is not valid JSON: ${error.message}`]
|
|
74
|
+
}
|
|
75
|
+
const list = Array.isArray(names) ? names : []
|
|
76
|
+
const missing = validateStates(config, list)
|
|
77
|
+
if (missing.length) {
|
|
78
|
+
const lines = [`${PROGRAM} plan: refusing — configured status name(s) not in the project`, '']
|
|
79
|
+
for (const { label, configured, suggestion } of stateSuggestions(config, list)) {
|
|
80
|
+
lines.push(` ${label}: "${configured}" is not a status in this project`)
|
|
81
|
+
if (suggestion) lines.push(` use "${suggestion}" instead`)
|
|
82
|
+
}
|
|
83
|
+
lines.push('', ` available: ${list.join(', ') || '(the project reported none)'}`, ` Fix ${CONFIG_FILE} -> states.`)
|
|
84
|
+
return lines
|
|
85
|
+
}
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Apply ops: the kit orchestration with Jira's tracker calls. Status is moved
|
|
91
|
+
// by TRANSITION after the create/update — Jira has no "set status" field — and
|
|
92
|
+
// `transitionTo` is idempotent when the issue already sits on the target.
|
|
93
|
+
function applyOps({ adapter, config }) {
|
|
94
|
+
const statusFor = (bucket) => {
|
|
95
|
+
const name = config.states && config.states[bucket]
|
|
96
|
+
if (!name) {
|
|
97
|
+
throw new Error(`no status configured for the "${bucket}" bucket — fix ${CONFIG_FILE} -> states`)
|
|
98
|
+
}
|
|
99
|
+
return name
|
|
100
|
+
}
|
|
101
|
+
const settle = async (keyOrId, bucket) => {
|
|
102
|
+
if (!bucket) return
|
|
103
|
+
await adapter.transitionTo(keyOrId, statusFor(bucket))
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
prevalidateStates: (buckets) => {
|
|
107
|
+
for (const bucket of buckets) statusFor(bucket)
|
|
108
|
+
},
|
|
109
|
+
searchTwin: (title) => adapter.searchIssues({ query: title, projectKey: config.jira.projectKey }),
|
|
110
|
+
twinAdvice: (twin, wanted, specName) =>
|
|
111
|
+
`${twin.identifier} is already titled "${wanted}" and no spec claims it — ` +
|
|
112
|
+
`stamp it onto the spec with \`spec-sync stamp ${specName} --issue ${twin.identifier}\`, ` +
|
|
113
|
+
'or pass --force-new to create a second',
|
|
114
|
+
missingIssueMessage: (identifier) => `no Jira issue found for ${identifier}`,
|
|
115
|
+
remoteEditedLine: (identifier) =>
|
|
116
|
+
` !! ${identifier}'s description was edited on Jira since the last push — this replaces it`,
|
|
117
|
+
mintIssue: async ({ title, issue }) => {
|
|
118
|
+
const created = await adapter.createIssue({
|
|
119
|
+
title,
|
|
120
|
+
description: issue && issue.description != null ? issue.description : undefined,
|
|
121
|
+
assigneeId: issue && issue.assignee ? issue.assignee : undefined,
|
|
122
|
+
projectKey: config.jira.projectKey,
|
|
123
|
+
issueTypeName: config.jira.issueType,
|
|
124
|
+
})
|
|
125
|
+
await settle(created.identifier, issue && issue.state)
|
|
126
|
+
return created
|
|
127
|
+
},
|
|
128
|
+
updateIssue: async (existing, issue) => {
|
|
129
|
+
const updates = {}
|
|
130
|
+
if (issue.description !== undefined) updates.description = issue.description
|
|
131
|
+
// Null is meaningful here — it is how Jira unassigns — so the key is
|
|
132
|
+
// forwarded whenever the plan carries it, never truthiness-tested.
|
|
133
|
+
if ('assignee' in issue) updates.assigneeId = issue.assignee ?? null
|
|
134
|
+
if (Object.keys(updates).length) await adapter.updateIssue(existing.identifier, updates)
|
|
135
|
+
await settle(existing.identifier, issue.state)
|
|
136
|
+
},
|
|
137
|
+
mintSubIssue: async (parentId, sub) => {
|
|
138
|
+
const created = await adapter.createSubIssue(parentId, {
|
|
139
|
+
title: sub.name,
|
|
140
|
+
description: sub.goal != null ? sub.goal : undefined,
|
|
141
|
+
projectKey: config.jira.projectKey,
|
|
142
|
+
subtaskTypeName: config.jira.subtaskType,
|
|
143
|
+
})
|
|
144
|
+
await settle(created.identifier, sub.state)
|
|
145
|
+
return created
|
|
146
|
+
},
|
|
147
|
+
updateSubIssue: async (sub) => {
|
|
148
|
+
const updates = {}
|
|
149
|
+
if (sub.name != null) updates.title = sub.name
|
|
150
|
+
if (sub.goal != null) updates.description = sub.goal
|
|
151
|
+
if (Object.keys(updates).length) await adapter.updateIssue(sub.id, updates)
|
|
152
|
+
await settle(sub.id, sub.state)
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function apiAdapterFor(config, flags) {
|
|
158
|
+
if (flags.adapter) return { ok: true, adapter: flags.adapter }
|
|
159
|
+
const cred = resolveCredential(config, flags.env || process.env)
|
|
160
|
+
if (!cred.ok) return { ok: false, error: cred.error }
|
|
161
|
+
return {
|
|
162
|
+
ok: true,
|
|
163
|
+
adapter: makeApiAdapter({
|
|
164
|
+
siteUrl: config.jira.siteUrl,
|
|
165
|
+
email: cred.email,
|
|
166
|
+
token: cred.token,
|
|
167
|
+
fetch: flags.fetch,
|
|
168
|
+
}),
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Subcommands. Each returns an exit code and writes only to the streams it is
|
|
174
|
+
// handed, so every branch is testable offline.
|
|
175
|
+
|
|
176
|
+
function specSyncNormalize(dir, config, specArg, out, err) {
|
|
177
|
+
const snapshotDir = resolveOrExit(specArg, dir, out)
|
|
178
|
+
if (!snapshotDir) return 1
|
|
179
|
+
warnToErr(snapshotDir, config, err)
|
|
180
|
+
out.write(JSON.stringify(normalizeLocal(snapshotDir, config), null, 2) + '\n')
|
|
181
|
+
return 0
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function specSyncPlan(dir, config, specArg, flags, out, err) {
|
|
185
|
+
const snapshotDir = resolveOrExit(specArg, dir, out)
|
|
186
|
+
if (!snapshotDir) return 1
|
|
187
|
+
const refused = stateCheckFailure(config, flags)
|
|
188
|
+
if (refused) {
|
|
189
|
+
out.write(refused.join('\n') + '\n')
|
|
190
|
+
return 1
|
|
191
|
+
}
|
|
192
|
+
warnToErr(snapshotDir, config, err)
|
|
193
|
+
const identifier = specIdentifier(snapshotDir, config)
|
|
194
|
+
const r = push({ dir, snapshotDir, identifier, config })
|
|
195
|
+
if (flags.json || !out.isTTY) {
|
|
196
|
+
out.write(JSON.stringify(r.plan, null, 2) + '\n')
|
|
197
|
+
return 0
|
|
198
|
+
}
|
|
199
|
+
if (r.empty) {
|
|
200
|
+
out.write(`${PROGRAM} plan: ${identifier} — nothing to push\n`)
|
|
201
|
+
return 0
|
|
202
|
+
}
|
|
203
|
+
const c = r.plan.subIssues.create.length
|
|
204
|
+
const u = r.plan.subIssues.update.length
|
|
205
|
+
out.write(
|
|
206
|
+
`${PROGRAM} plan: ${identifier} — ${r.plan.issue ? 'issue update, ' : ''}${c} subtask create(s), ${u} update(s)\n` +
|
|
207
|
+
' apply it: spec-sync plan <spec> --json > plan.json && spec-sync apply <spec> --plan plan.json\n',
|
|
208
|
+
)
|
|
209
|
+
return 0
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function specSyncApply(dir, config, specArg, flags, out, err) {
|
|
213
|
+
if (!flags.plan && !flags.all) {
|
|
214
|
+
out.write(`${PROGRAM} apply: pass --plan <file> (from \`plan --json\`), or --all <bucket>\n`)
|
|
215
|
+
return 1
|
|
216
|
+
}
|
|
217
|
+
const cred = apiAdapterFor(config, flags)
|
|
218
|
+
const transport = kit.resolveTransport(flags, config, cred.ok)
|
|
219
|
+
if (transport === 'mcp') {
|
|
220
|
+
if (flags.all) {
|
|
221
|
+
out.write(`${PROGRAM} apply --all: the MCP transport cannot bulk-apply — set credentials and re-run\n`)
|
|
222
|
+
return 1
|
|
223
|
+
}
|
|
224
|
+
out.write(
|
|
225
|
+
`${PROGRAM} apply: transport = mcp — nothing written by the engine.\n` +
|
|
226
|
+
' Apply the plan with the Atlassian MCP tools (createJiraIssue /\n' +
|
|
227
|
+
' editJiraIssue / transitionJiraIssue), then stamp the returned keys\n' +
|
|
228
|
+
' with `spec-sync stamp` and record with `spec-sync record`.\n',
|
|
229
|
+
)
|
|
230
|
+
return 0
|
|
231
|
+
}
|
|
232
|
+
if (!cred.ok) {
|
|
233
|
+
out.write(`${PROGRAM} apply: ${cred.error}\n`)
|
|
234
|
+
return 1
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const applyOne = (snapshotDir, plan) =>
|
|
238
|
+
kit.applySpecPlan({
|
|
239
|
+
dir,
|
|
240
|
+
config,
|
|
241
|
+
snapshotDir,
|
|
242
|
+
plan,
|
|
243
|
+
adapter: cred.adapter,
|
|
244
|
+
ops: applyOps({ adapter: cred.adapter, config }),
|
|
245
|
+
forceNew: !!flags.forceNew,
|
|
246
|
+
program: PROGRAM,
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
if (flags.all) {
|
|
250
|
+
const bucket = String(flags.all)
|
|
251
|
+
if (!BUCKETS.includes(bucket)) {
|
|
252
|
+
out.write(`${PROGRAM} apply --all: not a lifecycle bucket: ${bucket}\n`)
|
|
253
|
+
return 1
|
|
254
|
+
}
|
|
255
|
+
const specs = listSpecs(dir, config).filter((s) => s.bucket === bucket)
|
|
256
|
+
let failed = 0
|
|
257
|
+
for (const spec of specs) {
|
|
258
|
+
const snapshotDir = resolveSnapshotDir(spec.spec, dir)
|
|
259
|
+
const identifier = specIdentifier(snapshotDir, config)
|
|
260
|
+
const r = push({ dir, snapshotDir, identifier, config })
|
|
261
|
+
if (r.empty) continue
|
|
262
|
+
out.write(`${PROGRAM} apply: ${spec.spec}\n`)
|
|
263
|
+
try {
|
|
264
|
+
const applied = await applyOne(snapshotDir, r.plan)
|
|
265
|
+
out.write(applied.lines.join('\n') + '\n')
|
|
266
|
+
} catch (error) {
|
|
267
|
+
failed++
|
|
268
|
+
out.write(` !! ${error.message}\n`)
|
|
269
|
+
if (error.orphans && error.orphans.length) {
|
|
270
|
+
out.write(` created but not stamped: ${error.orphans.join(', ')} — stamp before re-running\n`)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
out.write(`${PROGRAM} apply --all ${bucket}: ${specs.length} spec(s), ${failed} failed\n`)
|
|
275
|
+
return failed ? 1 : 0
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const snapshotDir = resolveOrExit(specArg, dir, out)
|
|
279
|
+
if (!snapshotDir) return 1
|
|
280
|
+
let plan
|
|
281
|
+
try {
|
|
282
|
+
plan = JSON.parse(fs.readFileSync(flags.plan, 'utf-8'))
|
|
283
|
+
} catch (error) {
|
|
284
|
+
out.write(`${PROGRAM} apply: cannot read --plan: ${error.message}\n`)
|
|
285
|
+
return 1
|
|
286
|
+
}
|
|
287
|
+
if (isEmptyPlan(plan)) {
|
|
288
|
+
out.write(`${PROGRAM} apply: empty plan — nothing to do\n`)
|
|
289
|
+
return 0
|
|
290
|
+
}
|
|
291
|
+
out.write(`${PROGRAM} apply: transport = api\n`)
|
|
292
|
+
warnToErr(snapshotDir, config, err)
|
|
293
|
+
try {
|
|
294
|
+
const applied = await applyOne(snapshotDir, plan)
|
|
295
|
+
out.write(applied.lines.join('\n') + '\n')
|
|
296
|
+
return 0
|
|
297
|
+
} catch (error) {
|
|
298
|
+
out.write(`${PROGRAM} apply: ${error.message}\n`)
|
|
299
|
+
if (error.orphans && error.orphans.length) {
|
|
300
|
+
out.write(
|
|
301
|
+
` created but not stamped: ${error.orphans.join(', ')} — stamp them with \`spec-sync stamp\` before re-running,\n` +
|
|
302
|
+
' or the next apply mints duplicates\n',
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
return 1
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function specSyncStamp(dir, config, specArg, flags, out) {
|
|
310
|
+
const snapshotDir = resolveOrExit(specArg, dir, out)
|
|
311
|
+
if (!snapshotDir) return 1
|
|
312
|
+
// Validate EVERYTHING before writing ANYTHING: a half-stamped spec mints
|
|
313
|
+
// duplicates on the next push, which is worse than a refused stamp.
|
|
314
|
+
const problems = []
|
|
315
|
+
if (!flags.issue) problems.push('--issue <KEY-n> is required')
|
|
316
|
+
else if (!ID_RE.test(flags.issue)) problems.push(`--issue ${flags.issue} is not an issue key (PROJ-123)`)
|
|
317
|
+
if (flags.url && !/^https?:\/\//i.test(flags.url)) problems.push(`--url must be http(s): ${flags.url}`)
|
|
318
|
+
const subs = []
|
|
319
|
+
for (const pair of flags.subs || []) {
|
|
320
|
+
const [ref, ...rest] = String(pair).split('=')
|
|
321
|
+
const id = rest.join('=').trim()
|
|
322
|
+
const file = listPhaseFiles(snapshotDir).find(
|
|
323
|
+
(f) => f.replace(/\.md$/, '') === String(ref).trim().replace(/\.md$/, ''),
|
|
324
|
+
)
|
|
325
|
+
if (!file) problems.push(`--sub ${pair}: no phase file named ${ref}`)
|
|
326
|
+
else if (!ID_RE.test(id)) problems.push(`--sub ${pair}: ${id || '(empty)'} is not an issue key`)
|
|
327
|
+
else subs.push({ file, id })
|
|
328
|
+
}
|
|
329
|
+
// The live half of the same promise: when credentials resolve, a key Jira
|
|
330
|
+
// positively answers "no such issue" to is refused — a wrong stamp's whole
|
|
331
|
+
// failure mode is a duplicate minted LATER, far from the typo, so catching
|
|
332
|
+
// it here is worth one read per key. Offline use stays first-class: no
|
|
333
|
+
// credentials, or a Jira that cannot be reached, is a cannot-tell and stamps
|
|
334
|
+
// exactly as before (negative-checks rule 4) — a thrown read says it could
|
|
335
|
+
// not check rather than implying it did.
|
|
336
|
+
let verified = false
|
|
337
|
+
if (!problems.length) {
|
|
338
|
+
const cred = apiAdapterFor(config, flags)
|
|
339
|
+
if (cred.ok) {
|
|
340
|
+
try {
|
|
341
|
+
const keys = [[flags.issue, '--issue'], ...subs.map((sub) => [sub.id, `--sub ${path.basename(sub.file)}`])]
|
|
342
|
+
for (const [id, label] of keys) {
|
|
343
|
+
if ((await cred.adapter.readIssue(id)) == null) {
|
|
344
|
+
problems.push(`${label} ${id}: Jira has no such issue — check the key in the browser URL`)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
verified = !problems.length
|
|
348
|
+
} catch {
|
|
349
|
+
out.write(`${PROGRAM} stamp: could not check the key(s) against Jira — stamping anyway\n`)
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (problems.length) {
|
|
354
|
+
out.write([`${PROGRAM} stamp: refusing — nothing written`, ...problems.map((p) => ` ${p}`)].join('\n') + '\n')
|
|
355
|
+
return 1
|
|
356
|
+
}
|
|
357
|
+
const fields = config.fields
|
|
358
|
+
const site = config.jira.siteUrl
|
|
359
|
+
const url = flags.url || (site ? `${site}/browse/${flags.issue}` : null)
|
|
360
|
+
writeFrontmatter(snapshotDir, config, {
|
|
361
|
+
[fields.identifier]: flags.issue,
|
|
362
|
+
...(url ? { [fields.url]: url } : {}),
|
|
363
|
+
})
|
|
364
|
+
for (const { file, id } of subs) stampSubIssueId(snapshotDir, file, id, config)
|
|
365
|
+
out.write(
|
|
366
|
+
`${PROGRAM} stamp: ${flags.issue} stamped onto ${path.basename(snapshotDir)} (+${subs.length} subtask stamp(s))` +
|
|
367
|
+
`${verified ? ' — verified against Jira' : ''}\n`,
|
|
368
|
+
)
|
|
369
|
+
return 0
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function specSyncRecord(dir, config, specArg, out) {
|
|
373
|
+
const snapshotDir = resolveOrExit(specArg, dir, out)
|
|
374
|
+
if (!snapshotDir) return 1
|
|
375
|
+
const file = recordPush({ dir, snapshotDir, identifier: specIdentifier(snapshotDir, config), config })
|
|
376
|
+
out.write(`${PROGRAM} record: snapshot written — ${path.relative(dir, file)}\n`)
|
|
377
|
+
return 0
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function specSyncStatus(dir, config, specArg, flags, out, err) {
|
|
381
|
+
const snapshotDir = resolveOrExit(specArg, dir, out)
|
|
382
|
+
if (!snapshotDir) return 1
|
|
383
|
+
const identifier = specIdentifier(snapshotDir, config)
|
|
384
|
+
const lines = [`${PROGRAM} status: ${identifier}`]
|
|
385
|
+
warnToErr(snapshotDir, config, err)
|
|
386
|
+
|
|
387
|
+
if (flags.workspaceStates) {
|
|
388
|
+
const refused = stateCheckFailure(config, { workspaceStates: flags.workspaceStates })
|
|
389
|
+
if (refused) {
|
|
390
|
+
out.write(refused.join('\n') + '\n')
|
|
391
|
+
return 1
|
|
392
|
+
}
|
|
393
|
+
lines.push(' states: every configured status exists in the project')
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const r = push({ dir, snapshotDir, identifier, config })
|
|
397
|
+
if (r.empty) lines.push(' push: in sync — nothing would push')
|
|
398
|
+
else {
|
|
399
|
+
const c = r.plan.subIssues.create.length
|
|
400
|
+
const u = r.plan.subIssues.update.length
|
|
401
|
+
lines.push(` push: pending — ${r.plan.issue ? 'issue update, ' : ''}${c} subtask create(s), ${u} update(s)`)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (flags.remote) {
|
|
405
|
+
let remote
|
|
406
|
+
try {
|
|
407
|
+
remote = JSON.parse(fs.readFileSync(flags.remote, 'utf-8'))
|
|
408
|
+
} catch (error) {
|
|
409
|
+
out.write(`${PROGRAM} status: cannot read --remote: ${error.message}\n`)
|
|
410
|
+
return 1
|
|
411
|
+
}
|
|
412
|
+
// NO STATE LINE, deliberately: skitterspec tells the tracker about its own
|
|
413
|
+
// transitions and never reads a status back. A ticket someone moved there
|
|
414
|
+
// is theirs to have moved, and push resends State only when the spec does.
|
|
415
|
+
// SILENT ON EVERYTHING EXCEPT A POSITIVE ANSWER: `remoteDescriptionEdited`
|
|
416
|
+
// returns null for every cannot-tell, and none of those is evidence.
|
|
417
|
+
if (remoteDescriptionEdited(readBase(dir, identifier, config), remote, config) === true) {
|
|
418
|
+
lines.push(
|
|
419
|
+
` drift: ${identifier}'s description was edited on Jira since the last push`,
|
|
420
|
+
' (repo wins on next push — read it before pushing)',
|
|
421
|
+
)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
out.write(lines.join('\n') + '\n')
|
|
425
|
+
return 0
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function specSyncVerify(dir, config, specArg, flags, out) {
|
|
429
|
+
const snapshotDir = resolveOrExit(specArg, dir, out)
|
|
430
|
+
if (!snapshotDir) return 1
|
|
431
|
+
if (!flags.stored) {
|
|
432
|
+
out.write(`${PROGRAM} verify: pass --stored <file> ({ issue, subIssues: { ref: text } })\n`)
|
|
433
|
+
return 1
|
|
434
|
+
}
|
|
435
|
+
let stored
|
|
436
|
+
try {
|
|
437
|
+
stored = JSON.parse(fs.readFileSync(flags.stored, 'utf-8'))
|
|
438
|
+
} catch (error) {
|
|
439
|
+
out.write(`${PROGRAM} verify: cannot read --stored: ${error.message}\n`)
|
|
440
|
+
return 1
|
|
441
|
+
}
|
|
442
|
+
// A snapshot passed as --stored compares hashes against text and reports
|
|
443
|
+
// total loss on a healthy mirror — refuse the shape explicitly.
|
|
444
|
+
if (stored && stored.issueFields) {
|
|
445
|
+
out.write(
|
|
446
|
+
`${PROGRAM} verify: that looks like a .base.json snapshot, not a read-back —\n` +
|
|
447
|
+
' pass the DESCRIPTIONS the tracker returned ({ issue, subIssues: { ref: text } })\n',
|
|
448
|
+
)
|
|
449
|
+
return 1
|
|
450
|
+
}
|
|
451
|
+
out.write(verifyLines(snapshotDir, config, stored || {}, specIdentifier(snapshotDir, config)).join('\n') + '\n')
|
|
452
|
+
// Warn, never fail: losses are for a human to read, and failing the command
|
|
453
|
+
// would fail the push that just succeeded.
|
|
454
|
+
return 0
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function specSyncLinked(dir, config, flags, out) {
|
|
458
|
+
const specs = listSpecs(dir, config)
|
|
459
|
+
if (flags.json) {
|
|
460
|
+
out.write(JSON.stringify(specs, null, 2) + '\n')
|
|
461
|
+
return 0
|
|
462
|
+
}
|
|
463
|
+
if (!specs.length) {
|
|
464
|
+
out.write(`${PROGRAM} linked: no specs found under specs/\n`)
|
|
465
|
+
return 0
|
|
466
|
+
}
|
|
467
|
+
const lines = [`${PROGRAM} linked:`]
|
|
468
|
+
for (const s of specs) lines.push(` ${s.identifier || '—'}\t${s.spec} (${s.bucket})`)
|
|
469
|
+
lines.push(` ${specs.filter((s) => s.identifier).length}/${specs.length} linked`)
|
|
470
|
+
out.write(lines.join('\n') + '\n')
|
|
471
|
+
return 0
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function specSyncStates(dir, config, flags, out) {
|
|
475
|
+
const cred = apiAdapterFor(config, flags)
|
|
476
|
+
const transport = kit.resolveTransport(flags, config, cred.ok)
|
|
477
|
+
if (transport === 'mcp') {
|
|
478
|
+
out.write(
|
|
479
|
+
`${PROGRAM} states: transport = mcp — fetch the project's statuses with the\n` +
|
|
480
|
+
' Atlassian MCP tools and write the names to a JSON array file for\n' +
|
|
481
|
+
' --workspace-states.\n',
|
|
482
|
+
)
|
|
483
|
+
return 0
|
|
484
|
+
}
|
|
485
|
+
if (!cred.ok) {
|
|
486
|
+
out.write(`${PROGRAM} states: ${cred.error}\n`)
|
|
487
|
+
return 1
|
|
488
|
+
}
|
|
489
|
+
if (!config.jira.projectKey) {
|
|
490
|
+
out.write(`${PROGRAM} states: no jira.projectKey configured — fix ${CONFIG_FILE}\n`)
|
|
491
|
+
return 1
|
|
492
|
+
}
|
|
493
|
+
let states
|
|
494
|
+
try {
|
|
495
|
+
states = await cred.adapter.listIssueStates(config.jira.projectKey)
|
|
496
|
+
} catch (error) {
|
|
497
|
+
out.write(`${PROGRAM} states: ${error.message}\n`)
|
|
498
|
+
return 1
|
|
499
|
+
}
|
|
500
|
+
const names = states.map((s) => s.name)
|
|
501
|
+
if (flags.json) {
|
|
502
|
+
// The bare array --workspace-states consumes.
|
|
503
|
+
out.write(JSON.stringify(names, null, 2) + '\n')
|
|
504
|
+
return 0
|
|
505
|
+
}
|
|
506
|
+
const catOf = new Map(states.map((s) => [s.name, s.category || null]))
|
|
507
|
+
const lines = [
|
|
508
|
+
`${PROGRAM} states: ${config.jira.projectKey}`,
|
|
509
|
+
...names.map((n) => ` ${n}${catOf.get(n) ? ` [${catOf.get(n)}]` : ''}`),
|
|
510
|
+
]
|
|
511
|
+
// The bucket map — the one visual answer to "is skitterspec wired to what
|
|
512
|
+
// this project actually has?". Every bucket gets a row and a verdict, a
|
|
513
|
+
// missing status names its closest match, and the remap is one command.
|
|
514
|
+
const suggestions = new Map(stateSuggestions(config, names).map((x) => [x.bucket, x.suggestion]))
|
|
515
|
+
const buckets = Object.entries(config.states)
|
|
516
|
+
const bw = Math.max(...buckets.map(([b]) => b.length))
|
|
517
|
+
const nw = Math.max(...buckets.map(([, n]) => String(n).length))
|
|
518
|
+
lines.push('', ' map: lifecycle bucket -> Jira status')
|
|
519
|
+
const missing = []
|
|
520
|
+
for (const [bucket, name] of buckets) {
|
|
521
|
+
const ok = names.includes(name)
|
|
522
|
+
if (!ok) missing.push(bucket)
|
|
523
|
+
const tail = ok
|
|
524
|
+
? `ok${catOf.get(name) ? ` [${catOf.get(name)}]` : ''}`
|
|
525
|
+
: `MISSING from the project${suggestions.get(bucket) ? ` — closest: "${suggestions.get(bucket)}"` : ''}`
|
|
526
|
+
lines.push(` ${bucket.padEnd(bw)} -> ${String(name).padEnd(nw)} ${tail}`)
|
|
527
|
+
}
|
|
528
|
+
if (missing.length) lines.push(` remap: ${PROGRAM} init-config --force --state <bucket>="<status>"`)
|
|
529
|
+
out.write(lines.join('\n') + '\n')
|
|
530
|
+
return 0
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function specSyncList(dir, config, flags, out) {
|
|
534
|
+
const cred = apiAdapterFor(config, flags)
|
|
535
|
+
const transport = kit.resolveTransport(flags, config, cred.ok)
|
|
536
|
+
if (transport === 'mcp' || !cred.ok) {
|
|
537
|
+
// Degrades rather than refusing: a listing is a convenience, and with no
|
|
538
|
+
// credentials there is simply nothing to list from here.
|
|
539
|
+
out.write(`${PROGRAM} list: needs the API transport — ${cred.ok ? 'pass --via api' : cred.error.split('\n')[0]}\n`)
|
|
540
|
+
return 0
|
|
541
|
+
}
|
|
542
|
+
if (!config.jira.projectKey) {
|
|
543
|
+
out.write(`${PROGRAM} list: no jira.projectKey configured — fix ${CONFIG_FILE}\n`)
|
|
544
|
+
return 1
|
|
545
|
+
}
|
|
546
|
+
let issues
|
|
547
|
+
try {
|
|
548
|
+
const stateNames = flags.stateArgs || []
|
|
549
|
+
const query = stateNames.length
|
|
550
|
+
? { projectKey: config.jira.projectKey, jqlExtra: stateNames }
|
|
551
|
+
: { projectKey: config.jira.projectKey }
|
|
552
|
+
issues = await cred.adapter.searchIssues({ ...query, query: '', limit: flags.limit || 50 })
|
|
553
|
+
if (stateNames.length) {
|
|
554
|
+
const want = new Set(stateNames.map((s) => s.toLowerCase()))
|
|
555
|
+
issues = issues.filter((i) => i.state && want.has(String(i.state.name).toLowerCase()))
|
|
556
|
+
} else if (!flags.all) {
|
|
557
|
+
issues = issues.filter((i) => !i.state || !/done/i.test(String(i.state.name)))
|
|
558
|
+
}
|
|
559
|
+
} catch (error) {
|
|
560
|
+
out.write(`${PROGRAM} list: ${error.message}\n`)
|
|
561
|
+
return 1
|
|
562
|
+
}
|
|
563
|
+
const linked = new Map(listSpecs(dir, config).filter((s) => s.identifier).map((s) => [s.identifier, s]))
|
|
564
|
+
const rows = issues
|
|
565
|
+
.filter((i) => !i.parent)
|
|
566
|
+
.map((i) => ({
|
|
567
|
+
identifier: i.identifier,
|
|
568
|
+
title: i.title,
|
|
569
|
+
state: i.state ? i.state.name : null,
|
|
570
|
+
spec: linked.has(i.identifier) ? linked.get(i.identifier).spec : null,
|
|
571
|
+
}))
|
|
572
|
+
if (flags.json) {
|
|
573
|
+
out.write(JSON.stringify(rows, null, 2) + '\n')
|
|
574
|
+
return 0
|
|
575
|
+
}
|
|
576
|
+
if (!rows.length) {
|
|
577
|
+
out.write(`${PROGRAM} list: nothing matched\n`)
|
|
578
|
+
return 0
|
|
579
|
+
}
|
|
580
|
+
const lines = [`${PROGRAM} list: ${config.jira.projectKey}`]
|
|
581
|
+
for (const r of rows) {
|
|
582
|
+
lines.push(` ${r.identifier}\t${r.state || '—'}\t${r.title || ''}${r.spec ? ` -> ${r.spec}` : ''}`)
|
|
583
|
+
}
|
|
584
|
+
out.write(lines.join('\n') + '\n')
|
|
585
|
+
return 0
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// The `Refs:` trailer's source of truth: the ticket for the spec this branch
|
|
589
|
+
// (or the named spec) belongs to. Bare identifier on stdout ONLY on success —
|
|
590
|
+
// every no-ref case writes stderr and exits 1, so `$(spec-sync ref)` cannot
|
|
591
|
+
// splice an error into a commit body.
|
|
592
|
+
function specSyncRef(dir, config, specArg, flags, out, err) {
|
|
593
|
+
let snapshotDir = null
|
|
594
|
+
if (specArg) {
|
|
595
|
+
snapshotDir = resolveSnapshotDir(specArg, dir)
|
|
596
|
+
if (!snapshotDir) {
|
|
597
|
+
err.write(`${PROGRAM} ref: spec not found: ${specArg}\n`)
|
|
598
|
+
return 1
|
|
599
|
+
}
|
|
600
|
+
} else {
|
|
601
|
+
const branch = currentBranch(dir)
|
|
602
|
+
if (!branch) {
|
|
603
|
+
err.write(`${PROGRAM} ref: not on a branch\n`)
|
|
604
|
+
return 1
|
|
605
|
+
}
|
|
606
|
+
const slug = branch.includes('/') ? branch.slice(branch.indexOf('/') + 1) : branch
|
|
607
|
+
const type = branch.includes('/') ? branch.slice(0, branch.indexOf('/')) : null
|
|
608
|
+
const name = type ? `${type}-${slug}` : slug
|
|
609
|
+
const found = findSpecFolder(name, dir)
|
|
610
|
+
if (!found) {
|
|
611
|
+
err.write(`${PROGRAM} ref: no spec matches branch ${branch}\n`)
|
|
612
|
+
return 1
|
|
613
|
+
}
|
|
614
|
+
snapshotDir = found.path
|
|
615
|
+
}
|
|
616
|
+
const overviewFile = (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
|
|
617
|
+
const id = specHelpers.linkedIdentifier(path.join(snapshotDir, overviewFile), config)
|
|
618
|
+
if (!id) {
|
|
619
|
+
err.write(`${PROGRAM} ref: ${path.basename(snapshotDir)} has no ${config.fields.identifier} — omit the trailer\n`)
|
|
620
|
+
return 1
|
|
621
|
+
}
|
|
622
|
+
if (flags.json) out.write(JSON.stringify({ identifier: id }) + '\n')
|
|
623
|
+
else out.write(id + '\n')
|
|
624
|
+
return 0
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// ---------------------------------------------------------------------------
|
|
628
|
+
// `released [<range>]` — which tickets a commit range contains, off the
|
|
629
|
+
// `Refs:` trailers (kit `src/released.js` owns the parsing rules).
|
|
630
|
+
function readCommitRange(dir, range) {
|
|
631
|
+
const { execFileSync } = require('node:child_process')
|
|
632
|
+
const SEP = 'SKITTERSPEC'
|
|
633
|
+
let raw
|
|
634
|
+
try {
|
|
635
|
+
raw = execFileSync('git', ['-C', dir, 'log', `--format=%x00SKITTERSPEC%x00%H%n%B%x00END%x00`, '--name-only', range], {
|
|
636
|
+
encoding: 'utf-8',
|
|
637
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
638
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
639
|
+
})
|
|
640
|
+
} catch (error) {
|
|
641
|
+
const detail = (error.stderr || '').toString().trim().split('\n')[0] || error.message
|
|
642
|
+
return { ok: false, error: detail }
|
|
643
|
+
}
|
|
644
|
+
const commits = []
|
|
645
|
+
for (const chunk of raw.split(SEP)) {
|
|
646
|
+
if (!chunk.trim()) continue
|
|
647
|
+
const endAt = chunk.indexOf('END')
|
|
648
|
+
if (endAt < 0) continue
|
|
649
|
+
const head = chunk.slice(0, endAt)
|
|
650
|
+
const paths = chunk
|
|
651
|
+
.slice(endAt + 'END'.length)
|
|
652
|
+
.split('\n')
|
|
653
|
+
.map((l) => l.trim())
|
|
654
|
+
.filter(Boolean)
|
|
655
|
+
const nl = head.indexOf('\n')
|
|
656
|
+
const sha = head.slice(0, nl < 0 ? head.length : nl).trim()
|
|
657
|
+
const body = nl < 0 ? '' : head.slice(nl + 1)
|
|
658
|
+
commits.push({ sha, subject: body.split('\n')[0] || '', body, paths })
|
|
659
|
+
}
|
|
660
|
+
return { ok: true, commits }
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function specSyncReleased(dir, config, rangeArg, flags, out) {
|
|
664
|
+
const range = rangeArg || 'HEAD'
|
|
665
|
+
const read = readCommitRange(dir, range)
|
|
666
|
+
if (!read.ok) {
|
|
667
|
+
out.write(`${PROGRAM} released: ${read.error}\n`)
|
|
668
|
+
return 1
|
|
669
|
+
}
|
|
670
|
+
const report = kit.ticketsInRange(read.commits, {
|
|
671
|
+
ignorePaths: (config.release && config.release.ignorePaths) || [],
|
|
672
|
+
})
|
|
673
|
+
report.tickets = kit.nameTickets(report.tickets, listSpecs(dir, config, { urls: true }))
|
|
674
|
+
if (flags.json) {
|
|
675
|
+
out.write(JSON.stringify(report, null, 2) + '\n')
|
|
676
|
+
return 0
|
|
677
|
+
}
|
|
678
|
+
const lines = [`${PROGRAM} released: ${range} — ${report.total} commit(s)`]
|
|
679
|
+
for (const t of report.tickets) lines.push(` ${t.ref} (${t.commits} commit(s))${t.spec ? ` ${t.spec}` : ''}`)
|
|
680
|
+
if (!report.tickets.length) lines.push(' no referenced tickets')
|
|
681
|
+
if (report.unreferenced) lines.push(` ${report.unreferenced} commit(s) carry no Refs: trailer`)
|
|
682
|
+
if (report.ignored) lines.push(` ${report.ignored} bookkeeping commit(s) ignored`)
|
|
683
|
+
out.write(lines.join('\n') + '\n')
|
|
684
|
+
return 0
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// ---------------------------------------------------------------------------
|
|
688
|
+
// `preserve <spec>` — post the issue's current description back onto the issue
|
|
689
|
+
// as a comment BEFORE the linking push replaces it. Every cannot-tell exits 0:
|
|
690
|
+
// a missing link, an unreachable Jira, an issue with no description, and an
|
|
691
|
+
// already-preserved report are all states to report, not failures.
|
|
692
|
+
const PRESERVE_HEADING = '**Original report**'
|
|
693
|
+
const PRESERVE_MARKER = 'preserved by skitterspec before the spec replaced this description'
|
|
694
|
+
|
|
695
|
+
async function specSyncPreserve(dir, config, specArg, flags, out) {
|
|
696
|
+
const snapshotDir = resolveOrExit(specArg, dir, out)
|
|
697
|
+
if (!snapshotDir) return 1
|
|
698
|
+
const overviewFile = (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
|
|
699
|
+
const identifier = specHelpers.linkedIdentifier(path.join(snapshotDir, overviewFile), config)
|
|
700
|
+
const done = (msg) => {
|
|
701
|
+
out.write(`${PROGRAM} preserve: ${msg}\n`)
|
|
702
|
+
return 0
|
|
703
|
+
}
|
|
704
|
+
if (!identifier) return done(`${path.basename(snapshotDir)} is not linked — nothing to preserve`)
|
|
705
|
+
|
|
706
|
+
let original = null
|
|
707
|
+
if (flags.text) {
|
|
708
|
+
try {
|
|
709
|
+
original = fs.readFileSync(flags.text, 'utf-8')
|
|
710
|
+
} catch (error) {
|
|
711
|
+
out.write(`${PROGRAM} preserve: cannot read --text: ${error.message}\n`)
|
|
712
|
+
return 1
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const cred = apiAdapterFor(config, flags)
|
|
717
|
+
const transport = kit.resolveTransport(flags, config, cred.ok)
|
|
718
|
+
if (transport === 'mcp' && original == null) {
|
|
719
|
+
return done(
|
|
720
|
+
'transport = mcp — read the issue description with getJiraIssue, save it to a\n' +
|
|
721
|
+
' file, and re-run with --text <file>; the comment is then posted for you\n' +
|
|
722
|
+
` (or post it yourself with addCommentToJiraIssue, headed "${PRESERVE_HEADING}")`,
|
|
723
|
+
)
|
|
724
|
+
}
|
|
725
|
+
if (!cred.ok) return done(`no credentials — ${cred.error.split('\n')[0]}; nothing preserved`)
|
|
726
|
+
|
|
727
|
+
try {
|
|
728
|
+
const comments = await cred.adapter.listComments(identifier)
|
|
729
|
+
if (comments.some((c) => c.body && c.body.includes(PRESERVE_MARKER))) {
|
|
730
|
+
return done(`${identifier} — already preserved, nothing to do`)
|
|
731
|
+
}
|
|
732
|
+
if (original == null) {
|
|
733
|
+
const issue = await cred.adapter.readIssue(identifier)
|
|
734
|
+
if (!issue) return done(`${identifier} not found on Jira — nothing preserved`)
|
|
735
|
+
original = issue.description
|
|
736
|
+
}
|
|
737
|
+
if (original == null || !String(original).trim()) {
|
|
738
|
+
return done(`${identifier} has no description — nothing to preserve`)
|
|
739
|
+
}
|
|
740
|
+
await cred.adapter.createComment(identifier, `${PRESERVE_HEADING}\n\n_${PRESERVE_MARKER}_\n\n${original}`)
|
|
741
|
+
return done(`${identifier} — original report preserved as a comment (${String(original).length} characters kept)`)
|
|
742
|
+
} catch (error) {
|
|
743
|
+
return done(`could not reach Jira (${String(error.message).split('\n')[0]}); nothing preserved`)
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// ---------------------------------------------------------------------------
|
|
748
|
+
// Credentials: `status` is a READINESS check (exit 1 with none); `set`
|
|
749
|
+
// prompts — the email visibly (it is not a secret; it appears on every
|
|
750
|
+
// issue), the token hidden; `unset` removes the site's entry.
|
|
751
|
+
async function specSyncCredentials(dir, config, action, flags, out) {
|
|
752
|
+
// The store is USER-LEVEL — a machine credential must be storable before any
|
|
753
|
+
// repo is configured — so the site (the store's scope key) may come from the
|
|
754
|
+
// flag as well as the config. SETUP.md's ordering depends on this.
|
|
755
|
+
const site = (flags.siteUrl || (config.jira && config.jira.siteUrl) || '').replace(/\/+$/, '')
|
|
756
|
+
if (!site) {
|
|
757
|
+
out.write(
|
|
758
|
+
`${PROGRAM} credentials: no site to key the entry by — pass --site-url\n` +
|
|
759
|
+
` https://<your-site>.atlassian.net, or configure ${CONFIG_FILE} first\n` +
|
|
760
|
+
' (/spec-jira-setup, or spec-sync init-config).\n',
|
|
761
|
+
)
|
|
762
|
+
return 1
|
|
763
|
+
}
|
|
764
|
+
const env = flags.env || process.env
|
|
765
|
+
const file = storePath(env)
|
|
766
|
+
|
|
767
|
+
if (action === 'status') {
|
|
768
|
+
const resolved = resolveCredential(config, env)
|
|
769
|
+
const mode = storeMode(file)
|
|
770
|
+
const payload = {
|
|
771
|
+
store: file,
|
|
772
|
+
mode,
|
|
773
|
+
site,
|
|
774
|
+
credentials: resolved.ok
|
|
775
|
+
? { present: true, source: resolved.source, email: resolved.email, token: fingerprint(resolved.token) }
|
|
776
|
+
: { present: false },
|
|
777
|
+
}
|
|
778
|
+
if (flags.json) {
|
|
779
|
+
out.write(JSON.stringify(payload, null, 2) + '\n')
|
|
780
|
+
return resolved.ok ? 0 : 1
|
|
781
|
+
}
|
|
782
|
+
const lines = [`${PROGRAM} credentials:`]
|
|
783
|
+
lines.push(` store: ${file}${mode ? ` (${mode})` : ' (not created yet)'}`)
|
|
784
|
+
lines.push(` site: ${site}`)
|
|
785
|
+
if (resolved.ok) {
|
|
786
|
+
const where = resolved.source === 'env' ? `environment (${resolved.envVars.join(', ')})` : 'store'
|
|
787
|
+
lines.push(` email: ${resolved.email}`)
|
|
788
|
+
lines.push(` token: set — ${fingerprint(resolved.token)} from the ${where}`)
|
|
789
|
+
} else {
|
|
790
|
+
lines.push(' token: not set')
|
|
791
|
+
lines.push('')
|
|
792
|
+
lines.push(' Run this yourself, in your own terminal — not through an assistant:')
|
|
793
|
+
lines.push(' skitterspec-jira spec-sync credentials set')
|
|
794
|
+
}
|
|
795
|
+
out.write(lines.join('\n') + '\n')
|
|
796
|
+
return resolved.ok ? 0 : 1
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if (action === 'set') {
|
|
800
|
+
if (flags.keyArgGiven) {
|
|
801
|
+
out.write(
|
|
802
|
+
`${PROGRAM} credentials: --key is not supported, on purpose.\n` +
|
|
803
|
+
' A secret in the command line is visible in shell history and to `ps`.\n' +
|
|
804
|
+
' Run `credentials set` with no arguments and paste at the prompt (input\n' +
|
|
805
|
+
' is hidden), or pipe it: `… | credentials set --email <addr> --stdin`.\n',
|
|
806
|
+
)
|
|
807
|
+
return 1
|
|
808
|
+
}
|
|
809
|
+
let email = flags.email || ''
|
|
810
|
+
let token = ''
|
|
811
|
+
const input = flags.input || process.stdin
|
|
812
|
+
if (flags.stdin) {
|
|
813
|
+
// A TTY on stdin is positive evidence that nothing was piped: `--stdin`
|
|
814
|
+
// then waits for an EOF a terminal never sends. Refuse and name the two
|
|
815
|
+
// working forms rather than blocking forever.
|
|
816
|
+
if (input.isTTY) {
|
|
817
|
+
out.write(
|
|
818
|
+
`${PROGRAM} credentials: --stdin expects a pipe, but stdin is a terminal.\n` +
|
|
819
|
+
' Run it without --stdin to be prompted, or pipe the token:\n' +
|
|
820
|
+
' <command that prints the token> | skitterspec-jira spec-sync credentials set --email <addr> --stdin\n',
|
|
821
|
+
)
|
|
822
|
+
return 1
|
|
823
|
+
}
|
|
824
|
+
if (!email) {
|
|
825
|
+
out.write(`${PROGRAM} credentials: --stdin needs --email <addr> (the token is the piped half)\n`)
|
|
826
|
+
return 1
|
|
827
|
+
}
|
|
828
|
+
token = (await kit.readAllStdin(input)).trim()
|
|
829
|
+
} else {
|
|
830
|
+
if (!input.isTTY) {
|
|
831
|
+
out.write(
|
|
832
|
+
`${PROGRAM} credentials: not a terminal — cannot prompt.\n` +
|
|
833
|
+
' Run it in your own terminal, or pipe the token with --email and --stdin.\n',
|
|
834
|
+
)
|
|
835
|
+
return 1
|
|
836
|
+
}
|
|
837
|
+
if (!email) {
|
|
838
|
+
const readline = require('node:readline')
|
|
839
|
+
email = await new Promise((resolve) => {
|
|
840
|
+
const rl = readline.createInterface({ input, output: out, terminal: true })
|
|
841
|
+
rl.question(`Jira account email for ${site}: `, (answer) => {
|
|
842
|
+
rl.close()
|
|
843
|
+
resolve(String(answer || '').trim())
|
|
844
|
+
})
|
|
845
|
+
})
|
|
846
|
+
}
|
|
847
|
+
if (!email) {
|
|
848
|
+
out.write(`${PROGRAM} credentials: empty email — nothing stored.\n`)
|
|
849
|
+
return 1
|
|
850
|
+
}
|
|
851
|
+
token = (await kit.promptHidden(`Jira API token for ${email} (hidden): `, input, out)).trim()
|
|
852
|
+
}
|
|
853
|
+
if (!token) {
|
|
854
|
+
out.write(`${PROGRAM} credentials: empty token — nothing stored.\n`)
|
|
855
|
+
return 1
|
|
856
|
+
}
|
|
857
|
+
const r = writeCredential(file, site, email, token)
|
|
858
|
+
if (!r.ok) {
|
|
859
|
+
out.write(`${PROGRAM} credentials: ${r.reason}\n`)
|
|
860
|
+
return 1
|
|
861
|
+
}
|
|
862
|
+
out.write(`${PROGRAM} credentials: stored for ${site} in ${r.path} (600)\n`)
|
|
863
|
+
return 0
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
if (action === 'unset') {
|
|
867
|
+
const r = removeCredential(file, site)
|
|
868
|
+
if (!r.ok) {
|
|
869
|
+
out.write(`${PROGRAM} credentials: ${r.reason}\n`)
|
|
870
|
+
return 1
|
|
871
|
+
}
|
|
872
|
+
out.write(
|
|
873
|
+
r.removed
|
|
874
|
+
? `${PROGRAM} credentials: removed the entry for ${site} from ${r.path}\n`
|
|
875
|
+
: `${PROGRAM} credentials: nothing stored for ${site} — nothing to remove\n`,
|
|
876
|
+
)
|
|
877
|
+
return 0
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
out.write('Usage: skitterspec-jira spec-sync credentials <status|set|unset> [--email <addr>] [--stdin] [--json]\n')
|
|
881
|
+
return 1
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// ---------------------------------------------------------------------------
|
|
885
|
+
async function specSyncInitConfig(dir, flags, out) {
|
|
886
|
+
const file = path.join(dir, CONFIG_FILE)
|
|
887
|
+
if (fs.existsSync(file) && !flags.force) {
|
|
888
|
+
out.write(`${PROGRAM} init-config: ${CONFIG_FILE} already exists — pass --force to overwrite\n`)
|
|
889
|
+
return 1
|
|
890
|
+
}
|
|
891
|
+
if (!flags.siteUrl || !flags.projectKey) {
|
|
892
|
+
out.write(`${PROGRAM} init-config: --site-url and --project-key are required\n`)
|
|
893
|
+
return 1
|
|
894
|
+
}
|
|
895
|
+
// Only the keys the operator set are written; everything else stays a
|
|
896
|
+
// loader default, validated through the loader's own merge so a bad value
|
|
897
|
+
// fails HERE rather than on the first push.
|
|
898
|
+
const parsed = { jira: { siteUrl: flags.siteUrl, projectKey: flags.projectKey } }
|
|
899
|
+
if (flags.issueType) parsed.jira.issueType = flags.issueType
|
|
900
|
+
if (flags.subtaskType) parsed.jira.subtaskType = flags.subtaskType
|
|
901
|
+
if (flags.stateNames && Object.keys(flags.stateNames).length) {
|
|
902
|
+
for (const bucket of Object.keys(flags.stateNames)) {
|
|
903
|
+
if (!LIFECYCLE_BUCKETS.includes(bucket)) {
|
|
904
|
+
out.write(
|
|
905
|
+
`${PROGRAM} init-config: --state ${bucket}=… is not a lifecycle bucket (${LIFECYCLE_BUCKETS.join('|')})\n`,
|
|
906
|
+
)
|
|
907
|
+
return 1
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
parsed.states = flags.stateNames
|
|
911
|
+
}
|
|
912
|
+
let merged
|
|
913
|
+
try {
|
|
914
|
+
merged = mergeConfig(defaults(), parsed)
|
|
915
|
+
} catch (error) {
|
|
916
|
+
out.write(`${PROGRAM} init-config: ${error.message}\n`)
|
|
917
|
+
return 1
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// Validate the states map against the project's REAL statuses whenever the
|
|
921
|
+
// credentials can reach it — a map this command could have checked must not
|
|
922
|
+
// sail through to fail at doctor or, worse, at the first half-applied push.
|
|
923
|
+
// Refuses with per-bucket suggestions and writes NOTHING; it never picks a
|
|
924
|
+
// mapping itself, because "Done -> On Live" is a judgment only the operator
|
|
925
|
+
// can make (`/spec-jira-setup` is the surface that asks). Unreachable or
|
|
926
|
+
// credential-less is a cannot-tell: the config is still written, with one
|
|
927
|
+
// line saying what was not checked.
|
|
928
|
+
const probe = { jira: { siteUrl: merged.jira.siteUrl }, auth: merged.auth }
|
|
929
|
+
const cred = flags.adapter
|
|
930
|
+
? { ok: true }
|
|
931
|
+
: resolveCredential(probe, flags.env || process.env)
|
|
932
|
+
if (cred.ok) {
|
|
933
|
+
const adapter =
|
|
934
|
+
flags.adapter ||
|
|
935
|
+
makeApiAdapter({ siteUrl: merged.jira.siteUrl, email: cred.email, token: cred.token, fetch: flags.fetch })
|
|
936
|
+
let names = null
|
|
937
|
+
try {
|
|
938
|
+
names = (await adapter.listIssueStates(merged.jira.projectKey)).map((st) => st.name)
|
|
939
|
+
} catch (error) {
|
|
940
|
+
out.write(`${PROGRAM} init-config: could not check the project's statuses (${String(error.message).split('\n')[0]}) — doctor --check-remote will\n`)
|
|
941
|
+
}
|
|
942
|
+
if (names) {
|
|
943
|
+
const missing = [...new Set(validateStates(merged, names))]
|
|
944
|
+
if (missing.length) {
|
|
945
|
+
const lines = [`${PROGRAM} init-config: refusing — nothing written`, '']
|
|
946
|
+
for (const { label, configured, suggestion } of stateSuggestions(merged, names)) {
|
|
947
|
+
lines.push(` ${label}: "${configured}" is not a status in ${merged.jira.projectKey}`)
|
|
948
|
+
if (suggestion) lines.push(` try --state ${label}="${suggestion}"`)
|
|
949
|
+
}
|
|
950
|
+
lines.push(
|
|
951
|
+
'',
|
|
952
|
+
` available: ${names.join(', ')}`,
|
|
953
|
+
' Map every lifecycle bucket to one of those with --state <bucket>=<name>,',
|
|
954
|
+
' or run /spec-jira-setup to be walked through it.',
|
|
955
|
+
)
|
|
956
|
+
out.write(lines.join('\n') + '\n')
|
|
957
|
+
return 1
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
} else {
|
|
961
|
+
out.write(`${PROGRAM} init-config: states not checked against the project (no credentials) — doctor --check-remote will\n`)
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
965
|
+
fs.writeFileSync(file, JSON.stringify(parsed, null, 2) + '\n')
|
|
966
|
+
out.write(`${PROGRAM} init-config: wrote ${CONFIG_FILE}\n`)
|
|
967
|
+
// A worktree forks from a COMMIT, so an uncommitted config is invisible in
|
|
968
|
+
// every spec worktree — where the sync skills actually run. Say so here,
|
|
969
|
+
// once, rather than letting each worktree report "Jira sync is off".
|
|
970
|
+
out.write(` commit it — spec worktrees fork from commits, and sync is off wherever this file is absent\n`)
|
|
971
|
+
return 0
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// ---------------------------------------------------------------------------
|
|
975
|
+
// Doctor: readiness across the layers this provider owns. The row framework
|
|
976
|
+
// and the missing-is-not-broken discipline are the kit's.
|
|
977
|
+
async function specSyncDoctor(dir, flags, out) {
|
|
978
|
+
const { doctorRow: row, combineDoctorChecks } = kit
|
|
979
|
+
const checks = []
|
|
980
|
+
let loaded = null
|
|
981
|
+
try {
|
|
982
|
+
loaded = loadJiraConfig(dir)
|
|
983
|
+
} catch (error) {
|
|
984
|
+
checks.push(row('tracker', 'Jira config', 'broken', error.message, `fix ${CONFIG_FILE}`))
|
|
985
|
+
}
|
|
986
|
+
if (loaded) {
|
|
987
|
+
if (!loaded.present) {
|
|
988
|
+
checks.push(
|
|
989
|
+
row(
|
|
990
|
+
'tracker',
|
|
991
|
+
'Jira config',
|
|
992
|
+
'missing',
|
|
993
|
+
`${CONFIG_FILE} not present — Jira sync is off`,
|
|
994
|
+
'skitterspec-jira spec-sync init-config --site-url … --project-key …',
|
|
995
|
+
),
|
|
996
|
+
)
|
|
997
|
+
} else if (!loaded.config.jira.siteUrl || !loaded.config.jira.projectKey) {
|
|
998
|
+
checks.push(row('tracker', 'Jira config', 'broken', 'siteUrl or projectKey is empty', `fix ${CONFIG_FILE}`))
|
|
999
|
+
} else {
|
|
1000
|
+
checks.push(row('tracker', 'Jira config', 'ok', `${loaded.config.jira.projectKey} @ ${loaded.config.jira.siteUrl}`))
|
|
1001
|
+
// A worktree forks from a COMMIT, so a config that exists only in the
|
|
1002
|
+
// working tree is invisible in every spec worktree — where the sync
|
|
1003
|
+
// skills actually run — and sync there reports itself off with no clue
|
|
1004
|
+
// pointing back here. The positive signal is git tracking the file;
|
|
1005
|
+
// every cannot-tell (no git repo, no git binary, a crashed call) stays
|
|
1006
|
+
// silent rather than accusing a healthy setup (negative-checks rule 4).
|
|
1007
|
+
// WHAT WOULD FOOL THIS: tracked-but-modified — worktrees then see the
|
|
1008
|
+
// last committed mapping, not this one. Lesser and not warned on.
|
|
1009
|
+
try {
|
|
1010
|
+
const { execFileSync } = require('node:child_process')
|
|
1011
|
+
const tracked = execFileSync('git', ['-C', dir, 'ls-files', '--', CONFIG_FILE], {
|
|
1012
|
+
encoding: 'utf-8',
|
|
1013
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1014
|
+
}).trim()
|
|
1015
|
+
const inRepo = execFileSync('git', ['-C', dir, 'rev-parse', '--is-inside-work-tree'], {
|
|
1016
|
+
encoding: 'utf-8',
|
|
1017
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1018
|
+
}).trim()
|
|
1019
|
+
if (inRepo === 'true' && !tracked) {
|
|
1020
|
+
checks.push(
|
|
1021
|
+
row(
|
|
1022
|
+
'tracker',
|
|
1023
|
+
'Config committed',
|
|
1024
|
+
'warn',
|
|
1025
|
+
`${CONFIG_FILE} is untracked — invisible in spec worktrees, sync is off there`,
|
|
1026
|
+
`git add -- ${CONFIG_FILE} && git commit`,
|
|
1027
|
+
),
|
|
1028
|
+
)
|
|
1029
|
+
}
|
|
1030
|
+
} catch {
|
|
1031
|
+
// cannot tell — say nothing
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
const config = loaded && loaded.present ? loaded.config : null
|
|
1036
|
+
if (config && config.jira.siteUrl) {
|
|
1037
|
+
const cred = resolveCredential(config, flags.env || process.env)
|
|
1038
|
+
checks.push(
|
|
1039
|
+
cred.ok
|
|
1040
|
+
? row('credentials', 'Credentials', 'ok', `${cred.email} (token ${fingerprint(cred.token)}, ${cred.source})`)
|
|
1041
|
+
: row('credentials', 'Credentials', 'missing', cred.error.split('\n')[0], 'skitterspec-jira spec-sync credentials set'),
|
|
1042
|
+
)
|
|
1043
|
+
if (flags.remoteCheck && cred.ok) {
|
|
1044
|
+
const adapter =
|
|
1045
|
+
flags.adapter ||
|
|
1046
|
+
makeApiAdapter({ siteUrl: config.jira.siteUrl, email: cred.email, token: cred.token, fetch: flags.fetch })
|
|
1047
|
+
try {
|
|
1048
|
+
const viewer = await adapter.readViewer()
|
|
1049
|
+
checks.push(row('remote', 'Jira reachable', 'ok', viewer ? `as ${viewer.name || viewer.id}` : 'yes'))
|
|
1050
|
+
const states = await adapter.listIssueStates(config.jira.projectKey)
|
|
1051
|
+
// Deduped: complete and cancelled both map to one status by default,
|
|
1052
|
+
// and "not in the project: Done, Done" reads as a stutter, not a fact.
|
|
1053
|
+
const missing = [...new Set(validateStates(config, states.map((s) => s.name)))]
|
|
1054
|
+
checks.push(
|
|
1055
|
+
missing.length
|
|
1056
|
+
? row(
|
|
1057
|
+
'states',
|
|
1058
|
+
'Status names',
|
|
1059
|
+
'broken',
|
|
1060
|
+
`not in the project: ${missing.join(', ')}`,
|
|
1061
|
+
`fix ${CONFIG_FILE} -> states (see spec-sync states)`,
|
|
1062
|
+
)
|
|
1063
|
+
: row('states', 'Status names', 'ok', 'every configured status exists'),
|
|
1064
|
+
)
|
|
1065
|
+
} catch (error) {
|
|
1066
|
+
checks.push(row('remote', 'Jira reachable', 'broken', String(error.message).split('\n')[0], 'check credentials and siteUrl'))
|
|
1067
|
+
}
|
|
1068
|
+
} else if (flags.remoteCheck) {
|
|
1069
|
+
checks.push(row('remote', 'Jira reachable', 'skipped', 'no credentials to check with'))
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
const report = combineDoctorChecks(checks)
|
|
1073
|
+
if (flags.json) {
|
|
1074
|
+
out.write(JSON.stringify(report, null, 2) + '\n')
|
|
1075
|
+
return report.ok ? 0 : 1
|
|
1076
|
+
}
|
|
1077
|
+
const mark = { ok: 'ok', missing: '--', broken: 'XX', skipped: '..', warn: '!!' }
|
|
1078
|
+
const lines = [`${PROGRAM} doctor:`]
|
|
1079
|
+
for (const c of report.checks) {
|
|
1080
|
+
lines.push(` ${mark[c.state] || '??'} ${c.label}: ${c.detail}`)
|
|
1081
|
+
if (c.fix && c.state !== 'ok') lines.push(` fix: ${c.fix}`)
|
|
1082
|
+
}
|
|
1083
|
+
out.write(lines.join('\n') + '\n')
|
|
1084
|
+
return report.ok ? 0 : 1
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// ---------------------------------------------------------------------------
|
|
1088
|
+
const USAGE = `Usage: skitterspec-jira spec-sync <subcommand>
|
|
1089
|
+
|
|
1090
|
+
normalize <spec> print the local projection as JSON
|
|
1091
|
+
plan <spec> --workspace-states <f> what a push would change (dry, JSON)
|
|
1092
|
+
apply <spec> --plan <f> | --all <b> apply a plan over the API
|
|
1093
|
+
stamp <spec> --issue KEY [--sub r=K] write returned keys into the spec
|
|
1094
|
+
record <spec> write the last-pushed snapshot
|
|
1095
|
+
status <spec> [--remote <f>] read-only drift report
|
|
1096
|
+
verify <spec> --stored <f> compare a read-back against the repo
|
|
1097
|
+
states [--json] the project's status names
|
|
1098
|
+
list [--all | --state NAME] tracker listing for the project
|
|
1099
|
+
linked [--json] specs and their linked issues (offline)
|
|
1100
|
+
ref [<spec>] the Refs: trailer value for this branch
|
|
1101
|
+
released [<range>] tickets a commit range contains
|
|
1102
|
+
preserve <spec> [--text <f>] keep an adopted issue's original report
|
|
1103
|
+
credentials <status|set|unset> the email + API-token pair (user-level)
|
|
1104
|
+
init-config --site-url … --project-key … write the config
|
|
1105
|
+
doctor [--check-remote] [--json] readiness report
|
|
1106
|
+
|
|
1107
|
+
Not yet shipped for Jira (the Linear provider has them): assign/whoami/users,
|
|
1108
|
+
stage, retarget, reattach, sanitise.
|
|
1109
|
+
`
|
|
1110
|
+
|
|
1111
|
+
async function specSync(rest, io = {}) {
|
|
1112
|
+
const out = io.out || process.stdout
|
|
1113
|
+
const err = io.err || process.stderr
|
|
1114
|
+
const [sub, ...args] = rest
|
|
1115
|
+
let dir = io.cwd || process.cwd()
|
|
1116
|
+
|
|
1117
|
+
const { flags, positional, unknown } = parseArgs(args, sub)
|
|
1118
|
+
if (unknown.length) {
|
|
1119
|
+
out.write(kit.unknownFlagLines(PROGRAM, unknown).join('\n') + '\n')
|
|
1120
|
+
return 1
|
|
1121
|
+
}
|
|
1122
|
+
if (flags.dirOverride) dir = flags.dirOverride
|
|
1123
|
+
dir = path.resolve(dir)
|
|
1124
|
+
// Injection seam, alongside cwd/out/err: `env` supplies the credential
|
|
1125
|
+
// lookup and `adapter`/`fetch` stand in for the network, so every command is
|
|
1126
|
+
// exercised end to end offline. Production passes none and gets the real thing.
|
|
1127
|
+
flags.env = io.env || process.env
|
|
1128
|
+
if (io.adapter) flags.adapter = io.adapter
|
|
1129
|
+
if (io.fetch) flags.fetch = io.fetch
|
|
1130
|
+
if (io.input) flags.input = io.input
|
|
1131
|
+
if (io.tty !== undefined) out.isTTY = io.tty
|
|
1132
|
+
|
|
1133
|
+
if (sub === 'init-config') return specSyncInitConfig(dir, flags, out)
|
|
1134
|
+
if (sub === 'doctor') return specSyncDoctor(dir, flags, out)
|
|
1135
|
+
|
|
1136
|
+
let loaded
|
|
1137
|
+
try {
|
|
1138
|
+
loaded = loadJiraConfig(dir)
|
|
1139
|
+
} catch (error) {
|
|
1140
|
+
out.write(`${PROGRAM}: ${error.message}\n`)
|
|
1141
|
+
return 1
|
|
1142
|
+
}
|
|
1143
|
+
// Credentials are user-level, not repo-level: storing or checking them must
|
|
1144
|
+
// work before the repo opts in, or setup has a circular order (the smoke
|
|
1145
|
+
// found exactly this — `credentials set` refused on a fresh repo).
|
|
1146
|
+
if (sub === 'credentials') return specSyncCredentials(dir, loaded.config, positional[0], flags, out)
|
|
1147
|
+
if (!loaded.present) {
|
|
1148
|
+
// Opt-out, not an error: a repo with no jira config has nothing to sync.
|
|
1149
|
+
out.write(`${PROGRAM}: ${CONFIG_FILE} not present — Jira sync is off for this repo\n`)
|
|
1150
|
+
return 0
|
|
1151
|
+
}
|
|
1152
|
+
const config = loaded.config
|
|
1153
|
+
|
|
1154
|
+
switch (sub) {
|
|
1155
|
+
case 'normalize':
|
|
1156
|
+
return specSyncNormalize(dir, config, positional[0], out, err)
|
|
1157
|
+
case 'plan':
|
|
1158
|
+
return specSyncPlan(dir, config, positional[0], flags, out, err)
|
|
1159
|
+
case 'apply':
|
|
1160
|
+
return specSyncApply(dir, config, positional[0], flags, out, err)
|
|
1161
|
+
case 'stamp':
|
|
1162
|
+
return specSyncStamp(dir, config, positional[0], flags, out)
|
|
1163
|
+
case 'record':
|
|
1164
|
+
return specSyncRecord(dir, config, positional[0], out)
|
|
1165
|
+
case 'status':
|
|
1166
|
+
return specSyncStatus(dir, config, positional[0], flags, out, err)
|
|
1167
|
+
case 'verify':
|
|
1168
|
+
return specSyncVerify(dir, config, positional[0], flags, out)
|
|
1169
|
+
case 'linked':
|
|
1170
|
+
return specSyncLinked(dir, config, flags, out)
|
|
1171
|
+
case 'states':
|
|
1172
|
+
return specSyncStates(dir, config, flags, out)
|
|
1173
|
+
case 'list':
|
|
1174
|
+
return specSyncList(dir, config, flags, out)
|
|
1175
|
+
case 'ref':
|
|
1176
|
+
return specSyncRef(dir, config, positional[0], flags, out, err)
|
|
1177
|
+
case 'released':
|
|
1178
|
+
return specSyncReleased(dir, config, positional[0], flags, out)
|
|
1179
|
+
case 'preserve':
|
|
1180
|
+
return specSyncPreserve(dir, config, positional[0], flags, out)
|
|
1181
|
+
case 'credentials':
|
|
1182
|
+
return specSyncCredentials(dir, config, positional[0], flags, out)
|
|
1183
|
+
default:
|
|
1184
|
+
out.write(USAGE)
|
|
1185
|
+
return sub ? 1 : 0
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// The kit's table parser, plus the one flag it cannot express portably:
|
|
1190
|
+
// `apply --all <bucket>` takes a value while `list --all` is bare, so `--all`
|
|
1191
|
+
// is resolved in a pre-pass keyed on the subcommand.
|
|
1192
|
+
function parseArgs(args, sub) {
|
|
1193
|
+
const pre = []
|
|
1194
|
+
let all
|
|
1195
|
+
for (let i = 0; i < args.length; i++) {
|
|
1196
|
+
if (args[i] === '--all') {
|
|
1197
|
+
all = sub === 'list' ? true : args[++i]
|
|
1198
|
+
continue
|
|
1199
|
+
}
|
|
1200
|
+
pre.push(args[i])
|
|
1201
|
+
}
|
|
1202
|
+
const table = {
|
|
1203
|
+
'--json': { kind: 'boolean' },
|
|
1204
|
+
'--dir': { kind: 'path', name: 'dirOverride' },
|
|
1205
|
+
'--plan': { kind: 'path' },
|
|
1206
|
+
'--stored': { kind: 'path' },
|
|
1207
|
+
'--remote': { kind: 'path' },
|
|
1208
|
+
'--text': { kind: 'path' },
|
|
1209
|
+
'--workspace-states': { kind: 'path', name: 'workspaceStates' },
|
|
1210
|
+
'--skip-state-check': { kind: 'boolean', name: 'skipStateCheck' },
|
|
1211
|
+
'--via': { kind: 'string' },
|
|
1212
|
+
'--force': { kind: 'boolean' },
|
|
1213
|
+
'--force-new': { kind: 'boolean', name: 'forceNew' },
|
|
1214
|
+
'--issue': { kind: 'string' },
|
|
1215
|
+
'--url': { kind: 'value' },
|
|
1216
|
+
'--sub': { kind: 'list', name: 'subs' },
|
|
1217
|
+
'--limit': { kind: 'number' },
|
|
1218
|
+
'--email': { kind: 'string' },
|
|
1219
|
+
'--stdin': { kind: 'boolean' },
|
|
1220
|
+
'--check-remote': { kind: 'boolean', name: 'remoteCheck' },
|
|
1221
|
+
'--site-url': { kind: 'string', name: 'siteUrl' },
|
|
1222
|
+
'--project-key': { kind: 'string', name: 'projectKey' },
|
|
1223
|
+
'--issue-type': { kind: 'string', name: 'issueType' },
|
|
1224
|
+
'--subtask-type': { kind: 'string', name: 'subtaskType' },
|
|
1225
|
+
'--state': {
|
|
1226
|
+
kind: 'custom',
|
|
1227
|
+
handle: (flags, value) => {
|
|
1228
|
+
// Two readings, kept apart: `init-config --state complete=Shipped` is
|
|
1229
|
+
// a bucket=name PAIR; `list --state "In Progress"` a bare status name.
|
|
1230
|
+
const raw = String(value || '').trim()
|
|
1231
|
+
const [bucket, ...rest] = raw.split('=')
|
|
1232
|
+
if (rest.length) {
|
|
1233
|
+
flags.stateNames = { ...(flags.stateNames || {}), [bucket.trim()]: rest.join('=').trim() }
|
|
1234
|
+
}
|
|
1235
|
+
if (raw) (flags.stateArgs = flags.stateArgs || []).push(rest.length ? rest.join('=').trim() : raw)
|
|
1236
|
+
},
|
|
1237
|
+
},
|
|
1238
|
+
'--key': {
|
|
1239
|
+
kind: 'custom',
|
|
1240
|
+
handle: (flags) => {
|
|
1241
|
+
// Consumed and DELIBERATELY DISCARDED — a secret in argv is refused,
|
|
1242
|
+
// but it must still be swallowed or it would print back in usage.
|
|
1243
|
+
flags.keyArgGiven = true
|
|
1244
|
+
},
|
|
1245
|
+
},
|
|
1246
|
+
}
|
|
1247
|
+
const parsed = kit.parseFlags(pre, table)
|
|
1248
|
+
if (all !== undefined) parsed.flags.all = all
|
|
1249
|
+
return parsed
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
module.exports = { specSync, USAGE, stateCheckFailure, applyOps, PRESERVE_HEADING, PRESERVE_MARKER }
|