@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.
- package/MIGRATION.md +178 -1
- package/README.md +3 -1
- package/assets/core/SETUP.md +1 -1
- package/assets/core/env.config.md +54 -2
- package/assets/core/jira.config.md +33 -6
- package/assets/hooks/main-guard.cjs +1 -1
- package/assets/hooks/review-gate.cjs +1 -1
- package/assets/rules/spec-planning.md +13 -3
- package/assets/rules/spec-reports.md +19 -4
- package/assets/skills/no-spec/SKILL.md +6 -3
- package/assets/skills/spec-bug/SKILL.md +3 -1
- package/assets/skills/spec-complete/SKILL.md +6 -3
- package/assets/skills/spec-hotfix/SKILL.md +3 -1
- package/assets/skills/spec-jira-setup/SKILL.md +2 -1
- package/assets/skills/spec-next/SKILL.md +3 -1
- package/assets/skills/spec-status/SKILL.md +27 -25
- package/assets/skills/spec-sync/SKILL.md +6 -4
- package/assets/skills/spec-to-main/SKILL.md +5 -3
- package/bin/skitterspec-jira.js +7 -0
- package/package.json +1 -1
- package/src/cli.js +82 -16
- package/src/env/config.js +111 -14
- package/src/env/glob.js +34 -0
- package/src/env/hotfix.js +33 -5
- package/src/env/integrate.js +52 -11
- package/src/env/live.js +1 -26
- package/src/env/proxy.js +33 -0
- package/src/env/review.js +47 -2
- package/src/init.js +3 -3
- package/src/vendor/jira/cli-sync.js +5 -9
- package/src/vendor/jira/config.js +5 -15
- package/src/vendor/provider-kit/src/apply.js +10 -0
- package/src/vendor/provider-kit/src/assignment.js +277 -0
- package/src/vendor/provider-kit/src/config.js +101 -48
- package/src/vendor/provider-kit/src/http.js +30 -11
- 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/released.js +80 -87
- package/src/vendor/provider-kit/src/specs.js +89 -7
- package/src/vendor/sync-core/index.js +5 -5
- package/src/vendor/sync-core/src/compare.js +38 -6
- 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')
|
|
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
|
|
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
|
|
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
|
-
|
|
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 }
|
package/src/env/integrate.js
CHANGED
|
@@ -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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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({
|
|
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,
|
|
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
|
-
|
|
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
|
|
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}`)
|