@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
|
@@ -7,16 +7,25 @@
|
|
|
7
7
|
* names never reach it. A commit's `Refs:` trailer is therefore the only place
|
|
8
8
|
* the ticket survives into the range a release scans — see the
|
|
9
9
|
* `commit-trailers` rule. Nothing here is tracker-specific: the identifier
|
|
10
|
-
* shape
|
|
11
|
-
*
|
|
10
|
+
* shape defaults to `KEY-123` (Linear, Jira), and a provider whose refs look
|
|
11
|
+
* different (GitHub's `#123`) passes its own `refPattern`.
|
|
12
12
|
*
|
|
13
13
|
* The parsing half is pure and takes commits as data, so the rules below are
|
|
14
14
|
* testable without git. Reading the range, and enriching refs with issue titles,
|
|
15
15
|
* belong to the caller.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
// An issue identifier: uppercase team key, dash, number.
|
|
18
|
+
// An issue identifier: uppercase team key, dash, number. The default, used
|
|
19
|
+
// whenever a caller passes no `refPattern`.
|
|
19
20
|
const IDENTIFIER_RE = /\b[A-Z][A-Z0-9]*-\d+\b/g
|
|
21
|
+
// A GitHub issue ref in the same repo: `#123`.
|
|
22
|
+
//
|
|
23
|
+
// WHAT WOULD FOOL IT: `#` is also how a commit says "issue number" in prose and
|
|
24
|
+
// how `owner/repo#123` names ANOTHER repo's issue. Prose is excluded by the
|
|
25
|
+
// trailer anchor (only a `Refs:` line is scanned); the cross-repo form is
|
|
26
|
+
// excluded by the lookbehind, so a ref into some other repo never lands on this
|
|
27
|
+
// one's release.
|
|
28
|
+
const GITHUB_REF_RE = /(?<![\w/.-])#\d+\b/g
|
|
20
29
|
// A `Refs:` trailer line. Anchored, so it is a line of its own — never prose
|
|
21
30
|
// that happens to mention the word.
|
|
22
31
|
const TRAILER_RE = /^Refs:[ \t]*(.+?)[ \t]*$/
|
|
@@ -34,7 +43,8 @@ const TRAILER_RE = /^Refs:[ \t]*(.+?)[ \t]*$/
|
|
|
34
43
|
* which is worse than missing a ticket: the missing one is noticed when someone
|
|
35
44
|
* looks for it, the invented one never is.
|
|
36
45
|
*/
|
|
37
|
-
function refsInBody(body) {
|
|
46
|
+
function refsInBody(body, refPattern) {
|
|
47
|
+
const pattern = asGlobal(refPattern) || IDENTIFIER_RE
|
|
38
48
|
const found = []
|
|
39
49
|
let fenced = false
|
|
40
50
|
for (const raw of String(body || '').split('\n')) {
|
|
@@ -48,11 +58,19 @@ function refsInBody(body) {
|
|
|
48
58
|
if (/^ {4,}|\t/.test(line)) continue
|
|
49
59
|
const m = TRAILER_RE.exec(line.trim() === line ? line : line.trimStart())
|
|
50
60
|
if (!m) continue
|
|
51
|
-
for (const id of m[1].match(
|
|
61
|
+
for (const id of m[1].match(pattern) || []) found.push(id)
|
|
52
62
|
}
|
|
53
63
|
return found
|
|
54
64
|
}
|
|
55
65
|
|
|
66
|
+
// `String.prototype.match` only returns every hit for a GLOBAL regex; a
|
|
67
|
+
// provider passing `/#\d+/` would otherwise get its first ref and lose the rest
|
|
68
|
+
// in silence. Anything that is not a RegExp is no pattern at all.
|
|
69
|
+
function asGlobal(pattern) {
|
|
70
|
+
if (!(pattern instanceof RegExp)) return null
|
|
71
|
+
return pattern.global ? pattern : new RegExp(pattern.source, `${pattern.flags}g`)
|
|
72
|
+
}
|
|
73
|
+
|
|
56
74
|
/**
|
|
57
75
|
* Whether a commit's changed paths are ENTIRELY bookkeeping.
|
|
58
76
|
*
|
|
@@ -103,10 +121,18 @@ function onlyIgnoredPaths(paths, ignorePaths) {
|
|
|
103
121
|
* Fold commits into the report a release needs.
|
|
104
122
|
*
|
|
105
123
|
* @param {Array<{sha?:string, subject?:string, body?:string, paths?:string[]}>} commits
|
|
106
|
-
* @param {{ignorePaths?:string[]}} [options]
|
|
107
|
-
* see `onlyIgnoredPaths`. Omitted (or
|
|
108
|
-
* report is what it was before the
|
|
109
|
-
*
|
|
124
|
+
* @param {{ignorePaths?:string[], refPattern?:RegExp, foreignPatterns?:RegExp[]}} [options]
|
|
125
|
+
* `ignorePaths` marks bookkeeping — see `onlyIgnoredPaths`. Omitted (or
|
|
126
|
+
* empty) means nothing is ignored, so the report is what it was before the
|
|
127
|
+
* filter existed. `refPattern` is this provider's ref shape (see
|
|
128
|
+
* `refsInBody`). `foreignPatterns` are OTHER trackers' ref shapes this repo
|
|
129
|
+
* used before — a repo that moved from Linear to GitHub still has
|
|
130
|
+
* `Refs: SKS-29` all through its history, and those commits were referenced,
|
|
131
|
+
* just not to this tracker. They are counted as `historical` rather than
|
|
132
|
+
* `unreferenced`, which exists to surface a MISSED trailer.
|
|
133
|
+
* @returns {{tickets: Array<{ref:string, commits:number}>, unreferenced:number, ignored:number, total:number, historical?:number}}
|
|
134
|
+
* `historical` is present only when `foreignPatterns` was given, so a
|
|
135
|
+
* provider that never moved trackers sees exactly the shape it always did.
|
|
110
136
|
* `tickets` is deduped in FIRST-SEEN order: a ticket touched by eight commits
|
|
111
137
|
* is listed once, where it first appears, not eight times. `ignored` is
|
|
112
138
|
* reported rather than merely subtracted — a filter that removes commits in
|
|
@@ -114,9 +140,12 @@ function onlyIgnoredPaths(paths, ignorePaths) {
|
|
|
114
140
|
*/
|
|
115
141
|
function ticketsInRange(commits, options = {}) {
|
|
116
142
|
const ignorePaths = (options && options.ignorePaths) || []
|
|
143
|
+
const refPattern = options && options.refPattern
|
|
144
|
+
const foreign = ((options && options.foreignPatterns) || []).map(asGlobal).filter(Boolean)
|
|
117
145
|
const seen = new Map()
|
|
118
146
|
let unreferenced = 0
|
|
119
147
|
let ignored = 0
|
|
148
|
+
let historical = 0
|
|
120
149
|
for (const commit of commits || []) {
|
|
121
150
|
if (onlyIgnoredPaths(commit && commit.paths, ignorePaths)) {
|
|
122
151
|
// Not counted as unreferenced either: that number exists to surface a
|
|
@@ -124,103 +153,67 @@ function ticketsInRange(commits, options = {}) {
|
|
|
124
153
|
ignored++
|
|
125
154
|
continue
|
|
126
155
|
}
|
|
127
|
-
const refs = [...new Set(refsInBody(commit && commit.body))]
|
|
156
|
+
const refs = [...new Set(refsInBody(commit && commit.body, refPattern))]
|
|
128
157
|
if (!refs.length) {
|
|
129
|
-
|
|
158
|
+
// A POSITIVE signal, never an absence: only a trailer that actually
|
|
159
|
+
// carries an older tracker's ref makes a commit historical. A commit with
|
|
160
|
+
// no trailer at all is still the gap `unreferenced` is there to name.
|
|
161
|
+
if (foreign.some((p) => refsInBody(commit && commit.body, p).length)) historical++
|
|
162
|
+
else unreferenced++
|
|
130
163
|
continue
|
|
131
164
|
}
|
|
132
165
|
for (const ref of refs) seen.set(ref, (seen.get(ref) || 0) + 1)
|
|
133
166
|
}
|
|
134
|
-
|
|
167
|
+
const report = {
|
|
135
168
|
tickets: [...seen.entries()].map(([ref, count]) => ({ ref, commits: count })),
|
|
136
169
|
unreferenced,
|
|
137
170
|
ignored,
|
|
138
171
|
total: (commits || []).length,
|
|
139
172
|
}
|
|
173
|
+
if (foreign.length) report.historical = historical
|
|
174
|
+
return report
|
|
140
175
|
}
|
|
141
176
|
|
|
142
177
|
/**
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
* Three reasons to leave a ticket alone, and each is a case where acting would
|
|
147
|
-
* be worse than not acting:
|
|
178
|
+
* Name the spec behind each ticket a range contains, so a consumer can act on
|
|
179
|
+
* a release without a second lookup.
|
|
148
180
|
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
* It may be tracker-only work, or a typo in a trailer. Either way nothing
|
|
154
|
-
* here knows what it is, so it is reported, not moved.
|
|
155
|
-
* - **unfinished** — a spec that has not reached the ceded bucket. Its code
|
|
156
|
-
* really is in the release (it landed via `/spec-to-main`), but push still
|
|
157
|
-
* owns its workflow state and would bounce it straight back. Exactly one
|
|
158
|
-
* writer per issue at any moment beats a visible flip-flop.
|
|
181
|
+
* A ref no spec has stamped keeps `spec: null` and `url: null` — never a guess
|
|
182
|
+
* from a similar folder name. It may be tracker-only work, or a typo in a
|
|
183
|
+
* trailer, and a consumer acting on an invented match is worse than one told
|
|
184
|
+
* there was none.
|
|
159
185
|
*
|
|
160
|
-
* Pure: it takes the spec list as data, so
|
|
186
|
+
* Pure: it takes the spec list as data (`listSpecs` rows), so it is testable
|
|
187
|
+
* without a repo.
|
|
161
188
|
*/
|
|
162
|
-
function
|
|
163
|
-
const
|
|
164
|
-
for (const
|
|
165
|
-
if (
|
|
189
|
+
function nameTickets(tickets, specs) {
|
|
190
|
+
const byRef = new Map()
|
|
191
|
+
for (const s of specs || []) {
|
|
192
|
+
if (s && s.identifier && !byRef.has(s.identifier)) byRef.set(s.identifier, s)
|
|
166
193
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const unfinished = []
|
|
172
|
-
for (const ticket of tickets || []) {
|
|
173
|
-
const ref = ticket && ticket.ref
|
|
174
|
-
if (!ref) continue
|
|
175
|
-
if (key && !ref.toUpperCase().startsWith(`${key}-`)) {
|
|
176
|
-
foreign.push(ticket)
|
|
177
|
-
} else if (!bucketOf.has(ref)) {
|
|
178
|
-
unlinked.push(ticket)
|
|
179
|
-
} else if (bucketOf.get(ref) !== cededBucket) {
|
|
180
|
-
unfinished.push({ ...ticket, bucket: bucketOf.get(ref) })
|
|
181
|
-
} else {
|
|
182
|
-
movable.push(ticket)
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
return { movable, foreign, unlinked, unfinished }
|
|
194
|
+
return (tickets || []).map((t) => {
|
|
195
|
+
const s = byRef.get(t.ref)
|
|
196
|
+
return { ...t, spec: s ? s.spec : null, url: s ? s.url || null : null }
|
|
197
|
+
})
|
|
186
198
|
}
|
|
187
199
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
* normal case and says nothing.
|
|
196
|
-
*
|
|
197
|
-
* `lifecycleStates` is the bucket map's own state names, and a state among them
|
|
198
|
-
* means the issue is at its LIFECYCLE position, not on a rung — even when the
|
|
199
|
-
* two names coincide. They routinely do: `states.complete` and a final `prod`
|
|
200
|
-
* rung are both naturally "Done", and without this a spec that had merely been
|
|
201
|
-
* completed would read as already deployed to prod, so its first real deploy
|
|
202
|
-
* would be warned about as a move backwards. Bucket wins over ladder here for
|
|
203
|
-
* the same reason it wins in `bucketForState`.
|
|
204
|
-
*
|
|
205
|
-
* @returns {string|null}
|
|
206
|
-
*/
|
|
207
|
-
function stageOrderWarning(stages, fromState, toKey, lifecycleStates = []) {
|
|
208
|
-
const list = Array.isArray(stages) ? stages : []
|
|
209
|
-
const to = list.findIndex((s) => s && s.key === toKey)
|
|
210
|
-
if (to < 0) return null
|
|
211
|
-
const want = String(fromState || '').toLowerCase().trim()
|
|
212
|
-
if (!want) return null
|
|
213
|
-
const lifecycle = new Set(
|
|
214
|
-
(Array.isArray(lifecycleStates) ? lifecycleStates : [])
|
|
215
|
-
.filter((n) => typeof n === 'string')
|
|
216
|
-
.map((n) => n.toLowerCase().trim()),
|
|
200
|
+
// The answer a removed release verb gives (`release`, `stage`). Exit 1, not a
|
|
201
|
+
// quiet 0: a pipeline still calling one must learn it no longer mirrors
|
|
202
|
+
// anything, rather than go on believing it does.
|
|
203
|
+
function refuseRemovedVerb(out, program, verb) {
|
|
204
|
+
out.write(
|
|
205
|
+
`${program} ${verb}: removed — skitterspec no longer writes release or deploy state; ` +
|
|
206
|
+
`\`${program} released <range> --json\` lists the tickets a range contains, for your own tooling to act on\n`,
|
|
217
207
|
)
|
|
218
|
-
|
|
219
|
-
const from = list.findIndex((s) => s && typeof s.state === 'string' && s.state.toLowerCase().trim() === want)
|
|
220
|
-
if (from < 0) return null
|
|
221
|
-
if (to < from) return `moves back from "${list[from].key}"`
|
|
222
|
-
if (to > from + 1) return `skips ${to - from - 1} rung(s) from "${list[from].key}"`
|
|
223
|
-
return null
|
|
208
|
+
return 1
|
|
224
209
|
}
|
|
225
210
|
|
|
226
|
-
module.exports = {
|
|
211
|
+
module.exports = {
|
|
212
|
+
ticketsInRange,
|
|
213
|
+
refuseRemovedVerb,
|
|
214
|
+
nameTickets,
|
|
215
|
+
refsInBody,
|
|
216
|
+
onlyIgnoredPaths,
|
|
217
|
+
IDENTIFIER_RE,
|
|
218
|
+
GITHUB_REF_RE,
|
|
219
|
+
}
|
|
@@ -14,7 +14,16 @@
|
|
|
14
14
|
const fs = require('node:fs')
|
|
15
15
|
const path = require('node:path')
|
|
16
16
|
|
|
17
|
-
const {
|
|
17
|
+
const { execFileSync } = require('node:child_process')
|
|
18
|
+
|
|
19
|
+
const {
|
|
20
|
+
BUCKETS,
|
|
21
|
+
findSpecFolder,
|
|
22
|
+
resolveSpec,
|
|
23
|
+
liveWorktreePaths,
|
|
24
|
+
resolvePrimaryCheckout,
|
|
25
|
+
} = require('../../../env/resolve.js')
|
|
26
|
+
const { loadEnvConfig } = require('../../../env/config.js')
|
|
18
27
|
const { readSnapshot, parseFrontmatter, lintPhases, fieldsOf, projectionOf, compareStored } = require('../../sync-core')
|
|
19
28
|
|
|
20
29
|
function makeSpecHelpers({ program = 'spec-sync' } = {}) {
|
|
@@ -45,22 +54,32 @@ function makeSpecHelpers({ program = 'spec-sync' } = {}) {
|
|
|
45
54
|
// Read a spec's stamped identifier without throwing: an unlinked spec, an
|
|
46
55
|
// unreadable file and a spec with no frontmatter all read as `null`.
|
|
47
56
|
function linkedIdentifier(overviewPath, config) {
|
|
48
|
-
|
|
57
|
+
return linkedFields(overviewPath, config).identifier
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The stamped identifier AND url, each `null` when absent. The url is only
|
|
61
|
+
// ever what a push stamped — never built from the identifier, because a url
|
|
62
|
+
// constructed here would be a guess about a tracker's address scheme.
|
|
63
|
+
function linkedFields(overviewPath, config) {
|
|
64
|
+
const fields = fieldsOf(config)
|
|
49
65
|
let raw
|
|
50
66
|
try {
|
|
51
67
|
raw = fs.readFileSync(overviewPath, 'utf-8')
|
|
52
68
|
} catch {
|
|
53
|
-
return null
|
|
69
|
+
return { identifier: null, url: null }
|
|
54
70
|
}
|
|
55
71
|
const { data } = parseFrontmatter(raw)
|
|
56
|
-
return
|
|
72
|
+
return {
|
|
73
|
+
identifier: data[fields.identifier] ? String(data[fields.identifier]) : null,
|
|
74
|
+
url: fields.url && data[fields.url] ? String(data[fields.url]) : null,
|
|
75
|
+
}
|
|
57
76
|
}
|
|
58
77
|
|
|
59
78
|
// Every spec under specs/<bucket>/, paired with the tracker issue it is
|
|
60
79
|
// linked to (`null` when it has never been pushed). Folder specs read
|
|
61
80
|
// `snapshot.overviewFile`; legacy bare `<name>.md` specs read the file
|
|
62
81
|
// itself. Sorted for stable output.
|
|
63
|
-
function listSpecs(dir, config) {
|
|
82
|
+
function listSpecs(dir, config, options = {}) {
|
|
64
83
|
const overviewFile = (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
|
|
65
84
|
const specs = []
|
|
66
85
|
for (const bucket of BUCKETS) {
|
|
@@ -83,12 +102,75 @@ function makeSpecHelpers({ program = 'spec-sync' } = {}) {
|
|
|
83
102
|
} else {
|
|
84
103
|
continue
|
|
85
104
|
}
|
|
86
|
-
|
|
105
|
+
const linked = linkedFields(overviewPath, config)
|
|
106
|
+
const row = { spec: name, bucket, identifier: linked.identifier }
|
|
107
|
+
// Opt-in, so every listing that never asked for a url keeps its shape.
|
|
108
|
+
if (options.urls) row.url = linked.url
|
|
109
|
+
specs.push(row)
|
|
87
110
|
}
|
|
88
111
|
}
|
|
89
112
|
return specs.sort((a, b) => a.spec.localeCompare(b.spec))
|
|
90
113
|
}
|
|
91
114
|
|
|
115
|
+
// `listSpecs`, but each spec's BUCKET read from where that spec actually
|
|
116
|
+
// lives. `/spec-start` moves a spec to `in-progress/` on its own branch, so
|
|
117
|
+
// on the base branch an in-flight spec still reads `backlog` — and a spec
|
|
118
|
+
// authored in its own worktree and not yet landed is not here at all. Each
|
|
119
|
+
// live worktree is read too, and a spec found in ITS OWN worktree (the path
|
|
120
|
+
// `resolveSpec` names for it) wins over every other copy. `source` says
|
|
121
|
+
// which: `here`, `worktree`, or `other-tree` for a spec only some other
|
|
122
|
+
// worktree carries.
|
|
123
|
+
//
|
|
124
|
+
// WHAT WOULD FOOL THIS: a worktree left behind by a declined teardown is
|
|
125
|
+
// still live, so a finished spec can read from a stale tree. And with no
|
|
126
|
+
// `env.config.json` there is no worktree layout to resolve, so this is
|
|
127
|
+
// exactly `listSpecs` — the current tree's answer, as before.
|
|
128
|
+
function listSpecsLive(dir, config) {
|
|
129
|
+
const here = listSpecs(dir, config).map((s) => ({ ...s, source: 'here' }))
|
|
130
|
+
const git = (args) => {
|
|
131
|
+
try {
|
|
132
|
+
return execFileSync('git', ['-C', dir, ...args], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
|
|
133
|
+
} catch {
|
|
134
|
+
return null
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
let primary
|
|
138
|
+
let env
|
|
139
|
+
try {
|
|
140
|
+
primary = resolvePrimaryCheckout(dir, git) || dir
|
|
141
|
+
env = loadEnvConfig(primary)
|
|
142
|
+
} catch {
|
|
143
|
+
return here
|
|
144
|
+
}
|
|
145
|
+
if (!env.present) return here
|
|
146
|
+
// Compared as REAL paths: git reports a worktree resolved through
|
|
147
|
+
// symlinks (macOS /var → /private/var) where `resolveSpec` does not.
|
|
148
|
+
const real = (p) => {
|
|
149
|
+
try {
|
|
150
|
+
return fs.realpathSync(p)
|
|
151
|
+
} catch {
|
|
152
|
+
return path.resolve(p)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const self = real(dir)
|
|
156
|
+
const byName = new Map(here.map((s) => [s.spec, s]))
|
|
157
|
+
for (const listed of liveWorktreePaths(git)) {
|
|
158
|
+
const tree = real(listed)
|
|
159
|
+
if (tree === self) continue
|
|
160
|
+
for (const s of listSpecs(tree, config)) {
|
|
161
|
+
let own = null
|
|
162
|
+
try {
|
|
163
|
+
own = real(resolveSpec(s.spec, primary, env.config, { searchDirs: [tree] }).worktreePath)
|
|
164
|
+
} catch {
|
|
165
|
+
own = null
|
|
166
|
+
}
|
|
167
|
+
if (own === tree) byName.set(s.spec, { ...s, source: 'worktree' })
|
|
168
|
+
else if (!byName.has(s.spec)) byName.set(s.spec, { ...s, source: 'other-tree' })
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return [...byName.values()].sort((a, b) => a.spec.localeCompare(b.spec))
|
|
172
|
+
}
|
|
173
|
+
|
|
92
174
|
// Resolve a spec argument to its folder, or null with a message on stdout.
|
|
93
175
|
// EVERY failure path here must print and EVERY caller must return a non-zero
|
|
94
176
|
// code: a push skill checks $? before it applies a plan, so a resolve failure
|
|
@@ -160,7 +242,7 @@ function makeSpecHelpers({ program = 'spec-sync' } = {}) {
|
|
|
160
242
|
return lines
|
|
161
243
|
}
|
|
162
244
|
|
|
163
|
-
return { resolveSnapshotDir, specIdentifier, linkedIdentifier, listSpecs, resolveOrExit, warningLines, warnToErr, verifyLines }
|
|
245
|
+
return { resolveSnapshotDir, specIdentifier, linkedIdentifier, listSpecs, listSpecsLive, resolveOrExit, warningLines, warnToErr, verifyLines }
|
|
164
246
|
}
|
|
165
247
|
|
|
166
248
|
module.exports = { makeSpecHelpers }
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* stored matches what was sent — it merges nothing (see `src/verify.js`).
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
const { ownsField, normalizeLocal, LIFECYCLE_BUCKETS, lintPhases, readSnapshot, parseFrontmatter, remoteWorkflowState,
|
|
17
|
-
const { planChanges, snapshotOf, isEmptyPlan, hashField, stableStringify, remoteDescriptionEdited } = require('./src/compare.js')
|
|
16
|
+
const { ownsField, normalizeLocal, LIFECYCLE_BUCKETS, lintPhases, readSnapshot, parseFrontmatter, remoteWorkflowState, remoteStateReading, stateNamesFor, titleFromText, validateStates, stateSuggestions, phaseModeFor } = require('./src/normalize.js')
|
|
17
|
+
const { planChanges, snapshotOf, isEmptyPlan, hashField, stableStringify, remoteDescriptionEdited, remoteSubIssueEdited } = require('./src/compare.js')
|
|
18
18
|
const { readBase, writeBase } = require('./src/base.js')
|
|
19
19
|
const { push, recordPush, projectionOf } = require('./src/push.js')
|
|
20
20
|
const { writeFrontmatter, deleteFrontmatter, stampSubIssueId, stampIssueId, findPhaseFileByTitle, listPhaseFiles } = require('./src/write.js')
|
|
@@ -41,12 +41,11 @@ module.exports = {
|
|
|
41
41
|
push,
|
|
42
42
|
recordPush,
|
|
43
43
|
remoteWorkflowState,
|
|
44
|
+
remoteStateReading,
|
|
45
|
+
stateNamesFor,
|
|
44
46
|
titleFromText,
|
|
45
47
|
validateStates,
|
|
46
48
|
stateSuggestions,
|
|
47
|
-
stageForState,
|
|
48
|
-
remoteStage,
|
|
49
|
-
LADDER_ORIGIN_BUCKET,
|
|
50
49
|
hashField,
|
|
51
50
|
stableStringify,
|
|
52
51
|
readBase,
|
|
@@ -66,6 +65,7 @@ module.exports = {
|
|
|
66
65
|
// Did someone else edit the tracker's description since the last push?
|
|
67
66
|
// Three-valued; `null` is cannot-tell and must never be reported as drift.
|
|
68
67
|
remoteDescriptionEdited,
|
|
68
|
+
remoteSubIssueEdited,
|
|
69
69
|
// Provider seams: the frontmatter field vocabulary and the markdown dialect
|
|
70
70
|
// a provider's config supplies (`config.fields` / `config.dialect`).
|
|
71
71
|
fieldsOf,
|
|
@@ -58,12 +58,12 @@ function specIssueHash(p) {
|
|
|
58
58
|
|
|
59
59
|
// Per-field hashes of the same two values.
|
|
60
60
|
//
|
|
61
|
-
// They are hashed SEPARATELY because
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
// description without re-asserting a state
|
|
61
|
+
// They are hashed SEPARATELY because skitterspec tells the tracker about its
|
|
62
|
+
// own transitions and nothing else. The description is resent whenever it
|
|
63
|
+
// changes; the state only when the spec itself moves. Welding the two meant any
|
|
64
|
+
// prose edit re-emitted the state and dragged back an issue someone had moved
|
|
65
|
+
// on the tracker — which is theirs to do. Diffing them apart is what lets a
|
|
66
|
+
// push touch the description without re-asserting a state.
|
|
67
67
|
function specIssueFieldHashes(p, config) {
|
|
68
68
|
const hashes = {
|
|
69
69
|
description: hashField(p.description ?? null),
|
|
@@ -137,6 +137,30 @@ function remoteDescriptionEdited(snapshot, remote, config) {
|
|
|
137
137
|
|
|
138
138
|
return hashField(stream(text, config)) !== recorded
|
|
139
139
|
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The same question for one phase SUB-ISSUE: has its body been edited on the
|
|
143
|
+
* tracker since the last push? It matters most where the tracker renders a
|
|
144
|
+
* phase's task list as clickable boxes — GitHub does — because ticking one
|
|
145
|
+
* there is an edit the next push replaces.
|
|
146
|
+
*
|
|
147
|
+
* The same three answers and the same cannot-tells as above, plus one of its
|
|
148
|
+
* own: no `subIssueStreams` in the snapshot, or none for this id. Every
|
|
149
|
+
* snapshot written before this existed lacks it, and a phase whose body was
|
|
150
|
+
* never sent (no goal) has nothing recorded.
|
|
151
|
+
*
|
|
152
|
+
* @param {object|null} snapshot the committed last-pushed snapshot
|
|
153
|
+
* @param {string} id the sub-issue's id as the snapshot keys it
|
|
154
|
+
* @param {object|null} remote what the tracker currently holds for it
|
|
155
|
+
* @returns {boolean|null}
|
|
156
|
+
*/
|
|
157
|
+
function remoteSubIssueEdited(snapshot, id, remote, config) {
|
|
158
|
+
const streams = snapshot && snapshot.subIssueStreams
|
|
159
|
+
if (!streams || typeof streams !== 'object' || Array.isArray(streams)) return null
|
|
160
|
+
const recorded = streams[String(id)]
|
|
161
|
+
if (typeof recorded !== 'string' || !recorded) return null
|
|
162
|
+
return remoteDescriptionEdited({ issueFields: { descriptionStream: recorded } }, remote, config)
|
|
163
|
+
}
|
|
140
164
|
// A phase SUB-ISSUE: its name, goal and state (all repo-owned).
|
|
141
165
|
const subIssueHash = (s) => hashField({ name: s.name ?? null, goal: s.goal ?? null, state: s.state ?? null })
|
|
142
166
|
|
|
@@ -160,6 +184,13 @@ function snapshotOf(projection, config) {
|
|
|
160
184
|
issue: specIssueHash(p),
|
|
161
185
|
issueFields: specIssueFieldHashes(p, config),
|
|
162
186
|
subIssues: byId(p.subIssues, subIssueHash),
|
|
187
|
+
// Recorded and never planned on, like `descriptionStream`: `planChanges`
|
|
188
|
+
// reads `subIssues` by name, so this key cannot make a push pending. Only a
|
|
189
|
+
// phase whose body is actually sent (a string goal) gets one.
|
|
190
|
+
subIssueStreams: byId(
|
|
191
|
+
(p.subIssues || []).filter((s) => s && typeof s.goal === 'string'),
|
|
192
|
+
(s) => hashField(stream(s.goal, config)),
|
|
193
|
+
),
|
|
163
194
|
}
|
|
164
195
|
}
|
|
165
196
|
|
|
@@ -295,6 +326,7 @@ function isEmptyPlan(plan) {
|
|
|
295
326
|
module.exports = {
|
|
296
327
|
planChanges,
|
|
297
328
|
remoteDescriptionEdited,
|
|
329
|
+
remoteSubIssueEdited,
|
|
298
330
|
issueChanges,
|
|
299
331
|
specIssueFieldHashes,
|
|
300
332
|
snapshotOf,
|
|
@@ -570,6 +570,15 @@ const UNSTARTED_BUCKETS = ['backlog', 'cancelled']
|
|
|
570
570
|
// Buckets where a spec is FINISHED, and so has nobody working on it.
|
|
571
571
|
const TERMINAL_BUCKETS = ['complete', 'cancelled']
|
|
572
572
|
|
|
573
|
+
// A phase's state once its SPEC's bucket has a say. Cancelling a spec abandons
|
|
574
|
+
// every phase not already done — left alone, those sub-issues stay open in
|
|
575
|
+
// Backlog under a closed parent, which reads as work still owed. A phase that
|
|
576
|
+
// was finished keeps `complete`: it shipped before the spec was dropped.
|
|
577
|
+
function phaseStateUnder(workflowState, phaseState) {
|
|
578
|
+
if (workflowState === 'cancelled' && phaseState !== 'complete') return 'cancelled'
|
|
579
|
+
return phaseState
|
|
580
|
+
}
|
|
581
|
+
|
|
573
582
|
/**
|
|
574
583
|
* The Linear user this spec is assigned to, AS PROJECTED — which is not simply
|
|
575
584
|
* what the frontmatter records.
|
|
@@ -714,7 +723,7 @@ function normalizeLocal(snapshotDir, config) {
|
|
|
714
723
|
ref: p.phase,
|
|
715
724
|
name: p.name,
|
|
716
725
|
goal: subIssueBody(p, tasksMode, config.sync.localOnlySections, dialect),
|
|
717
|
-
state: p.state,
|
|
726
|
+
state: phaseStateUnder(workflowState, p.state),
|
|
718
727
|
})),
|
|
719
728
|
workflowState,
|
|
720
729
|
// Who is building this. `toFieldSet` drops it unless the repo listed
|
|
@@ -742,39 +751,27 @@ function phasesWithheld(snapshotDir, config) {
|
|
|
742
751
|
|
|
743
752
|
// --- remote projection ------------------------------------------------------
|
|
744
753
|
|
|
745
|
-
//
|
|
746
|
-
//
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
if (state == null) return null
|
|
754
|
-
const want = String(state).toLowerCase().trim()
|
|
755
|
-
const stages = (config && config.release && config.release.stages) || []
|
|
756
|
-
if (!Array.isArray(stages)) return null
|
|
757
|
-
return stages.find((s) => s && typeof s.state === 'string' && s.state.toLowerCase().trim() === want) || null
|
|
754
|
+
// Every tracker state NAME that means this bucket: the one push writes
|
|
755
|
+
// (`states[bucket]`) first, then its read-only aliases (`stateAliases[bucket]`,
|
|
756
|
+
// from the array form of `states`). Readers use the whole list; writers only
|
|
757
|
+
// ever use `states[bucket]`, so an alias can never be written.
|
|
758
|
+
function stateNamesFor(bucket, config) {
|
|
759
|
+
const primary = config && config.states && config.states[bucket]
|
|
760
|
+
const aliases = (config && config.stateAliases && config.stateAliases[bucket]) || []
|
|
761
|
+
return [primary, ...(Array.isArray(aliases) ? aliases : [])].filter((n) => typeof n === 'string' && n)
|
|
758
762
|
}
|
|
759
763
|
|
|
760
764
|
// Map a remote workflow-state name back to the local lifecycle bucket (the
|
|
761
765
|
// vocabulary `spec_status` uses) via config.states, so local and remote
|
|
762
766
|
// workflowState hash equal when semantically equal. Falls back to a lowercased
|
|
763
767
|
// raw value when the state isn't one of the configured names.
|
|
764
|
-
//
|
|
765
|
-
// A DECLARED DEPLOYMENT STAGE reads as `complete`, deliberately. Without this
|
|
766
|
-
// the fallback lowercases it — "On Test" becomes "on test", which equals no
|
|
767
|
-
// bucket — and every deployed spec reports as drifted forever, for the whole
|
|
768
|
-
// time it sits in the pipeline. The project told us these states are downstream
|
|
769
|
-
// of a finished spec, so they are not a disagreement about where the spec is.
|
|
770
768
|
function bucketForState(state, config) {
|
|
771
769
|
if (state == null) return null
|
|
772
770
|
const states = (config && config.states) || {}
|
|
773
771
|
const want = String(state).toLowerCase().trim()
|
|
774
|
-
for (const
|
|
775
|
-
if (
|
|
772
|
+
for (const bucket of Object.keys(states)) {
|
|
773
|
+
if (stateNamesFor(bucket, config).some((name) => name.toLowerCase().trim() === want)) return bucket
|
|
776
774
|
}
|
|
777
|
-
if (stageForState(state, config)) return LADDER_ORIGIN_BUCKET
|
|
778
775
|
return want
|
|
779
776
|
}
|
|
780
777
|
|
|
@@ -809,11 +806,20 @@ function remoteWorkflowState(issue, config) {
|
|
|
809
806
|
return name != null ? bucketForState(name, config) : null
|
|
810
807
|
}
|
|
811
808
|
|
|
812
|
-
//
|
|
813
|
-
//
|
|
814
|
-
//
|
|
815
|
-
|
|
816
|
-
|
|
809
|
+
// What the drift report needs to know about a remote issue's state: its name,
|
|
810
|
+
// the bucket it maps to, and whether it maps to one at all. Null when the
|
|
811
|
+
// payload carried no state — a cannot-tell, never an answer.
|
|
812
|
+
//
|
|
813
|
+
// AN UNMAPPED STATE IS NOT A DISAGREEMENT. A tracker grows states skitterspec
|
|
814
|
+
// never names ("Blocked", "Next Sprint"), and a ticket a person moved into one
|
|
815
|
+
// is not wrong about the spec — it is somewhere push does not manage, and push
|
|
816
|
+
// will not move it until the spec itself moves. `mapped` is what lets a report
|
|
817
|
+
// say that rather than accuse it.
|
|
818
|
+
function remoteStateReading(issue, config) {
|
|
819
|
+
const name = remoteStateName(issue || {}, config)
|
|
820
|
+
if (name == null) return null
|
|
821
|
+
const bucket = bucketForState(name, config)
|
|
822
|
+
return { name: String(name), bucket, mapped: LIFECYCLE_BUCKETS.includes(bucket) }
|
|
817
823
|
}
|
|
818
824
|
|
|
819
825
|
// A Linear issue title is plain text, so markdown emphasis is noise there — and
|
|
@@ -888,24 +894,16 @@ function titleFromText(text, max = 100) {
|
|
|
888
894
|
return stripTitleMarkup(title)
|
|
889
895
|
}
|
|
890
896
|
|
|
891
|
-
// Every state NAME the config points at: the lifecycle bucket map
|
|
892
|
-
// project's deployment ladder when it declares one. Both reach Linear the same
|
|
893
|
-
// way and fail the same way, so both are checked by the same lookup.
|
|
897
|
+
// Every state NAME the config points at: the lifecycle bucket map.
|
|
894
898
|
function configuredStateNames(config) {
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
if (stage && typeof stage.state === 'string') names.push(stage.state)
|
|
899
|
-
}
|
|
900
|
-
return names
|
|
899
|
+
// The WRITTEN names only. An alias is never written, so one the workspace
|
|
900
|
+
// lacks cannot strand a push; `stateSuggestions` still names it in reports.
|
|
901
|
+
return Object.values((config && config.states) || {}).filter((v) => typeof v === 'string')
|
|
901
902
|
}
|
|
902
903
|
|
|
903
904
|
// Which configured state NAMES are absent from the live workspace. The skill
|
|
904
905
|
// fetches the workspace's project-status names over MCP and passes them here;
|
|
905
906
|
// a non-empty result means a typo/rename that Linear would silently no-op.
|
|
906
|
-
//
|
|
907
|
-
// A project with no `release.stages` contributes nothing here, so the ladder
|
|
908
|
-
// cannot make this accuse a config that was fine before it existed.
|
|
909
907
|
function validateStates(config, workspaceStates) {
|
|
910
908
|
const configured = configuredStateNames(config)
|
|
911
909
|
const have = new Set((workspaceStates || []).map((s) => String(s).toLowerCase().trim()))
|
|
@@ -944,21 +942,6 @@ function stateSuggestions(config, workspaceStates) {
|
|
|
944
942
|
const suggestion = names.find((n) => words.includes(n.toLowerCase().trim())) || null
|
|
945
943
|
out.push({ bucket, configured, suggestion, label: `states.${bucket}` })
|
|
946
944
|
}
|
|
947
|
-
// Deployment-ladder rungs, reported the same way. No suggestion is offered:
|
|
948
|
-
// BUCKET_WORDS describes lifecycle vocabulary, and a stage name is the
|
|
949
|
-
// project's own — guessing "On Test" meant "In Progress" would be worse than
|
|
950
|
-
// saying nothing.
|
|
951
|
-
const stages = (config && config.release && config.release.stages) || []
|
|
952
|
-
for (const stage of Array.isArray(stages) ? stages : []) {
|
|
953
|
-
if (!stage || typeof stage.state !== 'string') continue
|
|
954
|
-
if (have.has(stage.state.toLowerCase().trim())) continue
|
|
955
|
-
out.push({
|
|
956
|
-
bucket: null,
|
|
957
|
-
configured: stage.state,
|
|
958
|
-
suggestion: null,
|
|
959
|
-
label: `release.stages[${stage.key}]`,
|
|
960
|
-
})
|
|
961
|
-
}
|
|
962
945
|
return out
|
|
963
946
|
}
|
|
964
947
|
|
|
@@ -982,8 +965,7 @@ module.exports = {
|
|
|
982
965
|
canonicalRemoteStatus,
|
|
983
966
|
bucketForState,
|
|
984
967
|
LIFECYCLE_BUCKETS,
|
|
985
|
-
stageForState,
|
|
986
|
-
remoteStage,
|
|
987
968
|
remoteWorkflowState,
|
|
988
|
-
|
|
969
|
+
remoteStateReading,
|
|
970
|
+
stateNamesFor,
|
|
989
971
|
}
|