@skitterbyte/skitterspec-jira 0.0.0-stage → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MIGRATION.md +1266 -0
- package/README.md +44 -2
- package/assets/claude-md-section.md +85 -0
- package/assets/commands/allow-main.md +33 -0
- package/assets/commands/spec-connect.md +13 -0
- package/assets/commands/spec-live.md +14 -0
- package/assets/commands/spec-remote-review.md +37 -0
- package/assets/commands/spec-skip.md +24 -0
- package/assets/core/SETUP.md +192 -0
- package/assets/core/env.config.json.example +56 -0
- package/assets/core/env.config.md +606 -0
- package/assets/core/gating.config.json.example +4 -0
- package/assets/core/gating.config.md +81 -0
- package/assets/core/jira.config.json.example +18 -0
- package/assets/core/jira.config.md +88 -0
- package/assets/hooks/main-guard.cjs +162 -0
- package/assets/hooks/review-gate.cjs +146 -0
- package/assets/review/page.html +3384 -0
- package/assets/rules/commit-trailers.md +96 -0
- package/assets/rules/negative-checks.md +69 -0
- package/assets/rules/offered-unblocks.md +100 -0
- package/assets/rules/spec-planning.md +702 -0
- package/assets/rules/spec-reports.md +572 -0
- package/assets/skills/no-spec/SKILL.md +249 -0
- package/assets/skills/spec/SKILL.md +714 -0
- package/assets/skills/spec-bug/SKILL.md +585 -0
- package/assets/skills/spec-cancel/SKILL.md +261 -0
- package/assets/skills/spec-complete/SKILL.md +451 -0
- package/assets/skills/spec-diff/SKILL.md +870 -0
- package/assets/skills/spec-hotfix/SKILL.md +607 -0
- package/assets/skills/spec-init/SKILL.md +178 -0
- package/assets/skills/spec-jira-setup/SKILL.md +221 -0
- package/assets/skills/spec-list/SKILL.md +173 -0
- package/assets/skills/spec-next/SKILL.md +678 -0
- package/assets/skills/spec-push/SKILL.md +306 -0
- package/assets/skills/spec-review/SKILL.md +256 -0
- package/assets/skills/spec-reviewed/SKILL.md +284 -0
- package/assets/skills/spec-start/SKILL.md +496 -0
- package/assets/skills/spec-status/SKILL.md +114 -0
- package/assets/skills/spec-sync/SKILL.md +220 -0
- package/assets/skills/spec-to-main/SKILL.md +141 -0
- package/bin/skitterspec-jira.js +68 -0
- package/package.json +40 -4
- package/src/cli.js +5967 -0
- package/src/deprecate.js +138 -0
- package/src/env/bootstrap.js +188 -0
- package/src/env/building.js +143 -0
- package/src/env/classify.js +176 -0
- package/src/env/commitcmd.js +108 -0
- package/src/env/config.js +850 -0
- package/src/env/dev.js +63 -0
- package/src/env/glob.js +34 -0
- package/src/env/hooks.js +226 -0
- package/src/env/hotfix.js +413 -0
- package/src/env/hotfixland.js +302 -0
- package/src/env/integrate.js +167 -0
- package/src/env/live.js +479 -0
- package/src/env/mainguard.js +299 -0
- package/src/env/provision.js +466 -0
- package/src/env/proxy.js +225 -0
- package/src/env/prune.js +119 -0
- package/src/env/registry.js +159 -0
- package/src/env/render.js +17 -0
- package/src/env/resolve.js +716 -0
- package/src/env/review.js +2622 -0
- package/src/env/reviewers/coderabbit.js +228 -0
- package/src/env/reviewers.js +517 -0
- package/src/env/serve.js +1044 -0
- package/src/env/supervise.js +157 -0
- package/src/env/teardown.js +248 -0
- package/src/env/trust.js +87 -0
- package/src/gating.js +155 -0
- package/src/init.js +1150 -0
- package/src/lines-diff.js +114 -0
- package/src/prompts.js +129 -0
- package/src/vendor/jira/adf.js +414 -0
- package/src/vendor/jira/api.js +295 -0
- package/src/vendor/jira/cli-sync.js +1252 -0
- package/src/vendor/jira/commands.js +44 -0
- package/src/vendor/jira/config.js +286 -0
- package/src/vendor/jira/credentials.js +108 -0
- package/src/vendor/jira/dialect.js +45 -0
- package/src/vendor/jira/mcp.js +129 -0
- package/src/vendor/provider-kit/index.js +41 -0
- package/src/vendor/provider-kit/src/adapter.js +23 -0
- package/src/vendor/provider-kit/src/apply.js +207 -0
- package/src/vendor/provider-kit/src/args.js +68 -0
- package/src/vendor/provider-kit/src/assignment.js +277 -0
- package/src/vendor/provider-kit/src/config.js +251 -0
- package/src/vendor/provider-kit/src/credentials.js +404 -0
- package/src/vendor/provider-kit/src/doctor.js +35 -0
- package/src/vendor/provider-kit/src/http.js +63 -0
- package/src/vendor/provider-kit/src/identity.js +93 -0
- package/src/vendor/provider-kit/src/list-assignee.js +71 -0
- package/src/vendor/provider-kit/src/list-rows.js +35 -0
- package/src/vendor/provider-kit/src/md-compare.js +39 -0
- package/src/vendor/provider-kit/src/md-tables.js +94 -0
- package/src/vendor/provider-kit/src/prompt.js +50 -0
- package/src/vendor/provider-kit/src/released.js +219 -0
- package/src/vendor/provider-kit/src/specs.js +248 -0
- package/src/vendor/provider-kit/src/transport.js +22 -0
- package/src/vendor/sync-core/index.js +92 -0
- package/src/vendor/sync-core/src/base.js +81 -0
- package/src/vendor/sync-core/src/compare.js +338 -0
- package/src/vendor/sync-core/src/dialect.js +51 -0
- package/src/vendor/sync-core/src/fields.js +36 -0
- package/src/vendor/sync-core/src/legacy.js +100 -0
- package/src/vendor/sync-core/src/normalize.js +971 -0
- package/src/vendor/sync-core/src/push.js +85 -0
- package/src/vendor/sync-core/src/retarget.js +278 -0
- package/src/vendor/sync-core/src/task-block.js +308 -0
- package/src/vendor/sync-core/src/verify.js +67 -0
- package/src/vendor/sync-core/src/write.js +191 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Rate-limit-aware HTTP retry — the transport discipline every provider's API
|
|
5
|
+
* client shares, with none of any tracker's protocol in it.
|
|
6
|
+
*
|
|
7
|
+
* A bulk backfill is exactly the workload that gets throttled, and failing the
|
|
8
|
+
* run would strand a half-mirrored bucket — so a 429 waits and retries rather
|
|
9
|
+
* than surfacing. The server says how long to wait (`Retry-After`, seconds);
|
|
10
|
+
* honour that over guessing, and fall back to exponential backoff (from 1s,
|
|
11
|
+
* capped) when it doesn't.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const MAX_RETRIES = 5
|
|
15
|
+
const MAX_BACKOFF_MS = 60_000
|
|
16
|
+
|
|
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')
|
|
25
|
+
const seconds = header != null ? Number(header) : NaN
|
|
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
|
+
}
|
|
32
|
+
return Math.min(1000 * 2 ** attempt, maxBackoffMs)
|
|
33
|
+
}
|
|
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
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Run `attempt()` (→ a fetch Response) until it is not rate-limited or the
|
|
42
|
+
* retries run out. Returns `{ response, exhausted }` — `exhausted` true when
|
|
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).
|
|
46
|
+
*/
|
|
47
|
+
async function withRateLimitRetry(
|
|
48
|
+
attempt,
|
|
49
|
+
{ sleep, maxRetries = MAX_RETRIES, maxBackoffMs = MAX_BACKOFF_MS, isLimited = isTooManyRequests, now } = {},
|
|
50
|
+
) {
|
|
51
|
+
const wait = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)))
|
|
52
|
+
for (let n = 0; ; n++) {
|
|
53
|
+
const response = await attempt()
|
|
54
|
+
const limited = isLimited(response)
|
|
55
|
+
if (limited && n < maxRetries) {
|
|
56
|
+
await wait(retryDelay(response, n, maxBackoffMs, now))
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
return { response, exhausted: limited }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { withRateLimitRetry, retryDelay, isTooManyRequests, MAX_RETRIES, MAX_BACKOFF_MS }
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* "Who am I in this workspace?" — the resolution shape every provider's
|
|
5
|
+
* assignment rests on.
|
|
6
|
+
*
|
|
7
|
+
* Three tiers, first hit wins:
|
|
8
|
+
*
|
|
9
|
+
* 1. the user-level credentials store (`source: 'store'`) — no network
|
|
10
|
+
* 2. the credential itself, via the tracker's "who am I" call
|
|
11
|
+
* (`source: 'viewer'`)
|
|
12
|
+
* 3. nothing (`source: 'unknown'`)
|
|
13
|
+
*
|
|
14
|
+
* **Unknown is a normal state, not a failure.** A shared/bot key answers the
|
|
15
|
+
* viewer call with the bot; an offline machine answers with nothing; neither is
|
|
16
|
+
* a broken install. So this returns a structured result the caller branches on,
|
|
17
|
+
* never an exception — and it NEVER PROMPTS and NEVER WRITES. Prompting belongs
|
|
18
|
+
* to the skill with a human in front of it, and caching belongs to the command
|
|
19
|
+
* that decided the answer was worth keeping: a resolver that wrote to disk as a
|
|
20
|
+
* side effect would cache a bot's identity the first time CI ran.
|
|
21
|
+
*
|
|
22
|
+
* Everything tracker-shaped arrives as a hook: how a credential resolves, how
|
|
23
|
+
* the viewer is fetched, and how a user object yields a display name.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {object} hooks
|
|
28
|
+
* - `scopeId` the credentials-store scope (e.g. a team id) — '' skips tier 1
|
|
29
|
+
* - `file` the store path (carried through on every result)
|
|
30
|
+
* - `readStore(file)` kit credentials read
|
|
31
|
+
* - `userFor(store,id)` the provider store binding's identity read
|
|
32
|
+
* - `resolveCredential()` → `{ ok, error?, envVar?, … }` — the provider's key resolution
|
|
33
|
+
* - `readViewer(credential)` → the tracker's current user (may throw)
|
|
34
|
+
* - `nameOf(viewer)` display name of a user object
|
|
35
|
+
* @returns {Promise<object>} `{ ok: true, id, name, email?, source, path }` or
|
|
36
|
+
* `{ ok: false, source: 'unknown', reason, envVar?, path }`.
|
|
37
|
+
*/
|
|
38
|
+
async function resolveIdentity({ scopeId, file, readStore, userFor, resolveCredential, readViewer, nameOf }) {
|
|
39
|
+
if (scopeId) {
|
|
40
|
+
const result = readStore(file)
|
|
41
|
+
if (result.ok) {
|
|
42
|
+
const cached = userFor(result.store, scopeId)
|
|
43
|
+
if (cached) return { ok: true, id: cached.id, name: cached.name, source: 'store', path: file }
|
|
44
|
+
}
|
|
45
|
+
// A store that is present but unusable (over-permissive, malformed) is NOT
|
|
46
|
+
// reported as an identity failure — credential resolution hits the same
|
|
47
|
+
// file and reports it properly. Duplicating it here would print the same
|
|
48
|
+
// permissions warning twice for one cause.
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const credential = resolveCredential()
|
|
52
|
+
if (!credential.ok) {
|
|
53
|
+
return { ok: false, source: 'unknown', reason: credential.error, envVar: credential.envVar, path: file }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let viewer
|
|
57
|
+
try {
|
|
58
|
+
viewer = await readViewer(credential)
|
|
59
|
+
} catch (error) {
|
|
60
|
+
// Offline, revoked key, tracker down. All are "cannot tell", which routes
|
|
61
|
+
// to the harmless branch: the caller skips assignment rather than guessing.
|
|
62
|
+
return { ok: false, source: 'unknown', reason: error.message, path: file }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const id = viewer && typeof viewer.id === 'string' ? viewer.id.trim() : ''
|
|
66
|
+
if (!id) {
|
|
67
|
+
return { ok: false, source: 'unknown', reason: 'the tracker returned no viewer for this key', path: file }
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
ok: true,
|
|
71
|
+
id,
|
|
72
|
+
name: (nameOf || defaultDisplayName)(viewer),
|
|
73
|
+
email: (viewer && viewer.email) || null,
|
|
74
|
+
source: 'viewer',
|
|
75
|
+
path: file,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The name to show for a tracker user. Falls back through `displayName` and
|
|
81
|
+
* `email` so a user with an odd profile still reads as a person rather than a
|
|
82
|
+
* UUID.
|
|
83
|
+
*/
|
|
84
|
+
function defaultDisplayName(user) {
|
|
85
|
+
if (!user || typeof user !== 'object') return null
|
|
86
|
+
for (const field of ['name', 'displayName', 'email']) {
|
|
87
|
+
const value = user[field]
|
|
88
|
+
if (typeof value === 'string' && value.trim()) return value.trim()
|
|
89
|
+
}
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { resolveIdentity, defaultDisplayName }
|
|
@@ -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 }
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The markdown-form reduction both dialects compare through. Every transform
|
|
5
|
+
* here is a benign difference between two spellings of the same markdown —
|
|
6
|
+
* a reserialising editor's (Linear rewrites bullets, renumbers lists,
|
|
7
|
+
* case-folds checkboxes, collapses separator rows on save) or a round-trip
|
|
8
|
+
* renderer's (the ADF reducer re-emits `-` bullets and `| -- |` separators
|
|
9
|
+
* whatever the source used). Normalised away BEFORE comparing, so an intact
|
|
10
|
+
* mirror never reports as data loss; digits stay significant everywhere else —
|
|
11
|
+
* a port, a version, a key length still count.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function canonicalForCompare(text) {
|
|
15
|
+
return String(text == null ? '' : text)
|
|
16
|
+
.replace(/\r\n/g, '\n')
|
|
17
|
+
.split('\n')
|
|
18
|
+
.map((line) =>
|
|
19
|
+
line
|
|
20
|
+
// Ordered-list markers → a placeholder. Linear renumbers lists, and the
|
|
21
|
+
// digits it rewrites are alphanumeric, so a naive alphanumeric compare
|
|
22
|
+
// would flag its own benign reformat as data loss. Normalising the
|
|
23
|
+
// marker keeps digits significant EVERYWHERE ELSE — a port, a version, a
|
|
24
|
+
// key length still count.
|
|
25
|
+
.replace(/^(\s*)\d+\.(\s)/, '$1#.$2')
|
|
26
|
+
// Unordered markers unify (`-`/`+` come back as `*`).
|
|
27
|
+
.replace(/^(\s*)[*+-](\s)/, '$1-$2')
|
|
28
|
+
// Checkbox marks case-fold. Targeted rather than lowercasing the whole
|
|
29
|
+
// text, so a genuine case corruption in prose is still caught.
|
|
30
|
+
.replace(/^(\s*-\s*\[)[xX](\])/, '$1x$2')
|
|
31
|
+
// Table separator rows collapse (`|-------|` → `| -- |`).
|
|
32
|
+
.replace(/^\s*\|[\s:|-]+\|\s*$/, '|--|')
|
|
33
|
+
.replace(/[ \t]+$/, ''),
|
|
34
|
+
)
|
|
35
|
+
.join('\n')
|
|
36
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
37
|
+
.trim()
|
|
38
|
+
}
|
|
39
|
+
module.exports = { markdownCanonicalForCompare: canonicalForCompare }
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Flatten markdown tables that sit INSIDE a list item, because both tracker
|
|
5
|
+
* dialects need it: Linear's renderer corrupts nested-table data cells
|
|
6
|
+
* (measured on probe SKI-28, 2026-08-28 — every data cell loses its first N
|
|
7
|
+
* characters, N being the rendered list indent; the auth header
|
|
8
|
+
* `X-Extraction-Key` was stored as `Extraction-Key`), and ADF's `listItem`
|
|
9
|
+
* cannot contain a `table` node at all, so a Jira render would drop or mangle
|
|
10
|
+
* it. The re-emitted shapes are ones proven to survive nesting unchanged:
|
|
11
|
+
*
|
|
12
|
+
* - 2 columns → a bullet list (`- a — b`), the key/value case
|
|
13
|
+
* - otherwise → a fenced code block wrapping the original rows verbatim
|
|
14
|
+
*
|
|
15
|
+
* This shapes the PROJECTION only. Repo files are never rewritten: the source
|
|
16
|
+
* markdown is valid and renders correctly in GitHub and every editor.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { fenceMask } = require('../../sync-core/src/task-block.js')
|
|
20
|
+
|
|
21
|
+
// A table row: optional indent, then a `|`-delimited line. We only ever act on
|
|
22
|
+
// indented ones — a column-0 table (the `## Phases` index, every Impact map) is
|
|
23
|
+
// rendered correctly by Linear and must project byte-identically.
|
|
24
|
+
const ROW_RE = /^([ \t]+)\|(.*)\|[ \t]*$/
|
|
25
|
+
// The separator under the header — `|---|:--:|`. Its presence is what makes the
|
|
26
|
+
// block a table rather than prose that happens to contain pipes.
|
|
27
|
+
const SEPARATOR_RE = /^[ \t]+\|[\s:|-]+\|[ \t]*$/
|
|
28
|
+
|
|
29
|
+
// Split a row into cells on pipes that are OUTSIDE an inline-code span, so a
|
|
30
|
+
// documented `` `a | b` `` alternation stays one cell instead of splitting.
|
|
31
|
+
function splitCells(body) {
|
|
32
|
+
const cells = []
|
|
33
|
+
let cur = ''
|
|
34
|
+
let code = false
|
|
35
|
+
for (const ch of body) {
|
|
36
|
+
if (ch === '`') code = !code
|
|
37
|
+
if (ch === '|' && !code) {
|
|
38
|
+
cells.push(cur.trim())
|
|
39
|
+
cur = ''
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
cur += ch
|
|
43
|
+
}
|
|
44
|
+
cells.push(cur.trim())
|
|
45
|
+
return cells
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Rewrite every indented table in `md`. Returns the text unchanged when there is
|
|
50
|
+
* nothing nested to flatten.
|
|
51
|
+
* @param {string} md
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
function flattenNestedTables(md) {
|
|
55
|
+
if (md == null) return md
|
|
56
|
+
const lines = String(md).split('\n')
|
|
57
|
+
const inFence = fenceMask(lines)
|
|
58
|
+
const out = []
|
|
59
|
+
|
|
60
|
+
for (let i = 0; i < lines.length; i++) {
|
|
61
|
+
const header = ROW_RE.exec(lines[i])
|
|
62
|
+
// A table shown as an EXAMPLE inside a ``` block is documentation — often of
|
|
63
|
+
// this very bug — so it is left exactly as written.
|
|
64
|
+
if (!header || inFence[i] || !SEPARATOR_RE.test(lines[i + 1] || '')) {
|
|
65
|
+
out.push(lines[i])
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const indent = header[1]
|
|
70
|
+
const rows = [splitCells(header[2])]
|
|
71
|
+
const raw = [lines[i], lines[i + 1]]
|
|
72
|
+
let j = i + 2
|
|
73
|
+
for (; j < lines.length && !inFence[j]; j++) {
|
|
74
|
+
const row = ROW_RE.exec(lines[j])
|
|
75
|
+
if (!row) break
|
|
76
|
+
rows.push(splitCells(row[2]))
|
|
77
|
+
raw.push(lines[j])
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (rows[0].length === 2) {
|
|
81
|
+
// Header first, bolded: dropping it would lose content and inventing a
|
|
82
|
+
// caption would invent it.
|
|
83
|
+
out.push(`${indent}- **${rows[0][0]}** — **${rows[0][1]}**`)
|
|
84
|
+
for (const r of rows.slice(1)) out.push(`${indent}- ${r[0]} — ${r[1]}`)
|
|
85
|
+
} else {
|
|
86
|
+
out.push(`${indent}\`\`\``, ...raw, `${indent}\`\`\``)
|
|
87
|
+
}
|
|
88
|
+
i = j - 1
|
|
89
|
+
}
|
|
90
|
+
return out.join('\n')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
module.exports = { flattenNestedTables, splitCells }
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Terminal input helpers for a credentials CLI — reading a piped secret, and
|
|
5
|
+
* prompting for one with the echo off.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Read stdin to completion (for `--stdin`).
|
|
9
|
+
function readAllStdin(input) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
let data = ''
|
|
12
|
+
input.setEncoding('utf-8')
|
|
13
|
+
input.on('data', (chunk) => (data += chunk))
|
|
14
|
+
input.on('end', () => resolve(data))
|
|
15
|
+
input.on('error', reject)
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Prompt on a TTY with the input hidden. `_writeToOutput` is readline's own echo
|
|
20
|
+
// hook — filtering it is what keeps the key off the screen (and out of a
|
|
21
|
+
// screen-shared terminal or a recorded session).
|
|
22
|
+
//
|
|
23
|
+
// READLINE OWNS THE PROMPT, deliberately. Writing it ourselves and then starting
|
|
24
|
+
// the interface loses it: readline clears from the cursor to the end of the
|
|
25
|
+
// screen (`ESC[0J`) before its first redraw, so the prompt was wiped the instant
|
|
26
|
+
// it appeared and the user was left staring at a blank line while the process
|
|
27
|
+
// waited for a key — indistinguishable from a hang.
|
|
28
|
+
//
|
|
29
|
+
// The hook is assigned BEFORE `question`, so the very first redraw goes through
|
|
30
|
+
// it. readline hands it `prompt + what has been typed`; re-writing only the
|
|
31
|
+
// prompt is what keeps the key hidden while the prompt survives every redraw.
|
|
32
|
+
function promptHidden(question, input, out) {
|
|
33
|
+
const readline = require('node:readline')
|
|
34
|
+
return new Promise((resolve) => {
|
|
35
|
+
// `out`, never `process.stdout`: they are the same stream in production, and
|
|
36
|
+
// hardcoding one half meant readline cleared a screen the prompt had not
|
|
37
|
+
// been written to under test — the split that hid this bug from the suite.
|
|
38
|
+
const rl = readline.createInterface({ input, output: out, terminal: true })
|
|
39
|
+
rl._writeToOutput = (s) => {
|
|
40
|
+
if (s.includes(question)) out.write(question)
|
|
41
|
+
}
|
|
42
|
+
rl.question(question, (answer) => {
|
|
43
|
+
out.write('\n')
|
|
44
|
+
rl.close()
|
|
45
|
+
resolve(answer)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { readAllStdin, promptHidden }
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which tickets a release contains.
|
|
5
|
+
*
|
|
6
|
+
* The repo lands specs with `merge --ff-only`, so history is linear and branch
|
|
7
|
+
* names never reach it. A commit's `Refs:` trailer is therefore the only place
|
|
8
|
+
* the ticket survives into the range a release scans — see the
|
|
9
|
+
* `commit-trailers` rule. Nothing here is tracker-specific: the identifier
|
|
10
|
+
* shape defaults to `KEY-123` (Linear, Jira), and a provider whose refs look
|
|
11
|
+
* different (GitHub's `#123`) passes its own `refPattern`.
|
|
12
|
+
*
|
|
13
|
+
* The parsing half is pure and takes commits as data, so the rules below are
|
|
14
|
+
* testable without git. Reading the range, and enriching refs with issue titles,
|
|
15
|
+
* belong to the caller.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// An issue identifier: uppercase team key, dash, number. The default, used
|
|
19
|
+
// whenever a caller passes no `refPattern`.
|
|
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
|
|
29
|
+
// A `Refs:` trailer line. Anchored, so it is a line of its own — never prose
|
|
30
|
+
// that happens to mention the word.
|
|
31
|
+
const TRAILER_RE = /^Refs:[ \t]*(.+?)[ \t]*$/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The refs a single commit body claims.
|
|
35
|
+
*
|
|
36
|
+
* Deliberately blind to three things that LOOK like trailers and are not:
|
|
37
|
+
*
|
|
38
|
+
* - a line inside a ``` fence — a commit explaining the convention quotes it;
|
|
39
|
+
* - a quoted line (`> Refs: …`) — same reason, in review replies;
|
|
40
|
+
* - an indented line (4+ spaces) — markdown code, e.g. a sample message.
|
|
41
|
+
*
|
|
42
|
+
* Counting any of those would make a release claim work it does not contain,
|
|
43
|
+
* which is worse than missing a ticket: the missing one is noticed when someone
|
|
44
|
+
* looks for it, the invented one never is.
|
|
45
|
+
*/
|
|
46
|
+
function refsInBody(body, refPattern) {
|
|
47
|
+
const pattern = asGlobal(refPattern) || IDENTIFIER_RE
|
|
48
|
+
const found = []
|
|
49
|
+
let fenced = false
|
|
50
|
+
for (const raw of String(body || '').split('\n')) {
|
|
51
|
+
const line = raw.replace(/\r$/, '')
|
|
52
|
+
if (/^\s*```/.test(line)) {
|
|
53
|
+
fenced = !fenced
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
if (fenced) continue
|
|
57
|
+
if (/^\s*>/.test(line)) continue
|
|
58
|
+
if (/^ {4,}|\t/.test(line)) continue
|
|
59
|
+
const m = TRAILER_RE.exec(line.trim() === line ? line : line.trimStart())
|
|
60
|
+
if (!m) continue
|
|
61
|
+
for (const id of m[1].match(pattern) || []) found.push(id)
|
|
62
|
+
}
|
|
63
|
+
return found
|
|
64
|
+
}
|
|
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
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Whether a commit's changed paths are ENTIRELY bookkeeping.
|
|
76
|
+
*
|
|
77
|
+
* The problem it solves: a spec's `chore(spec): complete <name>` commit carries
|
|
78
|
+
* the same `Refs:` trailer as the code it describes, but lands after the tag
|
|
79
|
+
* that shipped that code — so the ticket turns up in two consecutive release
|
|
80
|
+
* ranges, once for its code and once for its paperwork.
|
|
81
|
+
*
|
|
82
|
+
* Deliberately a POSITIVE signal: it says yes only when it actually saw paths
|
|
83
|
+
* and every one of them sits under an ignored prefix. It never reasons from an
|
|
84
|
+
* absence.
|
|
85
|
+
*
|
|
86
|
+
* WHAT WOULD FOOL THE OTHER PHRASING ("no unignored path was found"): git lists
|
|
87
|
+
* no files at all for a MERGE commit (without `-m`) — verified, not assumed —
|
|
88
|
+
* nor for a genuinely empty one, and a `git log` that failed outright yields no
|
|
89
|
+
* paths for anything. Under that phrasing every one of those becomes a release
|
|
90
|
+
* silently losing its tickets. Under this one they mean "nothing was seen, so
|
|
91
|
+
* nothing is known", which keeps the pre-filter behaviour: an over-claimed
|
|
92
|
+
* ticket is noticed when someone looks for it, whereas a ticket that quietly
|
|
93
|
+
* belongs to no release never is.
|
|
94
|
+
*
|
|
95
|
+
* Matching is path-prefix, not glob: `specs` and `specs/` both mean the
|
|
96
|
+
* directory, and a prefix only matches on a path SEGMENT boundary, so `specs/`
|
|
97
|
+
* never swallows `specs-archive/`.
|
|
98
|
+
*
|
|
99
|
+
* @param {string[]|null|undefined} paths repo-relative paths the commit changed
|
|
100
|
+
* @param {string[]} ignorePaths repo-relative prefixes that are bookkeeping
|
|
101
|
+
*/
|
|
102
|
+
function onlyIgnoredPaths(paths, ignorePaths) {
|
|
103
|
+
if (!Array.isArray(paths) || !paths.length) return false
|
|
104
|
+
const prefixes = (Array.isArray(ignorePaths) ? ignorePaths : [])
|
|
105
|
+
.filter((p) => typeof p === 'string' && p.trim())
|
|
106
|
+
.map((p) => p.trim().replace(/^\.\//, '').replace(/\/+$/, ''))
|
|
107
|
+
.filter(Boolean)
|
|
108
|
+
if (!prefixes.length) return false
|
|
109
|
+
return paths.every((raw) => {
|
|
110
|
+
const file = String(raw == null ? '' : raw)
|
|
111
|
+
.trim()
|
|
112
|
+
.replace(/^\.\//, '')
|
|
113
|
+
// An unreadable entry is an unknown, not an ignored one — it makes the whole
|
|
114
|
+
// commit count, per the bias above.
|
|
115
|
+
if (!file) return false
|
|
116
|
+
return prefixes.some((prefix) => file === prefix || file.startsWith(`${prefix}/`))
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Fold commits into the report a release needs.
|
|
122
|
+
*
|
|
123
|
+
* @param {Array<{sha?:string, subject?:string, body?:string, paths?:string[]}>} commits
|
|
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.
|
|
136
|
+
* `tickets` is deduped in FIRST-SEEN order: a ticket touched by eight commits
|
|
137
|
+
* is listed once, where it first appears, not eight times. `ignored` is
|
|
138
|
+
* reported rather than merely subtracted — a filter that removes commits in
|
|
139
|
+
* silence reads as "there was nothing there".
|
|
140
|
+
*/
|
|
141
|
+
function ticketsInRange(commits, options = {}) {
|
|
142
|
+
const ignorePaths = (options && options.ignorePaths) || []
|
|
143
|
+
const refPattern = options && options.refPattern
|
|
144
|
+
const foreign = ((options && options.foreignPatterns) || []).map(asGlobal).filter(Boolean)
|
|
145
|
+
const seen = new Map()
|
|
146
|
+
let unreferenced = 0
|
|
147
|
+
let ignored = 0
|
|
148
|
+
let historical = 0
|
|
149
|
+
for (const commit of commits || []) {
|
|
150
|
+
if (onlyIgnoredPaths(commit && commit.paths, ignorePaths)) {
|
|
151
|
+
// Not counted as unreferenced either: that number exists to surface a
|
|
152
|
+
// MISSED trailer, and a paperwork commit is not a gap someone should hunt.
|
|
153
|
+
ignored++
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
const refs = [...new Set(refsInBody(commit && commit.body, refPattern))]
|
|
157
|
+
if (!refs.length) {
|
|
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++
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
for (const ref of refs) seen.set(ref, (seen.get(ref) || 0) + 1)
|
|
166
|
+
}
|
|
167
|
+
const report = {
|
|
168
|
+
tickets: [...seen.entries()].map(([ref, count]) => ({ ref, commits: count })),
|
|
169
|
+
unreferenced,
|
|
170
|
+
ignored,
|
|
171
|
+
total: (commits || []).length,
|
|
172
|
+
}
|
|
173
|
+
if (foreign.length) report.historical = historical
|
|
174
|
+
return report
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Name the spec behind each ticket a range contains, so a consumer can act on
|
|
179
|
+
* a release without a second lookup.
|
|
180
|
+
*
|
|
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.
|
|
185
|
+
*
|
|
186
|
+
* Pure: it takes the spec list as data (`listSpecs` rows), so it is testable
|
|
187
|
+
* without a repo.
|
|
188
|
+
*/
|
|
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)
|
|
193
|
+
}
|
|
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
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
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`,
|
|
207
|
+
)
|
|
208
|
+
return 1
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
module.exports = {
|
|
212
|
+
ticketsInRange,
|
|
213
|
+
refuseRemovedVerb,
|
|
214
|
+
nameTickets,
|
|
215
|
+
refsInBody,
|
|
216
|
+
onlyIgnoredPaths,
|
|
217
|
+
IDENTIFIER_RE,
|
|
218
|
+
GITHUB_REF_RE,
|
|
219
|
+
}
|