@skitterbyte/skitterspec-jira 1.0.0 → 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.
Files changed (42) hide show
  1. package/MIGRATION.md +178 -1
  2. package/README.md +3 -1
  3. package/assets/core/SETUP.md +1 -1
  4. package/assets/core/env.config.md +54 -2
  5. package/assets/core/jira.config.md +33 -6
  6. package/assets/hooks/main-guard.cjs +1 -1
  7. package/assets/hooks/review-gate.cjs +1 -1
  8. package/assets/rules/spec-planning.md +13 -3
  9. package/assets/rules/spec-reports.md +19 -4
  10. package/assets/skills/no-spec/SKILL.md +6 -3
  11. package/assets/skills/spec-bug/SKILL.md +3 -1
  12. package/assets/skills/spec-complete/SKILL.md +6 -3
  13. package/assets/skills/spec-hotfix/SKILL.md +3 -1
  14. package/assets/skills/spec-jira-setup/SKILL.md +2 -1
  15. package/assets/skills/spec-next/SKILL.md +3 -1
  16. package/assets/skills/spec-status/SKILL.md +27 -25
  17. package/assets/skills/spec-sync/SKILL.md +6 -4
  18. package/assets/skills/spec-to-main/SKILL.md +5 -3
  19. package/bin/skitterspec-jira.js +7 -0
  20. package/package.json +1 -1
  21. package/src/cli.js +82 -16
  22. package/src/env/config.js +111 -14
  23. package/src/env/glob.js +34 -0
  24. package/src/env/hotfix.js +33 -5
  25. package/src/env/integrate.js +52 -11
  26. package/src/env/live.js +1 -26
  27. package/src/env/proxy.js +33 -0
  28. package/src/env/review.js +47 -2
  29. package/src/init.js +3 -3
  30. package/src/vendor/jira/cli-sync.js +5 -9
  31. package/src/vendor/jira/config.js +5 -15
  32. package/src/vendor/provider-kit/src/apply.js +10 -0
  33. package/src/vendor/provider-kit/src/assignment.js +277 -0
  34. package/src/vendor/provider-kit/src/config.js +101 -48
  35. package/src/vendor/provider-kit/src/http.js +30 -11
  36. package/src/vendor/provider-kit/src/list-assignee.js +71 -0
  37. package/src/vendor/provider-kit/src/list-rows.js +35 -0
  38. package/src/vendor/provider-kit/src/released.js +80 -87
  39. package/src/vendor/provider-kit/src/specs.js +89 -7
  40. package/src/vendor/sync-core/index.js +5 -5
  41. package/src/vendor/sync-core/src/compare.js +38 -6
  42. package/src/vendor/sync-core/src/normalize.js +40 -58
package/src/env/hotfix.js CHANGED
@@ -21,7 +21,7 @@
21
21
  */
22
22
 
23
23
  const path = require('node:path')
24
- const { afterLandFor } = require('./integrate.js')
24
+ const { afterLandFor, rangeOf } = require('./integrate.js')
25
25
 
26
26
  /**
27
27
  * Parse a semver-ish tag and return it with its PATCH bumped by one. Preserves any
@@ -114,12 +114,38 @@ const BUMPS = ['patch', 'hotfix-suffix']
114
114
  // The tag `hotfix.bump` asks for. An unknown strategy throws rather than
115
115
  // falling back to `patch`: a typo there would otherwise cut the wrong release
116
116
  // line and say nothing.
117
+ //
118
+ // Under `patch`, a base that is ITSELF a `-hotfix.N` tag continues that line.
119
+ // Bumping its patch takes X.Y.(Z+1), a number the mainline owns: refused when
120
+ // the mainline already cut it, and minted silently when it had not. Only a
121
+ // parsed hotfix suffix routes here; an rc or any other suffix keeps the patch
122
+ // bump, so a base we cannot read as a hotfix line is never treated as one.
117
123
  function nextTag(baseTag, existingTags, bump = 'patch') {
118
- if (bump === 'patch') return bumpPatch(baseTag)
124
+ if (bump === 'patch') {
125
+ const base = parseTag(baseTag)
126
+ if (base && base.hotfix !== null) return nextHotfixTag(baseTag, existingTags)
127
+ return bumpPatch(baseTag)
128
+ }
119
129
  if (bump === 'hotfix-suffix') return nextHotfixTag(baseTag, existingTags)
120
130
  throw new Error(`unknown hotfix.bump "${bump}" — use one of: ${BUMPS.join(', ')}`)
121
131
  }
122
132
 
133
+ // What a collision refusal offers: the free tag on the same base's hotfix
134
+ // line, which is never taken by a mainline release. Never a deletion and never
135
+ // another base — the first destroys a released tag, the second forks from a
136
+ // version prod is not running. Empty where the suffix line cannot be derived
137
+ // (an rc base) or is already the strategy, so the refusal just names the tag.
138
+ function freeTagHint(baseTag, existingTags, bump) {
139
+ if (bump !== 'patch') return ''
140
+ let free
141
+ try {
142
+ free = nextHotfixTag(baseTag, existingTags)
143
+ } catch {
144
+ return ''
145
+ }
146
+ return ` — set hotfix.bump "hotfix-suffix" in env.config.json to cut ${free} instead`
147
+ }
148
+
123
149
  // A filesystem/branch-safe slug derived from a tag (`v30.2.1` → `v30-2-1`).
124
150
  function tagSlug(tag) {
125
151
  return String(tag)
@@ -206,7 +232,7 @@ function planHotfixLand(spec, config, ctx) {
206
232
  } else {
207
233
  prodTag = nextTag(baseRef, c.existingTags, bump)
208
234
  if (taken.has(prodTag)) {
209
- return block(`tag ${prodTag} already exists — bump the base version or delete the stale tag`)
235
+ return block(`tag ${prodTag} already exists${freeTagHint(baseRef, c.existingTags, bump)}`)
210
236
  }
211
237
  taken.add(prodTag)
212
238
  commands.push(`git -C ${spec.worktreePath} tag ${prodTag}`)
@@ -248,7 +274,7 @@ function planHotfixLand(spec, config, ctx) {
248
274
  // hotfix numbers rather than colliding on the same one.
249
275
  const tag = nextTag(t, [...taken], bump)
250
276
  if (taken.has(tag)) {
251
- return block(`tag ${tag} (for target ${t}) already exists — resolve it before landing`)
277
+ return block(`tag ${tag} (for target ${t}) already exists${freeTagHint(t, [...taken], bump)}`)
252
278
  }
253
279
  taken.add(tag)
254
280
  const slug = `${spec.slug}-onto-${tagSlug(t)}`
@@ -307,7 +333,9 @@ function planHotfixLand(spec, config, ctx) {
307
333
  commits: picks,
308
334
  })
309
335
  targets.push({ kind: 'main', base: c.base })
310
- afterLand = afterLandFor(config, c.mainRepoPath)
336
+ // The range is the files the carried fix touches — `carry.files`, read by
337
+ // the caller from the fix commits; absent or unreadable → unknown → run.
338
+ afterLand = afterLandFor(config, c.mainRepoPath, rangeOf({ changedFiles: carry.files }))
311
339
  }
312
340
 
313
341
  return { ...result, commands, steps, afterLand, prodTag, targets, fixRange }
@@ -1,5 +1,7 @@
1
1
  'use strict'
2
2
 
3
+ const { globToRegExp } = require('./glob.js')
4
+
3
5
  /**
4
6
  * Pure integrate planner for `spec-env integrate`.
5
7
  *
@@ -21,7 +23,9 @@
21
23
  * fast-forward. `planIntegrateCheckout` below covers that; the shape of what it
22
24
  * returns is identical so callers need no second code path.
23
25
  *
24
- * @param {object} ctx { worktreeState: { dirty }, base, aheadOfBase, mainRepoPath }
26
+ * @param {object} ctx { worktreeState: { dirty }, base, aheadOfBase, mainRepoPath,
27
+ * changedFiles } — `changedFiles` is what the land brings
28
+ * in (`base...branch`), or null when it could not be read
25
29
  * @returns {object} { blocked, noop, reason, commands, afterLand, base, branch }
26
30
  */
27
31
  function planIntegrate(spec, config, ctx) {
@@ -39,8 +43,9 @@ function planIntegrate(spec, config, ctx) {
39
43
  // Nothing on the branch that isn't already on base → already landed. The
40
44
  // after-land commands still apply: the land may have happened on another
41
45
  // machine or in an earlier session, leaving them pending in THIS checkout.
46
+ // That earlier land's range is unknown here, so the range is null.
42
47
  if (!aheadOfBase) {
43
- return { ...result, noop: true, afterLand: afterLandFor(config, mainRepoPath) }
48
+ return { ...result, noop: true, afterLand: afterLandFor(config, mainRepoPath, null) }
44
49
  }
45
50
 
46
51
  return {
@@ -49,7 +54,7 @@ function planIntegrate(spec, config, ctx) {
49
54
  `git -C ${spec.worktreePath} rebase ${base}`,
50
55
  `git -C ${mainRepoPath} merge --ff-only ${branch}`,
51
56
  ],
52
- afterLand: afterLandFor(config, mainRepoPath),
57
+ afterLand: afterLandFor(config, mainRepoPath, rangeOf(ctx)),
53
58
  }
54
59
  }
55
60
 
@@ -65,7 +70,7 @@ function planIntegrate(spec, config, ctx) {
65
70
  * another spec's branch") for a spec that is finished, and the operator would
66
71
  * have to know to switch back by hand.
67
72
  *
68
- * ctx: { dirty, base, aheadOfBase, checkoutPath, onBranch }
73
+ * ctx: { dirty, base, aheadOfBase, checkoutPath, onBranch, changedFiles }
69
74
  */
70
75
  function planIntegrateCheckout(spec, config, ctx) {
71
76
  const { dirty, base, aheadOfBase, checkoutPath, onBranch } = ctx || {}
@@ -80,7 +85,7 @@ function planIntegrateCheckout(spec, config, ctx) {
80
85
  // would refuse the very spec this just finished landing. /spec-complete calls
81
86
  // integrate again on exactly that state.
82
87
  if (!aheadOfBase) {
83
- return { ...result, noop: true, afterLand: afterLandFor(config, checkoutPath) }
88
+ return { ...result, noop: true, afterLand: afterLandFor(config, checkoutPath, null) }
84
89
  }
85
90
  if (dirty) {
86
91
  return {
@@ -107,20 +112,56 @@ function planIntegrateCheckout(spec, config, ctx) {
107
112
  `git -C ${checkoutPath} switch ${base}`,
108
113
  `git -C ${checkoutPath} merge --ff-only ${branch}`,
109
114
  ],
110
- afterLand: afterLandFor(config, checkoutPath),
115
+ afterLand: afterLandFor(config, checkoutPath, rangeOf(ctx)),
111
116
  }
112
117
  }
113
118
 
114
119
  // The after-land half of a plan: `config.afterLand`, to run in `cwd` (the
115
120
  // primary checkout, standing on base) once the land commands have succeeded.
116
121
  // A blocked plan carries the empty form — nothing landed, so nothing follows.
117
- function afterLandFor(config, cwd) {
118
- const commands = (config && Array.isArray(config.afterLand)) ? [...config.afterLand] : []
119
- return { cwd: commands.length ? cwd : null, commands }
122
+ //
123
+ // `range` is the files the land changes: a list (possibly empty) when known,
124
+ // null when it is not. Each entry gets a decision — `always` (no `when`),
125
+ // `matched` (with the first path that hit), `skipped`, or `unknown` — and
126
+ // `commands` is every entry but the skipped ones, in config order.
127
+ //
128
+ // AN UNKNOWN RANGE RUNS THE CONDITIONAL ENTRIES. A land made elsewhere, or a
129
+ // diff git would not answer, says nothing about what changed, and only one
130
+ // reading is harmless: a redundant generate costs seconds, a skipped one leaves
131
+ // the base red with nothing pointing at why (`.claude/rules/negative-checks.md`
132
+ // rule 4). An EMPTY known range is not unknown — a land whose commits touch no
133
+ // files is healthy, and it skips them.
134
+ function afterLandFor(config, cwd, range = null) {
135
+ const raw = (config && Array.isArray(config.afterLand)) ? config.afterLand : []
136
+ const entries = raw.map((e) => decide(asEntry(e), range))
137
+ const commands = entries.filter((e) => e.decision !== 'skipped').map((e) => e.run)
138
+ return { cwd: entries.length ? cwd : null, commands, entries, range }
139
+ }
140
+
141
+ function decide(entry, range) {
142
+ if (!entry.when) return { ...entry, decision: 'always', matched: null }
143
+ if (range === null) return { ...entry, decision: 'unknown', matched: null }
144
+ const res = entry.when.map(globToRegExp)
145
+ const matched = range.find((f) => res.some((r) => r.test(f)))
146
+ return matched === undefined
147
+ ? { ...entry, decision: 'skipped', matched: null }
148
+ : { ...entry, decision: 'matched', matched }
149
+ }
150
+
151
+ // Normalised config holds `{ run, when }`; a pure caller may still hand over
152
+ // the plain string an entry is written as.
153
+ function asEntry(entry) {
154
+ return typeof entry === 'string' ? { run: entry, when: null } : { run: entry.run, when: entry.when || null }
155
+ }
156
+
157
+ // `changedFiles` from a planner's ctx, with anything that is not a list read as
158
+ // unknown — a caller that did not supply it said nothing about the range.
159
+ function rangeOf(ctx) {
160
+ return Array.isArray(ctx && ctx.changedFiles) ? ctx.changedFiles : null
120
161
  }
121
162
 
122
163
  function noAfterLand() {
123
- return { cwd: null, commands: [] }
164
+ return { cwd: null, commands: [], entries: [], range: null }
124
165
  }
125
166
 
126
- module.exports = { planIntegrate, planIntegrateCheckout, afterLandFor }
167
+ module.exports = { planIntegrate, planIntegrateCheckout, afterLandFor, rangeOf }
package/src/env/live.js CHANGED
@@ -18,6 +18,7 @@
18
18
 
19
19
  const fs = require('node:fs')
20
20
  const path = require('node:path')
21
+ const { globToRegExp } = require('./glob.js')
21
22
 
22
23
  const REQUIRED = ['spec', 'branch', 'holder', 'heldSince', 'baseMainCommit']
23
24
 
@@ -179,32 +180,6 @@ function liveStateLine(live) {
179
180
 
180
181
  // --- stateful detection (glob matching) -----------------------------------
181
182
 
182
- // Translate a restricted glob into an anchored RegExp: `**` matches across path
183
- // separators (with an optional trailing `/`), `*` matches within a segment, `?`
184
- // a single non-separator char. Enough for migration-path globs, no dependency.
185
- function globToRegExp(glob) {
186
- let re = ''
187
- for (let i = 0; i < glob.length; i++) {
188
- const ch = glob[i]
189
- if (ch === '*') {
190
- if (glob[i + 1] === '*') {
191
- re += '.*'
192
- i++
193
- if (glob[i + 1] === '/') i++ // consume the `/` in `**/`
194
- } else {
195
- re += '[^/]*'
196
- }
197
- } else if (ch === '?') {
198
- re += '[^/]'
199
- } else if ('.+^${}()|[]\\/'.includes(ch)) {
200
- re += '\\' + ch
201
- } else {
202
- re += ch
203
- }
204
- }
205
- return new RegExp('^' + re + '$')
206
- }
207
-
208
183
  // True when any file path matches any of the migration globs. Empty patterns or
209
184
  // no files → false (nothing configured / nothing changed).
210
185
  function migrationsHit(files, patterns) {
package/src/env/proxy.js CHANGED
@@ -167,12 +167,45 @@ async function portsInUseOn(port, hosts, probe = portsInUse) {
167
167
  return [...new Set(busy)]
168
168
  }
169
169
 
170
+ /**
171
+ * Which of `ports` accept a connection on `host` — "is a dev server running?".
172
+ *
173
+ * CONNECTS, never binds. A bind probe on 127.0.0.1 SUCCEEDS on macOS beside a
174
+ * server listening on every address (Vite `host: true`, Fastify `0.0.0.0`),
175
+ * because BSD lets the two coexist, so it read a running phone-on-wifi dev
176
+ * server as absent. Linux refuses the second bind, which is why CI never saw
177
+ * it. A server that answers is running on any OS. What could still fool it: a
178
+ * server bound only to a non-loopback address, which `host` does not reach.
179
+ */
180
+ async function portsAnswering(ports, host = '127.0.0.1') {
181
+ const results = await Promise.all(ports.map((p) => checkListening(p, host)))
182
+ return ports.filter((_, i) => results[i])
183
+ }
184
+
185
+ /**
186
+ * Which of `ports` are taken for a new listener on `host` — "may the proxy
187
+ * have these?". Busy when the bind fails OR something already answers.
188
+ *
189
+ * The bind alone is not enough on macOS: beside a wildcard listener it
190
+ * succeeds, and the proxy would then own `localhost` while LAN clients still
191
+ * reach the old server — two apps behind one port number. The connect catches
192
+ * that. SEQUENTIAL, bind first: the bind probe's own listener is closed before
193
+ * the connect runs, so the connect can never find the probe itself.
194
+ */
195
+ async function portsHeld(ports, host = '127.0.0.1') {
196
+ const bound = await portsInUse(ports, host)
197
+ const answering = await portsAnswering(ports, host)
198
+ return ports.filter((p) => bound.includes(p) || answering.includes(p))
199
+ }
200
+
170
201
  module.exports = {
171
202
  renderRoutes,
172
203
  createRouteServer,
173
204
  startProxy,
174
205
  portsInUse,
175
206
  portsInUseOn,
207
+ portsAnswering,
208
+ portsHeld,
176
209
  waitListening,
177
210
  }
178
211
 
package/src/env/review.js CHANGED
@@ -715,7 +715,21 @@ function reviewTierStack({ served, fileUrl, publishedUrl, config }) {
715
715
  if (!config.review.allowNetwork) {
716
716
  tiers.push({ tier: 'network', off: true, enable: 'skitterspec spec-env review allow network' })
717
717
  } else if (served && !served.loopback && served.url) {
718
- tiers.push({ tier: 'network', url: served.url, alternates: served.alternates || [] })
718
+ // LABELLED HERE, not by the renderer: `--json`'s `tiers` is the stack a
719
+ // skill copies, and an unlabelled alternate made the `(tailnet)` suffix a
720
+ // fact only the text render knew — so the one link a reader off the wifi
721
+ // could open reached the JSON indistinguishable from a second LAN address.
722
+ tiers.push({
723
+ tier: 'network',
724
+ url: served.url,
725
+ alternates: (served.alternates || []).map((u) => {
726
+ let host = ''
727
+ try {
728
+ host = new URL(u).hostname
729
+ } catch {}
730
+ return isTailnetAddress(host) ? { url: u, label: 'tailnet' } : { url: u }
731
+ }),
732
+ })
719
733
  } else {
720
734
  // Permitted, but there is no address to offer — a machine with no network,
721
735
  // or a server that could not take one. Not a problem to report as an error.
@@ -738,13 +752,42 @@ function reviewTierStack({ served, fileUrl, publishedUrl, config }) {
738
752
  }
739
753
 
740
754
  // One tier as a line, padded so the labels form a column the eye can run down.
755
+ // A tier with alternates renders them as indented `also:` lines of its own —
756
+ // they are PART of the tier, which is what puts them under `network:` rather
757
+ // than dangling after the stack (they used to print below `remote:`, against
758
+ // the comment that placed them).
741
759
  function reviewTierLine(t) {
742
760
  const label = ` ${t.tier}:`.padEnd(11)
743
- if (t.url) return `${label}${t.url}${t.note ? ` (${t.note})` : ''}`
761
+ if (t.url) {
762
+ return [
763
+ `${label}${t.url}${t.note ? ` (${t.note})` : ''}`,
764
+ ...(t.alternates || []).map((a) => ` also: ${a.url}${a.label ? ` (${a.label})` : ''}`),
765
+ ].join('\n')
766
+ }
744
767
  if (t.off) return `${label}off — turn on with: ${t.enable}`
745
768
  return `${label}— (${t.note})`
746
769
  }
747
770
 
771
+ // 100.64.0.0/10 — the CGNAT range Tailscale assigns from.
772
+ //
773
+ // WHAT WOULD FOOL THIS: a carrier-grade NAT or another overlay network using
774
+ // the same range on a utun adapter is offered too. That costs one dud `also:`
775
+ // line, where filtering it hides the only link a reader off the wifi can open.
776
+ function isTailnetAddress(address) {
777
+ const m = /^100\.(\d+)\./.exec(address)
778
+ return !!m && Number(m[1]) >= 64 && Number(m[1]) <= 127
779
+ }
780
+
781
+ // The suffix on an `also:` line whose host is a tailnet address, so it reads
782
+ // apart from the LAN one — a reader off the wifi needs to know which to tap.
783
+ function tailnetLabel(url) {
784
+ let host = ''
785
+ try {
786
+ host = new URL(url).hostname
787
+ } catch {}
788
+ return isTailnetAddress(host) ? ' (tailnet)' : ''
789
+ }
790
+
748
791
 
749
792
  // The verdicts that COMMIT, and are therefore blocked by an open comment. One
750
793
  // list, so a fifth verdict cannot become a way around the single refusal this
@@ -2482,6 +2525,8 @@ module.exports = {
2482
2525
  surfacesFor,
2483
2526
  reviewTierStack,
2484
2527
  reviewTierLine,
2528
+ isTailnetAddress,
2529
+ tailnetLabel,
2485
2530
  COMMITTING,
2486
2531
  BUTTON_SETS,
2487
2532
  DEFAULT_BUTTON_SET,
package/src/init.js CHANGED
@@ -125,7 +125,7 @@ function detectPackageManager(dir) {
125
125
 
126
126
  // The bin names a distribution may expose. A provider superset ships its own
127
127
  // name alongside `skitterspec`, and either proves the engine is reachable.
128
- const ENGINE_BINS = ['skitterspec', 'skitterspec-linear', 'skitterspec-jira']
128
+ const ENGINE_BINS = ['skitterspec', 'skitterspec-linear', 'skitterspec-jira', 'skitterspec-github', 'skitterspec-azure-boards']
129
129
 
130
130
  function binNames() {
131
131
  return process.platform === 'win32'
@@ -914,8 +914,8 @@ function resync(dir, { force = false, claudeMd = true, diff = false } = {}) {
914
914
  // never delete spec content or active config (defense-in-depth — the manifest
915
915
  // never lists these, but a tampered/foreign entry must still be refused).
916
916
  const PROTECTED_SPEC_BUCKETS = ['backlog', 'in-progress', 'complete', 'cancelled']
917
- const PROTECTED_CONFIG = ['env.config.json', 'linear.config.json', 'jira.config.json']
918
- const PROTECTED_DIRS = ['linear-base', 'linear-backups', 'jira-base', 'jira-backups']
917
+ const PROTECTED_CONFIG = ['env.config.json', 'linear.config.json', 'jira.config.json', 'github.config.json', 'azure-boards.config.json']
918
+ const PROTECTED_DIRS = ['linear-base', 'linear-backups', 'jira-base', 'jira-backups', 'github-base', 'github-backups', 'azure-base', 'azure-backups']
919
919
 
920
920
  function assertSafeToDelete(relPath, managedSet) {
921
921
  const norm = relPath.split(path.sep).join('/')
@@ -26,7 +26,6 @@ const {
26
26
  isEmptyPlan,
27
27
  validateStates,
28
28
  stateSuggestions,
29
- remoteWorkflowState,
30
29
  remoteDescriptionEdited,
31
30
  writeFrontmatter,
32
31
  stampSubIssueId,
@@ -410,13 +409,9 @@ function specSyncStatus(dir, config, specArg, flags, out, err) {
410
409
  out.write(`${PROGRAM} status: cannot read --remote: ${error.message}\n`)
411
410
  return 1
412
411
  }
413
- const bucket = remoteWorkflowState(remote, config)
414
- const local = r.projection.status
415
- if (bucket && local && bucket !== local) {
416
- lines.push(` drift: Jira says "${bucket}", the repo says "${local}" (repo wins on next push)`)
417
- } else if (bucket) {
418
- lines.push(' drift: none — Jira agrees with the repo')
419
- }
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.
420
415
  // SILENT ON EVERYTHING EXCEPT A POSITIVE ANSWER: `remoteDescriptionEdited`
421
416
  // returns null for every cannot-tell, and none of those is evidence.
422
417
  if (remoteDescriptionEdited(readBase(dir, identifier, config), remote, config) === true) {
@@ -675,12 +670,13 @@ function specSyncReleased(dir, config, rangeArg, flags, out) {
675
670
  const report = kit.ticketsInRange(read.commits, {
676
671
  ignorePaths: (config.release && config.release.ignorePaths) || [],
677
672
  })
673
+ report.tickets = kit.nameTickets(report.tickets, listSpecs(dir, config, { urls: true }))
678
674
  if (flags.json) {
679
675
  out.write(JSON.stringify(report, null, 2) + '\n')
680
676
  return 0
681
677
  }
682
678
  const lines = [`${PROGRAM} released: ${range} — ${report.total} commit(s)`]
683
- for (const t of report.tickets) lines.push(` ${t.ref} (${t.commits} commit(s))`)
679
+ for (const t of report.tickets) lines.push(` ${t.ref} (${t.commits} commit(s))${t.spec ? ` ${t.spec}` : ''}`)
684
680
  if (!report.tickets.length) lines.push(' no referenced tickets')
685
681
  if (report.unreferenced) lines.push(` ${report.unreferenced} commit(s) carry no Refs: trailer`)
686
682
  if (report.ignored) lines.push(` ${report.ignored} bookkeeping commit(s) ignored`)
@@ -63,7 +63,7 @@ const DEFAULT_CONFIG = Object.freeze({
63
63
  assigneeId: 'jira_assignee_id',
64
64
  assigneeName: 'jira_assignee_name',
65
65
  }),
66
- release: Object.freeze({ stages: Object.freeze([]), ignorePaths: Object.freeze(['specs/', 'CHANGELOG.md', 'RELEASES.md']) }),
66
+ release: Object.freeze({ ignorePaths: Object.freeze(['specs/', 'CHANGELOG.md', 'RELEASES.md']) }),
67
67
  branch: Object.freeze({ pattern: '{type}/{slug}' }),
68
68
  // Env-var NAMES, not values: Jira Cloud authenticates with an email + API
69
69
  // token pair (basic auth), so there are two of them.
@@ -102,10 +102,10 @@ function defaults() {
102
102
  jira: { ...DEFAULT_CONFIG.jira },
103
103
  mapping: { ...DEFAULT_CONFIG.mapping },
104
104
  states: { ...DEFAULT_CONFIG.states },
105
+ stateAliases: {},
105
106
  snapshot: { ...DEFAULT_CONFIG.snapshot },
106
107
  fields: { ...DEFAULT_CONFIG.fields },
107
108
  release: {
108
- stages: DEFAULT_CONFIG.release.stages.map((s) => ({ ...s })),
109
109
  ignorePaths: [...DEFAULT_CONFIG.release.ignorePaths],
110
110
  },
111
111
  branch: { ...DEFAULT_CONFIG.branch },
@@ -122,7 +122,7 @@ function defaults() {
122
122
  }
123
123
 
124
124
  const { isObject, stringList, assign } = kitConfig
125
- const { mergeFieldOwnership, mergePhaseMapping, mergeReleaseStages, mergeReleaseIgnorePaths, mergeKeyedFields } =
125
+ const { mergeStates, mergeFieldOwnership, mergePhaseMapping, mergeReleaseIgnorePaths, mergeKeyedFields } =
126
126
  kitConfig.makeMergeHelpers({
127
127
  configFile: CONFIG_FILE,
128
128
  ownership: OWNERSHIP,
@@ -158,18 +158,13 @@ function mergeConfig(base, parsed) {
158
158
  mergePhaseMapping(base.mapping, parsed.mapping)
159
159
  }
160
160
 
161
- if (isObject(parsed.states)) {
162
- for (const key of Object.keys(base.states)) {
163
- assign(base.states, parsed.states, key, 'string')
164
- }
165
- }
161
+ mergeStates(base, parsed)
166
162
 
167
163
  if (isObject(parsed.snapshot)) {
168
164
  assign(base.snapshot, parsed.snapshot, 'overviewFile', 'string')
169
165
  }
170
166
 
171
167
  if (isObject(parsed.release)) {
172
- mergeReleaseStages(base.release, parsed.release)
173
168
  mergeReleaseIgnorePaths(base.release, parsed.release)
174
169
  }
175
170
 
@@ -269,18 +264,13 @@ function loadJiraConfig(dir = process.cwd()) {
269
264
  return { config: withRuntime(mergeConfig(base, parsed)), present: true }
270
265
  }
271
266
 
272
- // The declared deployment ladder (possibly empty) and its rungs — same
273
- // accessors the Linear provider exposes, reading this provider's config.
274
- const releaseStages = (config) => (config.release && config.release.stages) || []
275
- const stageFor = (config, key) => releaseStages(config).find((s) => s.key === key) || null
267
+ // The bookkeeping path prefixes `released` ignores.
276
268
  const releaseIgnorePaths = (config) => (config.release && config.release.ignorePaths) || []
277
269
 
278
270
  module.exports = {
279
271
  loadJiraConfig,
280
272
  mergeConfig,
281
273
  defaults,
282
- releaseStages,
283
- stageFor,
284
274
  releaseIgnorePaths,
285
275
  DEFAULT_CONFIG,
286
276
  CONFIG_FILE,
@@ -49,6 +49,7 @@ const {
49
49
  stampSubIssueId,
50
50
  fieldsOf,
51
51
  remoteDescriptionEdited,
52
+ remoteSubIssueEdited,
52
53
  listPhaseFiles,
53
54
  } = require('../../sync-core')
54
55
  const { makeSpecHelpers } = require('./specs.js')
@@ -162,7 +163,16 @@ async function applyInner({ dir, config, snapshotDir, plan, adapter, ops, progre
162
163
  for (const s of projectionOf(snapshotDir, config).subIssues || []) {
163
164
  if (s.id != null) refById.set(String(s.id), s.ref)
164
165
  }
166
+ // A phase body edited on the tracker gets the same warning as the spec
167
+ // issue. The read is spent only where the snapshot recorded something to
168
+ // compare against, so an old snapshot costs no extra calls.
169
+ const base = readBase(dir, identifier, config)
165
170
  for (const sub of (plan.subIssues && plan.subIssues.update) || []) {
171
+ const streams = base && base.subIssueStreams
172
+ if (sub.goal != null && streams && streams[String(sub.id)]) {
173
+ const before = await adapter.readIssue(sub.id)
174
+ if (remoteSubIssueEdited(base, sub.id, before, config) === true) lines.push(ops.remoteEditedLine(sub.id))
175
+ }
166
176
  await ops.updateSubIssue(sub)
167
177
  result.subIssues[sub.ref || refById.get(String(sub.id)) || sub.id] = sub.id
168
178
  lines.push(` sub-issue updated: ${sub.id}`)