@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.
Files changed (113) hide show
  1. package/MIGRATION.md +1266 -0
  2. package/README.md +44 -2
  3. package/assets/claude-md-section.md +85 -0
  4. package/assets/commands/allow-main.md +33 -0
  5. package/assets/commands/spec-connect.md +13 -0
  6. package/assets/commands/spec-live.md +14 -0
  7. package/assets/commands/spec-remote-review.md +37 -0
  8. package/assets/commands/spec-skip.md +24 -0
  9. package/assets/core/SETUP.md +192 -0
  10. package/assets/core/env.config.json.example +56 -0
  11. package/assets/core/env.config.md +606 -0
  12. package/assets/core/gating.config.json.example +4 -0
  13. package/assets/core/gating.config.md +81 -0
  14. package/assets/core/jira.config.json.example +18 -0
  15. package/assets/core/jira.config.md +88 -0
  16. package/assets/hooks/main-guard.cjs +162 -0
  17. package/assets/hooks/review-gate.cjs +146 -0
  18. package/assets/review/page.html +3384 -0
  19. package/assets/rules/commit-trailers.md +96 -0
  20. package/assets/rules/negative-checks.md +69 -0
  21. package/assets/rules/offered-unblocks.md +100 -0
  22. package/assets/rules/spec-planning.md +702 -0
  23. package/assets/rules/spec-reports.md +572 -0
  24. package/assets/skills/no-spec/SKILL.md +249 -0
  25. package/assets/skills/spec/SKILL.md +714 -0
  26. package/assets/skills/spec-bug/SKILL.md +585 -0
  27. package/assets/skills/spec-cancel/SKILL.md +261 -0
  28. package/assets/skills/spec-complete/SKILL.md +451 -0
  29. package/assets/skills/spec-diff/SKILL.md +870 -0
  30. package/assets/skills/spec-hotfix/SKILL.md +607 -0
  31. package/assets/skills/spec-init/SKILL.md +178 -0
  32. package/assets/skills/spec-jira-setup/SKILL.md +221 -0
  33. package/assets/skills/spec-list/SKILL.md +173 -0
  34. package/assets/skills/spec-next/SKILL.md +678 -0
  35. package/assets/skills/spec-push/SKILL.md +306 -0
  36. package/assets/skills/spec-review/SKILL.md +256 -0
  37. package/assets/skills/spec-reviewed/SKILL.md +284 -0
  38. package/assets/skills/spec-start/SKILL.md +496 -0
  39. package/assets/skills/spec-status/SKILL.md +114 -0
  40. package/assets/skills/spec-sync/SKILL.md +220 -0
  41. package/assets/skills/spec-to-main/SKILL.md +141 -0
  42. package/bin/skitterspec-jira.js +68 -0
  43. package/package.json +40 -4
  44. package/src/cli.js +5967 -0
  45. package/src/deprecate.js +138 -0
  46. package/src/env/bootstrap.js +188 -0
  47. package/src/env/building.js +143 -0
  48. package/src/env/classify.js +176 -0
  49. package/src/env/commitcmd.js +108 -0
  50. package/src/env/config.js +850 -0
  51. package/src/env/dev.js +63 -0
  52. package/src/env/glob.js +34 -0
  53. package/src/env/hooks.js +226 -0
  54. package/src/env/hotfix.js +413 -0
  55. package/src/env/hotfixland.js +302 -0
  56. package/src/env/integrate.js +167 -0
  57. package/src/env/live.js +479 -0
  58. package/src/env/mainguard.js +299 -0
  59. package/src/env/provision.js +466 -0
  60. package/src/env/proxy.js +225 -0
  61. package/src/env/prune.js +119 -0
  62. package/src/env/registry.js +159 -0
  63. package/src/env/render.js +17 -0
  64. package/src/env/resolve.js +716 -0
  65. package/src/env/review.js +2622 -0
  66. package/src/env/reviewers/coderabbit.js +228 -0
  67. package/src/env/reviewers.js +517 -0
  68. package/src/env/serve.js +1044 -0
  69. package/src/env/supervise.js +157 -0
  70. package/src/env/teardown.js +248 -0
  71. package/src/env/trust.js +87 -0
  72. package/src/gating.js +155 -0
  73. package/src/init.js +1150 -0
  74. package/src/lines-diff.js +114 -0
  75. package/src/prompts.js +129 -0
  76. package/src/vendor/jira/adf.js +414 -0
  77. package/src/vendor/jira/api.js +295 -0
  78. package/src/vendor/jira/cli-sync.js +1252 -0
  79. package/src/vendor/jira/commands.js +44 -0
  80. package/src/vendor/jira/config.js +286 -0
  81. package/src/vendor/jira/credentials.js +108 -0
  82. package/src/vendor/jira/dialect.js +45 -0
  83. package/src/vendor/jira/mcp.js +129 -0
  84. package/src/vendor/provider-kit/index.js +41 -0
  85. package/src/vendor/provider-kit/src/adapter.js +23 -0
  86. package/src/vendor/provider-kit/src/apply.js +207 -0
  87. package/src/vendor/provider-kit/src/args.js +68 -0
  88. package/src/vendor/provider-kit/src/assignment.js +277 -0
  89. package/src/vendor/provider-kit/src/config.js +251 -0
  90. package/src/vendor/provider-kit/src/credentials.js +404 -0
  91. package/src/vendor/provider-kit/src/doctor.js +35 -0
  92. package/src/vendor/provider-kit/src/http.js +63 -0
  93. package/src/vendor/provider-kit/src/identity.js +93 -0
  94. package/src/vendor/provider-kit/src/list-assignee.js +71 -0
  95. package/src/vendor/provider-kit/src/list-rows.js +35 -0
  96. package/src/vendor/provider-kit/src/md-compare.js +39 -0
  97. package/src/vendor/provider-kit/src/md-tables.js +94 -0
  98. package/src/vendor/provider-kit/src/prompt.js +50 -0
  99. package/src/vendor/provider-kit/src/released.js +219 -0
  100. package/src/vendor/provider-kit/src/specs.js +248 -0
  101. package/src/vendor/provider-kit/src/transport.js +22 -0
  102. package/src/vendor/sync-core/index.js +92 -0
  103. package/src/vendor/sync-core/src/base.js +81 -0
  104. package/src/vendor/sync-core/src/compare.js +338 -0
  105. package/src/vendor/sync-core/src/dialect.js +51 -0
  106. package/src/vendor/sync-core/src/fields.js +36 -0
  107. package/src/vendor/sync-core/src/legacy.js +100 -0
  108. package/src/vendor/sync-core/src/normalize.js +971 -0
  109. package/src/vendor/sync-core/src/push.js +85 -0
  110. package/src/vendor/sync-core/src/retarget.js +278 -0
  111. package/src/vendor/sync-core/src/task-block.js +308 -0
  112. package/src/vendor/sync-core/src/verify.js +67 -0
  113. 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
+ }