@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
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Assignment — who is building a spec, mirrored to the tracker's assignee.
|
|
5
|
+
*
|
|
6
|
+
* Three commands every provider with assignment shares, hoisted from the
|
|
7
|
+
* Linear provider when GitHub became the second consumer:
|
|
8
|
+
*
|
|
9
|
+
* - `assign <spec> --to <id> [--name <n>] | --release` stamps (or clears) the
|
|
10
|
+
* assignee fields in the overview frontmatter. It writes the REPO and
|
|
11
|
+
* nothing else; the next push carries it, like any other field.
|
|
12
|
+
* - `whoami [--set <id> [--name <n>]] [--unset]` reports — and caches — who
|
|
13
|
+
* the operator is in the tracker, which is the fact assignment rests on.
|
|
14
|
+
* - `users [<query>]` finds a person to hand a spec to.
|
|
15
|
+
*
|
|
16
|
+
* Everything tracker-shaped arrives as a hook (`makeAssignmentCommands`): the
|
|
17
|
+
* tracker's name for messages, the frontmatter fields, the store scope, how a
|
|
18
|
+
* credential resolves and how an adapter is made. The behaviour is the Linear
|
|
19
|
+
* provider's, unchanged — its tests still pin every message.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const path = require('node:path')
|
|
23
|
+
|
|
24
|
+
const { ownsField, writeFrontmatter, deleteFrontmatter, fieldsOf } = require('../../sync-core')
|
|
25
|
+
const { resolveTransport } = require('./transport.js')
|
|
26
|
+
const { makeSpecHelpers } = require('./specs.js')
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} p
|
|
30
|
+
* - `tracker` the tracker's name in messages ("Linear", "GitHub")
|
|
31
|
+
* - `configFile` the provider config path, for refusals
|
|
32
|
+
* - `bin` the command a person types (for the hints)
|
|
33
|
+
* - `idHint` how a user id is described ("<linear-user-id>", "<login>")
|
|
34
|
+
* - `scopeOf(config)` → `{ id, label }` the credentials-store scope, or `{ id: '' , missing }` with
|
|
35
|
+
* `missing` the refusal text when the config cannot name one
|
|
36
|
+
* - `storePath(env)`, `writeUser(file, scope, user)`, `removeUser(file, scope)`
|
|
37
|
+
* - `resolveIdentity(config, env, deps)` the provider's identity binding
|
|
38
|
+
* - `resolveCredential(config, env)` → `{ ok, error? }`
|
|
39
|
+
* - `makeAdapter(config, credential, flags)` → an adapter with `searchUsers`
|
|
40
|
+
* - `displayNameOf(user)`
|
|
41
|
+
* - `validateAssignee(config, id, flags)` OPTIONAL — async, resolves to a
|
|
42
|
+
* refusal string or null. Absent means "do not check", which is the Linear
|
|
43
|
+
* provider's behaviour.
|
|
44
|
+
* - `usersOverMcp` the hint printed when users must be searched over MCP
|
|
45
|
+
* - `unknownHint` OPTIONAL extra lines for the unknown-identity whoami report
|
|
46
|
+
*/
|
|
47
|
+
function makeAssignmentCommands(p) {
|
|
48
|
+
const helpers = makeSpecHelpers({ program: 'spec-sync' })
|
|
49
|
+
|
|
50
|
+
async function assign(dir, config, specArg, flags, out) {
|
|
51
|
+
const snapshotDir = helpers.resolveOrExit(specArg, dir, out)
|
|
52
|
+
if (!snapshotDir) return 1
|
|
53
|
+
const fields = fieldsOf(config)
|
|
54
|
+
|
|
55
|
+
const overviewFile = (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
|
|
56
|
+
const identifier = helpers.linkedIdentifier(path.join(snapshotDir, overviewFile), config)
|
|
57
|
+
const rel = path.relative(dir, snapshotDir)
|
|
58
|
+
|
|
59
|
+
const problems = []
|
|
60
|
+
if (flags.release && flags.to) problems.push('--release and --to are opposites — pass one')
|
|
61
|
+
if (!flags.release && !flags.to) problems.push('nothing to do — pass --to <user-id> or --release')
|
|
62
|
+
// An unlinked spec has no issue to assign. Refusing beats stamping a field
|
|
63
|
+
// that would sit in the file doing nothing until someone noticed.
|
|
64
|
+
if (!identifier) problems.push(`${rel} is not linked to ${p.tracker} — /spec-push it first`)
|
|
65
|
+
// AND THE SAME REASONING FOR THE FIELD ITSELF. A field the repo does not own
|
|
66
|
+
// is dropped from the projection, so a stamp written here would never reach
|
|
67
|
+
// the tracker — while the success line says `next: push it`. That is not a
|
|
68
|
+
// silent skip, it is an untrue assertion. Applies to `--release` too.
|
|
69
|
+
if (!ownsField(config, 'assignee')) {
|
|
70
|
+
problems.push(
|
|
71
|
+
'this repo does not own the assignee field — set sync.fieldOwnership.assignee ' +
|
|
72
|
+
`to "push" in ${p.configFile}`,
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
// A POSITIVE refusal only: the hook answers "this id cannot be assigned"
|
|
76
|
+
// or null, and every cannot-tell inside it must be null.
|
|
77
|
+
if (!problems.length && flags.to && p.validateAssignee) {
|
|
78
|
+
const refusal = await p.validateAssignee(config, flags.to, flags)
|
|
79
|
+
if (refusal) problems.push(refusal)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (problems.length) {
|
|
83
|
+
out.write(
|
|
84
|
+
['spec-sync assign: refusing to write — nothing was changed', ...problems.map((x) => ` ${x}`)].join('\n') + '\n',
|
|
85
|
+
)
|
|
86
|
+
return 1
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (flags.release) {
|
|
90
|
+
const removed = deleteFrontmatter(snapshotDir, config, [fields.assigneeId, fields.assigneeName].filter(Boolean))
|
|
91
|
+
if (flags.json) {
|
|
92
|
+
out.write(JSON.stringify({ spec: rel, issue: identifier, released: removed.length > 0 }, null, 2) + '\n')
|
|
93
|
+
return 0
|
|
94
|
+
}
|
|
95
|
+
out.write(
|
|
96
|
+
(removed.length
|
|
97
|
+
? `spec-sync assign: ${rel} released (${identifier})\n`
|
|
98
|
+
: `spec-sync assign: ${rel} records no assignee — nothing to release\n`) +
|
|
99
|
+
` next: push it, so ${p.tracker} is unassigned too\n`,
|
|
100
|
+
)
|
|
101
|
+
return 0
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
writeFrontmatter(snapshotDir, config, {
|
|
105
|
+
[fields.assigneeId]: flags.to,
|
|
106
|
+
// Display only — the projection never sends it. It exists so a reader (and
|
|
107
|
+
// `/spec-claim`) can name the person without a tracker round trip.
|
|
108
|
+
...(fields.assigneeName ? { [fields.assigneeName]: flags.name || null } : {}),
|
|
109
|
+
})
|
|
110
|
+
if (flags.json) {
|
|
111
|
+
out.write(JSON.stringify({ spec: rel, issue: identifier, id: flags.to, name: flags.name || null }, null, 2) + '\n')
|
|
112
|
+
return 0
|
|
113
|
+
}
|
|
114
|
+
const who = flags.name ? `${flags.name} (${flags.to})` : flags.to
|
|
115
|
+
out.write(`spec-sync assign: ${rel} assigned to ${who} (${identifier})\n next: push it, so ${p.tracker} agrees\n`)
|
|
116
|
+
return 0
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Exit 0 even when the answer is "unknown": this answers a question, and
|
|
121
|
+
* "nobody is identified here" is a valid answer — a shared key, an offline
|
|
122
|
+
* machine. A successful DERIVE is cached; a cache read is not re-written, and
|
|
123
|
+
* the caching lives here so that asking never has a side effect.
|
|
124
|
+
*/
|
|
125
|
+
async function whoami(dir, config, flags, out) {
|
|
126
|
+
const env = flags.env || process.env
|
|
127
|
+
const file = p.storePath(env)
|
|
128
|
+
const scope = p.scopeOf(config)
|
|
129
|
+
|
|
130
|
+
// The store is keyed by scope, so without one there is nowhere to read from
|
|
131
|
+
// or write to. That IS a broken config, so it exits non-zero.
|
|
132
|
+
if (!scope.id) {
|
|
133
|
+
out.write(scope.missing + '\n')
|
|
134
|
+
return 1
|
|
135
|
+
}
|
|
136
|
+
const label = scope.label || scope.id
|
|
137
|
+
|
|
138
|
+
if (flags.unset) {
|
|
139
|
+
const r = p.removeUser(file, scope.id, {})
|
|
140
|
+
if (!r.ok) {
|
|
141
|
+
out.write(`spec-sync whoami: ${r.reason}\n`)
|
|
142
|
+
return 1
|
|
143
|
+
}
|
|
144
|
+
out.write(
|
|
145
|
+
r.removed
|
|
146
|
+
? `spec-sync whoami: forgot the identity for ${label} (the ${p.credentialNoun || 'API key'} is untouched)\n`
|
|
147
|
+
: `spec-sync whoami: no identity stored for ${label} — nothing to forget\n`,
|
|
148
|
+
)
|
|
149
|
+
return 0
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (flags.set) {
|
|
153
|
+
const r = p.writeUser(file, scope.id, { id: flags.set, name: flags.name }, {})
|
|
154
|
+
if (!r.ok) {
|
|
155
|
+
out.write(`spec-sync whoami: ${r.reason}\n`)
|
|
156
|
+
return 1
|
|
157
|
+
}
|
|
158
|
+
if (flags.json) {
|
|
159
|
+
out.write(JSON.stringify({ id: flags.set, name: flags.name || null, source: 'set', store: r.path }, null, 2) + '\n')
|
|
160
|
+
return 0
|
|
161
|
+
}
|
|
162
|
+
const shown = flags.name ? `${flags.name} (${flags.set})` : flags.set
|
|
163
|
+
out.write(`spec-sync whoami: identity for ${label} set to ${shown} in ${r.path} (600)\n`)
|
|
164
|
+
return 0
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const identity = await p.resolveIdentity(config, env, { adapter: flags.adapter, fetch: flags.fetch })
|
|
168
|
+
|
|
169
|
+
if (identity.ok && identity.source === 'viewer') {
|
|
170
|
+
// A failed write is not fatal: we HAVE the answer, and re-deriving it next
|
|
171
|
+
// time is a slower success, not a wrong one.
|
|
172
|
+
const cached = p.writeUser(file, scope.id, { id: identity.id, name: identity.name }, {})
|
|
173
|
+
if (!cached.ok) identity.cacheWarning = cached.reason
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (flags.json) {
|
|
177
|
+
out.write(
|
|
178
|
+
JSON.stringify(
|
|
179
|
+
{
|
|
180
|
+
[p.scopeJsonKey || 'team']: label,
|
|
181
|
+
store: file,
|
|
182
|
+
ok: identity.ok,
|
|
183
|
+
id: identity.id || null,
|
|
184
|
+
name: identity.name || null,
|
|
185
|
+
source: identity.source,
|
|
186
|
+
reason: identity.ok ? null : identity.reason || null,
|
|
187
|
+
},
|
|
188
|
+
null,
|
|
189
|
+
2,
|
|
190
|
+
) + '\n',
|
|
191
|
+
)
|
|
192
|
+
return 0
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!identity.ok) {
|
|
196
|
+
out.write(
|
|
197
|
+
[
|
|
198
|
+
'spec-sync whoami: unknown — no identity for this workspace',
|
|
199
|
+
` ${identity.reason}`,
|
|
200
|
+
' Set one by hand:',
|
|
201
|
+
` ${p.bin} spec-sync whoami --set ${p.idHint} --name "<your name>"`,
|
|
202
|
+
` Find the id with: ${p.bin} spec-sync users <name-or-email>`,
|
|
203
|
+
].join('\n') + '\n',
|
|
204
|
+
)
|
|
205
|
+
return 0
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const where = identity.source === 'store' ? `cached in ${file}` : `derived from the ${p.credentialNoun || 'API key'}`
|
|
209
|
+
const lines = [
|
|
210
|
+
`spec-sync whoami: ${identity.name || '(unnamed)'} <${identity.id}>`,
|
|
211
|
+
` ${(p.scopeLineLabel || 'team:').padEnd(7)} ${label}`,
|
|
212
|
+
` source: ${where}`,
|
|
213
|
+
]
|
|
214
|
+
if (identity.cacheWarning) lines.push(` note: not cached — ${identity.cacheWarning}`)
|
|
215
|
+
out.write(lines.join('\n') + '\n')
|
|
216
|
+
return 0
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The engine answers on the API path and steps aside on the MCP path — the
|
|
221
|
+
* same contract `states` follows.
|
|
222
|
+
*/
|
|
223
|
+
async function users(dir, config, query, flags, out) {
|
|
224
|
+
const cred = p.resolveCredential(config, flags.env || process.env)
|
|
225
|
+
const transport = resolveTransport(flags, config, cred.ok || !!flags.adapter)
|
|
226
|
+
|
|
227
|
+
if (transport === 'mcp') {
|
|
228
|
+
if (flags.json) {
|
|
229
|
+
out.write(JSON.stringify({ transport: 'mcp', reason: cred.ok ? 'requested' : cred.error, users: null }, null, 2) + '\n')
|
|
230
|
+
return 0
|
|
231
|
+
}
|
|
232
|
+
out.write(
|
|
233
|
+
['spec-sync users: transport = mcp', ` ${cred.ok ? '--via mcp was requested' : cred.error}`, ` ${p.usersOverMcp}`].join('\n') +
|
|
234
|
+
'\n',
|
|
235
|
+
)
|
|
236
|
+
return 0
|
|
237
|
+
}
|
|
238
|
+
if (!cred.ok && !flags.adapter) {
|
|
239
|
+
out.write(`spec-sync users: refusing — ${cred.error}\n`)
|
|
240
|
+
return 1
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const adapter = flags.adapter || p.makeAdapter(config, cred, flags)
|
|
244
|
+
let page
|
|
245
|
+
try {
|
|
246
|
+
page = await adapter.searchUsers(query || '', { limit: flags.limit || 50, cursor: flags.cursor || null })
|
|
247
|
+
} catch (error) {
|
|
248
|
+
out.write(`spec-sync users: ${error.message}\n`)
|
|
249
|
+
return 1
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const list = (page && page.users) || []
|
|
253
|
+
if (flags.json) {
|
|
254
|
+
out.write(JSON.stringify({ transport: 'api', users: list, nextCursor: (page && page.nextCursor) || null }, null, 2) + '\n')
|
|
255
|
+
return 0
|
|
256
|
+
}
|
|
257
|
+
if (!list.length) {
|
|
258
|
+
out.write(`spec-sync users: no match for ${JSON.stringify(query || '')}\n`)
|
|
259
|
+
return 0
|
|
260
|
+
}
|
|
261
|
+
const lines = [`spec-sync users: ${list.length} match(es)`]
|
|
262
|
+
for (const u of list) {
|
|
263
|
+
// A deactivated user still resolves by id and can still be assigned, so
|
|
264
|
+
// the marker is the difference between a valid pick and a silently dead one.
|
|
265
|
+
const inactive = u && u.active === false ? ' [deactivated]' : ''
|
|
266
|
+
const email = u && u.email ? ` ${u.email}` : ''
|
|
267
|
+
lines.push(` ${p.displayNameOf(u) || '(unnamed)'} <${u && u.id}>${email}${inactive}`)
|
|
268
|
+
}
|
|
269
|
+
if (page && page.nextCursor) lines.push(` … more — re-run with --cursor ${page.nextCursor}`)
|
|
270
|
+
out.write(lines.join('\n') + '\n')
|
|
271
|
+
return 0
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return { assign, whoami, users }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
module.exports = { makeAssignmentCommands }
|
|
@@ -98,52 +98,6 @@ function makeMergeHelpers({ configFile, ownership, lifecycleBuckets, phaseMappin
|
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
// Merge (and validate) `release.stages` — the project's deployment ladder.
|
|
102
|
-
//
|
|
103
|
-
// Loud on a malformed entry: this list names tracker states, and a tracker
|
|
104
|
-
// that silently ignores an unknown state would let a half-typed ladder push
|
|
105
|
-
// clean and move nothing — so a bad shape fails here, at load, rather than at
|
|
106
|
-
// the first deploy nobody is watching. An ABSENT `release` block is not an
|
|
107
|
-
// error: it is the opt-out, and the whole feature is unused without it.
|
|
108
|
-
function mergeReleaseStages(base, parsed) {
|
|
109
|
-
const value = parsed.stages
|
|
110
|
-
if (value === undefined) return
|
|
111
|
-
if (!Array.isArray(value)) {
|
|
112
|
-
throw new Error(
|
|
113
|
-
`Invalid ${configFile}: release.stages = ${JSON.stringify(value)} ` +
|
|
114
|
-
'(expected an array of { key, state })',
|
|
115
|
-
)
|
|
116
|
-
}
|
|
117
|
-
const stages = []
|
|
118
|
-
const seen = new Set()
|
|
119
|
-
value.forEach((entry, i) => {
|
|
120
|
-
if (!isObject(entry)) {
|
|
121
|
-
throw new Error(
|
|
122
|
-
`Invalid ${configFile}: release.stages[${i}] = ${JSON.stringify(entry)} ` +
|
|
123
|
-
'(expected { key, state })',
|
|
124
|
-
)
|
|
125
|
-
}
|
|
126
|
-
for (const field of ['key', 'state']) {
|
|
127
|
-
if (typeof entry[field] !== 'string' || !entry[field].trim()) {
|
|
128
|
-
throw new Error(
|
|
129
|
-
`Invalid ${configFile}: release.stages[${i}].${field} = ${JSON.stringify(entry[field])} ` +
|
|
130
|
-
'(expected a non-empty string)',
|
|
131
|
-
)
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
const key = entry.key.trim()
|
|
135
|
-
if (seen.has(key)) {
|
|
136
|
-
throw new Error(
|
|
137
|
-
`Invalid ${configFile}: release.stages[${i}].key = ${JSON.stringify(key)} is a duplicate ` +
|
|
138
|
-
'(a stage key is how CI names the rung, so it must be unique)',
|
|
139
|
-
)
|
|
140
|
-
}
|
|
141
|
-
seen.add(key)
|
|
142
|
-
stages.push({ key, state: entry.state.trim() })
|
|
143
|
-
})
|
|
144
|
-
base.stages = stages
|
|
145
|
-
}
|
|
146
|
-
|
|
147
101
|
// Merge (and validate) release.ignorePaths — the path prefixes whose commits
|
|
148
102
|
// are bookkeeping. Loud on anything but an array of non-empty strings: a bad
|
|
149
103
|
// value that quietly fell back would let a project believe it had opted out
|
|
@@ -186,13 +140,112 @@ function makeMergeHelpers({ configFile, ownership, lifecycleBuckets, phaseMappin
|
|
|
186
140
|
}
|
|
187
141
|
}
|
|
188
142
|
|
|
143
|
+
// Merge (and validate) `states`. A bucket's value is a string — the one state
|
|
144
|
+
// push writes, exactly as it always was — or a non-empty array whose FIRST
|
|
145
|
+
// name is the one push writes and whose rest are read-only aliases: tracker
|
|
146
|
+
// states that also mean this bucket ("Blocked" for in-progress). The first
|
|
147
|
+
// name lands in `states[bucket]`, so every writer is unchanged; the rest go
|
|
148
|
+
// to `stateAliases[bucket]`, read only by code that interprets a remote state.
|
|
149
|
+
//
|
|
150
|
+
// An ALIAS in two buckets is refused: it would make a remote state mean two
|
|
151
|
+
// things, and which one won would be an accident of iteration order. Two
|
|
152
|
+
// buckets sharing one written name stays legal, as it always was — a tracker
|
|
153
|
+
// with no Cancelled state maps cancelled to "Done" — so only a name that an
|
|
154
|
+
// alias contributes is checked.
|
|
155
|
+
function mergeStates(base, parsed) {
|
|
156
|
+
const value = parsed.states
|
|
157
|
+
if (!isObject(value)) return
|
|
158
|
+
const aliases = {}
|
|
159
|
+
for (const bucket of Object.keys(base.states)) {
|
|
160
|
+
const entry = value[bucket]
|
|
161
|
+
if (entry === undefined) continue
|
|
162
|
+
if (typeof entry === 'string') {
|
|
163
|
+
if (entry.trim()) base.states[bucket] = entry.trim()
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
if (!Array.isArray(entry) || !entry.length || entry.some((n) => typeof n !== 'string' || !n.trim())) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`Invalid ${configFile}: states.${bucket} = ${JSON.stringify(entry)} ` +
|
|
169
|
+
'(expected a state name, or a non-empty array of them — the first is what push writes)',
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
const names = entry.map((n) => n.trim())
|
|
173
|
+
base.states[bucket] = names[0]
|
|
174
|
+
aliases[bucket] = names.slice(1)
|
|
175
|
+
}
|
|
176
|
+
const owner = new Map()
|
|
177
|
+
for (const bucket of Object.keys(base.states)) {
|
|
178
|
+
const key = base.states[bucket].toLowerCase()
|
|
179
|
+
if (!owner.has(key)) owner.set(key, bucket)
|
|
180
|
+
}
|
|
181
|
+
for (const bucket of Object.keys(aliases)) {
|
|
182
|
+
for (const name of aliases[bucket]) {
|
|
183
|
+
const key = name.toLowerCase()
|
|
184
|
+
if (owner.has(key) && owner.get(key) !== bucket) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`Invalid ${configFile}: states name ${JSON.stringify(name)} is in both ` +
|
|
187
|
+
`${owner.get(key)} and ${bucket} (a tracker state can mean only one bucket)`,
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
owner.set(key, bucket)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
base.stateAliases = aliases
|
|
194
|
+
}
|
|
195
|
+
|
|
189
196
|
return {
|
|
197
|
+
mergeStates,
|
|
190
198
|
mergeFieldOwnership,
|
|
191
199
|
mergePhaseMapping,
|
|
192
|
-
mergeReleaseStages,
|
|
193
200
|
mergeReleaseIgnorePaths,
|
|
194
201
|
mergeKeyedFields,
|
|
195
202
|
}
|
|
196
203
|
}
|
|
197
204
|
|
|
198
|
-
|
|
205
|
+
// Keys no provider reads any more. The merge already ignores them, as it does
|
|
206
|
+
// every unknown key — so a config still carrying one is a stale file, not a
|
|
207
|
+
// broken one, and `doctor` says so as a `warn` without touching the exit code.
|
|
208
|
+
//
|
|
209
|
+
// WHAT WOULD FOOL THIS: it reads the committed file, so a key set only in a
|
|
210
|
+
// working-tree edit is still reported — which is the file a person would edit.
|
|
211
|
+
const RETIRED_KEYS = Object.freeze([
|
|
212
|
+
{ path: ['releases'], name: 'releases' },
|
|
213
|
+
{ path: ['release', 'stages'], name: 'release.stages' },
|
|
214
|
+
])
|
|
215
|
+
|
|
216
|
+
// The retired keys present in a parsed config. Not an object, or none present,
|
|
217
|
+
// is `[]`: nothing to say is the safe answer to every cannot-tell.
|
|
218
|
+
function retiredKeys(parsed) {
|
|
219
|
+
if (!isObject(parsed)) return []
|
|
220
|
+
return RETIRED_KEYS.filter(({ path }) => {
|
|
221
|
+
let node = parsed
|
|
222
|
+
for (const key of path) {
|
|
223
|
+
if (!isObject(node) || !(key in node)) return false
|
|
224
|
+
node = node[key]
|
|
225
|
+
}
|
|
226
|
+
return true
|
|
227
|
+
}).map((k) => k.name)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// A doctor row naming the retired keys in the config at `file`, or null when
|
|
231
|
+
// there are none — or when the file cannot be read or parsed, which is the
|
|
232
|
+
// loader's to report, not this row's.
|
|
233
|
+
function retiredKeysRow(file, configFile, row) {
|
|
234
|
+
let parsed = null
|
|
235
|
+
try {
|
|
236
|
+
parsed = JSON.parse(require('node:fs').readFileSync(file, 'utf-8'))
|
|
237
|
+
} catch {
|
|
238
|
+
return null
|
|
239
|
+
}
|
|
240
|
+
const names = retiredKeys(parsed)
|
|
241
|
+
if (!names.length) return null
|
|
242
|
+
return row(
|
|
243
|
+
'retired',
|
|
244
|
+
'Retired config',
|
|
245
|
+
'warn',
|
|
246
|
+
`${names.join(', ')} in ${configFile} is no longer read — release and deploy state is the host project's to write (see \`spec-sync released --json\`)`,
|
|
247
|
+
`remove ${names.join(', ')} from ${configFile}`,
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
module.exports = { isObject, stringList, assign, makeMergeHelpers, retiredKeys, retiredKeysRow, RETIRED_KEYS }
|
|
@@ -14,31 +14,50 @@
|
|
|
14
14
|
const MAX_RETRIES = 5
|
|
15
15
|
const MAX_BACKOFF_MS = 60_000
|
|
16
16
|
|
|
17
|
-
// How long to wait before retrying a
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
// How long to wait before retrying a rate-limited response: `Retry-After`
|
|
18
|
+
// (seconds) first, then `X-RateLimit-Reset` (an epoch second — GitHub's
|
|
19
|
+
// primary limit names the moment it lifts rather than a duration), then
|
|
20
|
+
// exponential backoff. A reset already in the past falls through to backoff
|
|
21
|
+
// rather than waiting zero and hammering.
|
|
22
|
+
function retryDelay(res, attempt, maxBackoffMs = MAX_BACKOFF_MS, now = Date.now) {
|
|
23
|
+
const get = res.headers && typeof res.headers.get === 'function' ? (h) => res.headers.get(h) : () => null
|
|
24
|
+
const header = get('retry-after')
|
|
20
25
|
const seconds = header != null ? Number(header) : NaN
|
|
21
26
|
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1000, maxBackoffMs)
|
|
27
|
+
const reset = Number(get('x-ratelimit-reset'))
|
|
28
|
+
if (Number.isFinite(reset) && reset > 0) {
|
|
29
|
+
const wait = reset * 1000 - now()
|
|
30
|
+
if (wait > 0) return Math.min(wait, maxBackoffMs)
|
|
31
|
+
}
|
|
22
32
|
return Math.min(1000 * 2 ** attempt, maxBackoffMs)
|
|
23
33
|
}
|
|
24
34
|
|
|
35
|
+
// The default answer to "is this response a rate limit?": a 429. A provider
|
|
36
|
+
// whose tracker also signals limits another way (GitHub: a 403 carrying
|
|
37
|
+
// `Retry-After` or a zero `X-RateLimit-Remaining`) passes its own `isLimited`.
|
|
38
|
+
const isTooManyRequests = (res) => res.status === 429
|
|
39
|
+
|
|
25
40
|
/**
|
|
26
41
|
* Run `attempt()` (→ a fetch Response) until it is not rate-limited or the
|
|
27
42
|
* retries run out. Returns `{ response, exhausted }` — `exhausted` true when
|
|
28
|
-
* the LAST response is still
|
|
29
|
-
* marks it retryable: rate limiting is the failure where waiting
|
|
30
|
-
* answer, and the one a refusal must stay distinguishable from).
|
|
43
|
+
* the LAST response is still rate-limited, so the caller phrases its own
|
|
44
|
+
* refusal (and marks it retryable: rate limiting is the failure where waiting
|
|
45
|
+
* is the whole answer, and the one a refusal must stay distinguishable from).
|
|
31
46
|
*/
|
|
32
|
-
async function withRateLimitRetry(
|
|
47
|
+
async function withRateLimitRetry(
|
|
48
|
+
attempt,
|
|
49
|
+
{ sleep, maxRetries = MAX_RETRIES, maxBackoffMs = MAX_BACKOFF_MS, isLimited = isTooManyRequests, now } = {},
|
|
50
|
+
) {
|
|
33
51
|
const wait = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)))
|
|
34
52
|
for (let n = 0; ; n++) {
|
|
35
53
|
const response = await attempt()
|
|
36
|
-
|
|
37
|
-
|
|
54
|
+
const limited = isLimited(response)
|
|
55
|
+
if (limited && n < maxRetries) {
|
|
56
|
+
await wait(retryDelay(response, n, maxBackoffMs, now))
|
|
38
57
|
continue
|
|
39
58
|
}
|
|
40
|
-
return { response, exhausted:
|
|
59
|
+
return { response, exhausted: limited }
|
|
41
60
|
}
|
|
42
61
|
}
|
|
43
62
|
|
|
44
|
-
module.exports = { withRateLimitRetry, retryDelay, MAX_RETRIES, MAX_BACKOFF_MS }
|
|
63
|
+
module.exports = { withRateLimitRetry, retryDelay, isTooManyRequests, MAX_RETRIES, MAX_BACKOFF_MS }
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `spec-sync list --mine | --by <user>` — whose tickets to list.
|
|
5
|
+
*
|
|
6
|
+
* Linear defined the contract (`linear/src/cli-sync.js`, decision 9 of
|
|
7
|
+
* `feat-linear-assignment`); this is the same contract for every provider that
|
|
8
|
+
* lists over the API, so the two sentences below mean the same thing anywhere:
|
|
9
|
+
*
|
|
10
|
+
* - `--mine` with an UNKNOWN identity lists nothing and exits 0. Identity is
|
|
11
|
+
* an ordinary unknown (a bot token, an offline machine). What must never
|
|
12
|
+
* happen is dropping the filter and printing everyone's work under a
|
|
13
|
+
* heading that promised yours.
|
|
14
|
+
* - `--by` that matches nobody, or more than one person, exits 1. The
|
|
15
|
+
* argument is wrong and only the caller can fix it — the same shape as an
|
|
16
|
+
* unknown `--state` — and a fallback to everyone would be the same lie.
|
|
17
|
+
*
|
|
18
|
+
* Returns `{ assignee: { id, name } | null }` to carry on with, or
|
|
19
|
+
* `{ code, lines }` to print and stop.
|
|
20
|
+
*/
|
|
21
|
+
async function resolveListAssignee({ flags, config, env, adapter, resolveIdentity, displayNameOf, tracker, prog }) {
|
|
22
|
+
if (flags.mine && flags.by) {
|
|
23
|
+
return {
|
|
24
|
+
code: 1,
|
|
25
|
+
lines: [
|
|
26
|
+
`${prog} list: --mine and --by both filter by assignee, so they cannot be combined`,
|
|
27
|
+
' use --mine for your own, or --by <user> for someone else.',
|
|
28
|
+
],
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (flags.mine) {
|
|
32
|
+
const who = await resolveIdentity(config, env, { adapter })
|
|
33
|
+
if (!who || !who.ok) {
|
|
34
|
+
return {
|
|
35
|
+
code: 0,
|
|
36
|
+
lines: [
|
|
37
|
+
`${prog} list: --mine needs to know who you are, and it does not (${(who && who.reason) || 'no identity'})`,
|
|
38
|
+
' run `spec-sync whoami --set <id>` to say so, or use --by <user>.',
|
|
39
|
+
],
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { assignee: { id: who.id, name: who.name || who.id } }
|
|
43
|
+
}
|
|
44
|
+
if (flags.by) {
|
|
45
|
+
const term = String(flags.by)
|
|
46
|
+
let found
|
|
47
|
+
try {
|
|
48
|
+
found = await adapter.searchUsers(term, { limit: 50 })
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return { code: 1, lines: [`${prog} list: could not search users (${error.message})`] }
|
|
51
|
+
}
|
|
52
|
+
const users = (found && found.users) || []
|
|
53
|
+
if (!users.length) {
|
|
54
|
+
return {
|
|
55
|
+
code: 1,
|
|
56
|
+
lines: [`${prog} list: no ${tracker} user matches ${JSON.stringify(term)}`, ' `spec-sync users <term>` lists who there is.'],
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (users.length > 1) {
|
|
60
|
+
const lines = [`${prog} list: ${users.length} users match ${JSON.stringify(term)}`]
|
|
61
|
+
for (const u of users.slice(0, 10)) lines.push(` ${displayNameOf(u) || '(unnamed)'}${u.email ? ` ${u.email}` : ''}`)
|
|
62
|
+
if (users.length > 10) lines.push(` … and ${users.length - 10} more`)
|
|
63
|
+
lines.push(' narrow it — an email address matches exactly one person.')
|
|
64
|
+
return { code: 1, lines }
|
|
65
|
+
}
|
|
66
|
+
return { assignee: { id: users[0].id, name: displayNameOf(users[0]) || users[0].id } }
|
|
67
|
+
}
|
|
68
|
+
return { assignee: null }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { resolveListAssignee }
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared pieces of `spec-sync list`: what `--state` means, and how a row's
|
|
5
|
+
* board state and its spec's own status are put side by side.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { remoteStateReading, stateNamesFor, LIFECYCLE_BUCKETS } = require('../../sync-core')
|
|
9
|
+
|
|
10
|
+
// `--state` takes a lifecycle bucket as well as a tracker state. A bucket
|
|
11
|
+
// expands to every tracker name it has (`stateNamesFor`, aliases included); any
|
|
12
|
+
// other value is a tracker state, matched as it always was.
|
|
13
|
+
function expandStateArgs(args, config) {
|
|
14
|
+
return (args || []).flatMap((arg) => (LIFECYCLE_BUCKETS.includes(arg) ? stateNamesFor(arg, config) : [arg]))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The spec's status beside the board's, and whether they disagree. A board
|
|
18
|
+
// state that maps to no bucket never disagrees: it is somewhere push does not
|
|
19
|
+
// manage, which is not a claim about the spec. Nor does a row whose spec is not
|
|
20
|
+
// on this machine — there is nothing to disagree WITH.
|
|
21
|
+
function specColumn(stateName, spec, config) {
|
|
22
|
+
const specStatus = spec ? spec.bucket : null
|
|
23
|
+
const seen = stateName == null ? null : remoteStateReading({ state: { name: stateName } }, config)
|
|
24
|
+
const disagrees = Boolean(specStatus && seen && seen.mapped && seen.bucket !== specStatus)
|
|
25
|
+
return { specStatus, disagrees }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The cell itself: `in-progress`, `≠ backlog` when it disagrees, `—` when the
|
|
29
|
+
// spec is not here.
|
|
30
|
+
function specCell({ specStatus, disagrees }) {
|
|
31
|
+
if (!specStatus) return '—'
|
|
32
|
+
return disagrees ? `≠ ${specStatus}` : specStatus
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { expandStateArgs, specColumn, specCell }
|