@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,44 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The commands this provider distribution adds on top of the base CLI.
5
+ *
6
+ * ONE table drives both routing and `--help`, exactly as the Linear provider's
7
+ * does: anything added here is routed and documented in the same edit, so the
8
+ * two cannot drift.
9
+ *
10
+ * `run(rest)` returns an exit code (the bin propagates it); `summary` is the
11
+ * one-line description `--help` prints.
12
+ */
13
+
14
+ const { specSync } = require('./cli-sync.js')
15
+
16
+ const DIST = '@skitterbyte/skitterspec-jira'
17
+
18
+ const PROVIDER_COMMANDS = {
19
+ 'spec-sync': {
20
+ run: specSync,
21
+ usage: 'skitterspec spec-sync <cmd>',
22
+ summary:
23
+ 'One-way sync to Jira Cloud (repo -> tracker; opt-in, needs\n' +
24
+ 'specs/.core/jira.config.json). Run it with no args to\n' +
25
+ 'list its subcommands.',
26
+ },
27
+ }
28
+
29
+ // The `--help` section for these commands, in the base HELP's column layout:
30
+ // two-space indent, description starting at column 30, continuations aligned.
31
+ const COL = 30
32
+
33
+ function providerHelpSection() {
34
+ const lines = [`Provider commands (${DIST}):`]
35
+ for (const name of Object.keys(PROVIDER_COMMANDS)) {
36
+ const { usage, summary } = PROVIDER_COMMANDS[name]
37
+ const [first, ...rest] = summary.split('\n')
38
+ lines.push(` ${usage.padEnd(COL - 2)}${first}`)
39
+ for (const line of rest) lines.push(`${' '.repeat(COL)}${line}`)
40
+ }
41
+ return lines.join('\n') + '\n'
42
+ }
43
+
44
+ module.exports = { PROVIDER_COMMANDS, providerHelpSection, DIST }
@@ -0,0 +1,286 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * `specs/.core/jira.config.json` — the committed, shareable half of this
5
+ * provider's setup. It holds ids, names and env-var NAMES; never a credential
6
+ * (those live in the user-level store — `credentials.js`).
7
+ *
8
+ * The merge/validation machinery is the kit's (`provider-kit/src/config.js`);
9
+ * this module owns what is Jira's: the defaults, the file name, and the
10
+ * vocabularies.
11
+ */
12
+
13
+ const { readFileSync } = require('node:fs')
14
+ const { join } = require('node:path')
15
+
16
+ const kitConfig = require('../provider-kit/src/config.js')
17
+ const { dialect } = require('./dialect.js')
18
+ const { adfToText } = require('./adf.js')
19
+
20
+ const CONFIG_FILE = 'specs/.core/jira.config.json'
21
+
22
+ const OWNERSHIP = Object.freeze(['both', 'pull', 'push', 'none'])
23
+ const TASK_MAPPINGS = Object.freeze(['checklist', 'none'])
24
+ const PHASE_MAPPINGS = Object.freeze(['subissue', 'deferred', 'inline'])
25
+ const TRANSPORTS = Object.freeze(['api', 'mcp'])
26
+ const DEFAULT_EMAIL_ENV = 'JIRA_EMAIL'
27
+ const DEFAULT_TOKEN_ENV = 'JIRA_API_TOKEN'
28
+ const DEFAULT_PHASE_MODE = 'subissue'
29
+
30
+ const DEFAULT_CONFIG = Object.freeze({
31
+ // `siteUrl` is the Jira Cloud site (https://<site>.atlassian.net); it also
32
+ // keys the credentials-store entry, so one store serves every checkout.
33
+ // `projectKey` is where spec issues land — Jira's project is the analogue of
34
+ // Linear's team, so there is no separate project picker: placement is decided
35
+ // here, once. `issueType`/`subtaskType` name the types this site uses for a
36
+ // spec and its phases; workflows and type names vary per site, which is why
37
+ // both are config rather than constants.
38
+ jira: Object.freeze({ siteUrl: '', projectKey: '', issueType: 'Task', subtaskType: 'Subtask' }),
39
+ // A spec is a Jira ISSUE; each phase a SUBTASK of it. `tasks` selects how the
40
+ // phase's checkboxes reach that subtask's description (as an ADF task list —
41
+ // live checkboxes, still a read-only mirror).
42
+ mapping: Object.freeze({ specFolder: 'issue', phases: DEFAULT_PHASE_MODE, tasks: 'checklist' }),
43
+ // Jira STATUS names — the spec issue's status (from the folder bucket) and
44
+ // each subtask's status (from the phase emoji) both map through this table.
45
+ // They must match the project's workflow statuses exactly; `validateStates`
46
+ // checks them at push/status time. `cancelled` defaults to Done because the
47
+ // default Jira workflows carry no Cancelled status — a site that has one
48
+ // overrides this.
49
+ states: Object.freeze({
50
+ backlog: 'To Do',
51
+ 'in-progress': 'In Progress',
52
+ complete: 'Done',
53
+ cancelled: 'Done',
54
+ }),
55
+ snapshot: Object.freeze({ overviewFile: '00-overview.md' }),
56
+ // The frontmatter FIELD VOCABULARY this provider stamps into spec files
57
+ // (sync-core `src/fields.js`). No legacy keys: this provider has no pre-9.0
58
+ // era, so legacy-mirror detection reports nothing by construction.
59
+ fields: Object.freeze({
60
+ identifier: 'jira_identifier',
61
+ url: 'jira_url',
62
+ issueId: 'jira_issue_id',
63
+ assigneeId: 'jira_assignee_id',
64
+ assigneeName: 'jira_assignee_name',
65
+ }),
66
+ release: Object.freeze({ ignorePaths: Object.freeze(['specs/', 'CHANGELOG.md', 'RELEASES.md']) }),
67
+ branch: Object.freeze({ pattern: '{type}/{slug}' }),
68
+ // Env-var NAMES, not values: Jira Cloud authenticates with an email + API
69
+ // token pair (basic auth), so there are two of them.
70
+ auth: Object.freeze({ emailEnv: DEFAULT_EMAIL_ENV, tokenEnv: DEFAULT_TOKEN_ENV }),
71
+ apply: Object.freeze({ transport: '' }),
72
+ sync: Object.freeze({
73
+ baseDir: 'specs/.core/jira-base',
74
+ backupDir: 'specs/.core/jira-backups',
75
+ fieldOwnership: Object.freeze({
76
+ description: 'push',
77
+ subIssues: 'push',
78
+ workflowState: 'push',
79
+ assignee: 'push',
80
+ }),
81
+ localOnlySections: Object.freeze(['State log', 'Changelog', 'Open questions']),
82
+ keyedFields: Object.freeze({}),
83
+ }),
84
+ })
85
+
86
+ const IDENTIFIER_FIELD = DEFAULT_CONFIG.fields.identifier
87
+
88
+ // The per-spec-isolation keys THIS PROVIDER needs a project to declare, merged
89
+ // into env.config.json.example at dist-compose time (see build-dist.js).
90
+ const ENV_CONFIG_DEFAULTS = Object.freeze({
91
+ branch: Object.freeze({ identifierField: IDENTIFIER_FIELD }),
92
+ spec: Object.freeze({
93
+ companionPaths: Object.freeze([`${DEFAULT_CONFIG.sync.baseDir}/{identifier}.base.json`]),
94
+ }),
95
+ })
96
+
97
+ const LIFECYCLE_BUCKETS = Object.freeze(Object.keys(DEFAULT_CONFIG.states))
98
+
99
+ // A fresh, deeply-mutable copy of the defaults to merge onto.
100
+ function defaults() {
101
+ return {
102
+ jira: { ...DEFAULT_CONFIG.jira },
103
+ mapping: { ...DEFAULT_CONFIG.mapping },
104
+ states: { ...DEFAULT_CONFIG.states },
105
+ stateAliases: {},
106
+ snapshot: { ...DEFAULT_CONFIG.snapshot },
107
+ fields: { ...DEFAULT_CONFIG.fields },
108
+ release: {
109
+ ignorePaths: [...DEFAULT_CONFIG.release.ignorePaths],
110
+ },
111
+ branch: { ...DEFAULT_CONFIG.branch },
112
+ auth: { ...DEFAULT_CONFIG.auth },
113
+ apply: { ...DEFAULT_CONFIG.apply },
114
+ sync: {
115
+ baseDir: DEFAULT_CONFIG.sync.baseDir,
116
+ backupDir: DEFAULT_CONFIG.sync.backupDir,
117
+ fieldOwnership: { ...DEFAULT_CONFIG.sync.fieldOwnership },
118
+ localOnlySections: [...DEFAULT_CONFIG.sync.localOnlySections],
119
+ keyedFields: { ...DEFAULT_CONFIG.sync.keyedFields },
120
+ },
121
+ }
122
+ }
123
+
124
+ const { isObject, stringList, assign } = kitConfig
125
+ const { mergeStates, mergeFieldOwnership, mergePhaseMapping, mergeReleaseIgnorePaths, mergeKeyedFields } =
126
+ kitConfig.makeMergeHelpers({
127
+ configFile: CONFIG_FILE,
128
+ ownership: OWNERSHIP,
129
+ lifecycleBuckets: LIFECYCLE_BUCKETS,
130
+ phaseMappings: PHASE_MAPPINGS,
131
+ })
132
+
133
+ /**
134
+ * Merge a parsed config over the defaults. Only known keys are copied (unknown
135
+ * keys ignored for forward-compat). Nested objects are merged field-by-field.
136
+ */
137
+ function mergeConfig(base, parsed) {
138
+ if (!isObject(parsed)) return base
139
+
140
+ if (isObject(parsed.jira)) {
141
+ assign(base.jira, parsed.jira, 'siteUrl', 'string?')
142
+ assign(base.jira, parsed.jira, 'projectKey', 'string?')
143
+ assign(base.jira, parsed.jira, 'issueType', 'string')
144
+ assign(base.jira, parsed.jira, 'subtaskType', 'string')
145
+ // A trailing slash on the site would double up in every composed URL.
146
+ if (base.jira.siteUrl) base.jira.siteUrl = base.jira.siteUrl.replace(/\/+$/, '')
147
+ }
148
+
149
+ if (isObject(parsed.mapping)) {
150
+ assign(base.mapping, parsed.mapping, 'specFolder', 'string')
151
+ assign(base.mapping, parsed.mapping, 'tasks', 'string')
152
+ if (!TASK_MAPPINGS.includes(base.mapping.tasks)) {
153
+ throw new Error(
154
+ `Invalid ${CONFIG_FILE}: mapping.tasks = ${JSON.stringify(base.mapping.tasks)} ` +
155
+ `(expected one of ${TASK_MAPPINGS.join('|')})`,
156
+ )
157
+ }
158
+ mergePhaseMapping(base.mapping, parsed.mapping)
159
+ }
160
+
161
+ mergeStates(base, parsed)
162
+
163
+ if (isObject(parsed.snapshot)) {
164
+ assign(base.snapshot, parsed.snapshot, 'overviewFile', 'string')
165
+ }
166
+
167
+ if (isObject(parsed.release)) {
168
+ mergeReleaseIgnorePaths(base.release, parsed.release)
169
+ }
170
+
171
+ if (isObject(parsed.branch)) {
172
+ assign(base.branch, parsed.branch, 'pattern', 'string')
173
+ }
174
+
175
+ if (isObject(parsed.auth)) {
176
+ assign(base.auth, parsed.auth, 'emailEnv', 'string')
177
+ assign(base.auth, parsed.auth, 'tokenEnv', 'string')
178
+ }
179
+
180
+ if (isObject(parsed.apply)) {
181
+ assign(base.apply, parsed.apply, 'transport', 'string?')
182
+ if (base.apply.transport && !TRANSPORTS.includes(base.apply.transport)) {
183
+ throw new Error(
184
+ `Invalid ${CONFIG_FILE}: apply.transport = ${JSON.stringify(base.apply.transport)} ` +
185
+ `(expected one of ${TRANSPORTS.join('|')})`,
186
+ )
187
+ }
188
+ }
189
+
190
+ if (isObject(parsed.sync)) {
191
+ assign(base.sync, parsed.sync, 'baseDir', 'string')
192
+ assign(base.sync, parsed.sync, 'backupDir', 'string')
193
+ mergeFieldOwnership(base.sync.fieldOwnership, parsed.sync.fieldOwnership)
194
+ if (Array.isArray(parsed.sync.localOnlySections)) {
195
+ base.sync.localOnlySections = stringList(parsed.sync.localOnlySections)
196
+ }
197
+ mergeKeyedFields(base.sync.keyedFields, parsed.sync.keyedFields)
198
+ }
199
+
200
+ return base
201
+ }
202
+
203
+ // Attach the parts of the provider contract that are code, not JSON: the ADF
204
+ // dialect, and the `provider` block retarget reads (Jira's project key is the
205
+ // identifier prefix, the analogue of Linear's team key). Runs on BOTH loader
206
+ // paths so an absent config still carries them.
207
+ function withRuntime(config) {
208
+ config.dialect = dialect
209
+ config.provider = {
210
+ teamKey: (config.jira && config.jira.projectKey) || '',
211
+ configFile: CONFIG_FILE,
212
+ teamKeyJsonField: 'projectKey',
213
+ }
214
+ // How sync-core reads a REMOTE issue this provider hands it. `--remote` is
215
+ // documented as "fetch the issue JSON", which is the raw REST shape —
216
+ // everything under `fields`, the description as ADF — where the default
217
+ // readers expect the flat Linear-like shape. Without these hooks every
218
+ // drift check routes to its silent cannot-tell branch, so `status
219
+ // --remote` can never report anything. Each hook falls back to the flat
220
+ // shape, so a pre-reduced payload keeps working.
221
+ config.remote = {
222
+ stateName(issue) {
223
+ const st = issue && issue.fields && issue.fields.status
224
+ if (st && typeof st === 'object' && st.name != null) return st.name
225
+ const flat = issue && (issue.state != null ? issue.state : issue.status)
226
+ if (flat == null) return null
227
+ if (typeof flat === 'object') return flat.name != null ? flat.name : flat.type != null ? flat.type : null
228
+ return flat
229
+ },
230
+ description(issue) {
231
+ const fields = issue && issue.fields
232
+ if (fields && typeof fields === 'object' && 'description' in fields) {
233
+ // null IS an answer on this branch: Jira returns it when the field
234
+ // was fetched and the description is genuinely empty — clearing a
235
+ // description on Jira is an edit, not a cannot-tell.
236
+ const d = fields.description
237
+ return d == null ? '' : typeof d === 'string' ? d : adfToText(d)
238
+ }
239
+ return issue && typeof issue.description === 'string' ? issue.description : undefined
240
+ },
241
+ }
242
+ return config
243
+ }
244
+
245
+ function loadJiraConfig(dir = process.cwd()) {
246
+ const base = defaults()
247
+ const file = join(dir, CONFIG_FILE)
248
+
249
+ let raw
250
+ try {
251
+ raw = readFileSync(file, 'utf-8')
252
+ } catch (error) {
253
+ if (error.code === 'ENOENT') return { config: withRuntime(base), present: false }
254
+ throw error
255
+ }
256
+
257
+ let parsed
258
+ try {
259
+ parsed = JSON.parse(raw)
260
+ } catch (error) {
261
+ throw new Error(`Invalid ${CONFIG_FILE}: ${error.message}`)
262
+ }
263
+
264
+ return { config: withRuntime(mergeConfig(base, parsed)), present: true }
265
+ }
266
+
267
+ // The bookkeeping path prefixes `released` ignores.
268
+ const releaseIgnorePaths = (config) => (config.release && config.release.ignorePaths) || []
269
+
270
+ module.exports = {
271
+ loadJiraConfig,
272
+ mergeConfig,
273
+ defaults,
274
+ releaseIgnorePaths,
275
+ DEFAULT_CONFIG,
276
+ CONFIG_FILE,
277
+ OWNERSHIP,
278
+ TASK_MAPPINGS,
279
+ PHASE_MAPPINGS,
280
+ LIFECYCLE_BUCKETS,
281
+ TRANSPORTS,
282
+ DEFAULT_EMAIL_ENV,
283
+ DEFAULT_TOKEN_ENV,
284
+ ENV_CONFIG_DEFAULTS,
285
+ IDENTIFIER_FIELD,
286
+ }
@@ -0,0 +1,108 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The Jira binding of the shared credentials store
5
+ * (`@skitterbyte/skitterspec-provider-kit`, `src/credentials.js`) — one file
6
+ * per machine at `$XDG_CONFIG_HOME/skitterspec/credentials.json`, this
7
+ * provider's entries keyed by SITE URL so one store serves every checkout.
8
+ *
9
+ * Jira Cloud authenticates with an email + API-token pair (basic auth), so the
10
+ * stored credential is COMPOUND — `{ email, token }` — where Linear's is a
11
+ * bare string. The kit store passes compounds through opaquely
12
+ * (`credentialFor`), and the token half is a secret on exactly the same terms
13
+ * as a key: never logged, never in an error, never in argv.
14
+ *
15
+ * Environment wins over the store: `auth.emailEnv` + `auth.tokenEnv` (both
16
+ * required — an email without a token authenticates nothing).
17
+ */
18
+
19
+ const kit = require('../provider-kit/src/credentials.js')
20
+
21
+ // v1 stores were only ever written by the Linear provider (the store predates
22
+ // the namespace), so this binding is NOT their owner: its first write migrates
23
+ // a v1 file to the namespaced v2 shape, folding the old entries under linear.
24
+ const store = kit.makeCredentialsStore({ provider: 'jira', legacyV1Owner: 'linear' })
25
+
26
+ // An Atlassian API token pasted where a command belongs. Tokens are opaque
27
+ // strings; the modern ones carry this prefix.
28
+ const LITERAL_TOKEN_RE = /ATATT[A-Za-z0-9_=-]{8,}/
29
+
30
+ const LITERAL_TOKEN_ADVICE =
31
+ 'that command has a Jira API token written into it.\n' +
32
+ ' A command is displayed by `credentials status` and is not treated as a\n' +
33
+ ' secret — embedding a token there exposes it. To store credentials, run\n' +
34
+ ' `credentials set` with no arguments and paste at the hidden prompt.'
35
+
36
+ const siteOf = (config) => (config && config.jira && config.jira.siteUrl) || ''
37
+
38
+ /**
39
+ * Resolve the email + token pair: environment first, then the store.
40
+ *
41
+ * @returns {{ ok: true, email, token, source } | { ok: false, error, envVars }}
42
+ * `source` is `'env'` or `'store'`. The error names what was looked for and
43
+ * where; it never contains a token.
44
+ */
45
+ function resolveCredential(config, env = process.env, deps = {}) {
46
+ const emailEnv = (config && config.auth && config.auth.emailEnv) || 'JIRA_EMAIL'
47
+ const tokenEnv = (config && config.auth && config.auth.tokenEnv) || 'JIRA_API_TOKEN'
48
+ const email = (env[emailEnv] || '').trim()
49
+ const token = (env[tokenEnv] || '').trim()
50
+ if (email && token) return { ok: true, email, token, source: 'env', envVars: [emailEnv, tokenEnv] }
51
+
52
+ const site = siteOf(config)
53
+ const file = (deps.storePath || kit.storePath)(env)
54
+ if (site) {
55
+ const read = (deps.readStore || kit.readStore)(file)
56
+ if (read.ok) {
57
+ const cred = store.credentialFor(read.store, site)
58
+ if (cred && typeof cred.email === 'string' && typeof cred.token === 'string' && cred.email && cred.token) {
59
+ return { ok: true, email: cred.email, token: cred.token, source: 'store', path: file }
60
+ }
61
+ } else if (read.code !== 'absent') {
62
+ return { ok: false, error: read.reason, envVars: [emailEnv, tokenEnv], path: file }
63
+ }
64
+ }
65
+
66
+ const half = email || token ? `\n (${email ? tokenEnv : emailEnv} is unset — both are required)` : ''
67
+ return {
68
+ ok: false,
69
+ error:
70
+ `no Jira credentials: set ${emailEnv} and ${tokenEnv}, or run ` +
71
+ `\`skitterspec-jira spec-sync credentials set\`${half}`,
72
+ envVars: [emailEnv, tokenEnv],
73
+ path: file,
74
+ }
75
+ }
76
+
77
+ // The basic-auth header value for a resolved credential.
78
+ function authHeader({ email, token }) {
79
+ return `Basic ${Buffer.from(`${email}:${token}`).toString('base64')}`
80
+ }
81
+
82
+ const writeCredential = (file, site, email, token, deps) =>
83
+ store.writeKey(file, site, { email, token }, deps)
84
+ const userForSite = (s, site) => store.userFor(s, site)
85
+ const writeUser = (file, site, user, deps) => store.writeUser(file, site, user, deps)
86
+ const removeUser = (file, site, deps) => store.removeUser(file, site, deps)
87
+ const removeCredential = (file, site, deps) => store.removeKey(file, site, deps)
88
+ const writeKeyCommand = (file, site, command, deps) =>
89
+ store.writeKeyCommand(file, site, command, deps, {
90
+ literalKeyRe: LITERAL_TOKEN_RE,
91
+ literalKeyAdvice: LITERAL_TOKEN_ADVICE,
92
+ })
93
+
94
+ module.exports = {
95
+ storePath: kit.storePath,
96
+ readStore: kit.readStore,
97
+ storeMode: kit.storeMode,
98
+ fingerprint: kit.fingerprint,
99
+ resolveCredential,
100
+ authHeader,
101
+ siteOf,
102
+ writeCredential,
103
+ userForSite,
104
+ writeUser,
105
+ removeUser,
106
+ removeCredential,
107
+ writeKeyCommand,
108
+ }
@@ -0,0 +1,45 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The Jira Cloud dialect — how projections are shaped for, and read back from,
5
+ * a tracker that stores ADF rather than markdown.
6
+ *
7
+ * The ADF conversion itself lives in `adf.js` and is applied at the ADAPTER
8
+ * boundary (`api.js`): the engine only ever sees markdown text. What the
9
+ * dialect hooks carry (sync-core `src/dialect.js`):
10
+ *
11
+ * - `canonicalize` — light markdown canonicalisation of the outbound
12
+ * projection: bullet markers unify, trailing whitespace and blank runs
13
+ * collapse. No emphasis surgery — ADF has no straddle mangle to defend
14
+ * against; the renderer joins soft-wrapped lines itself.
15
+ * - `flattenTables` — ADF's `listItem` cannot contain a `table` node, so a
16
+ * table nested in a list item is re-shaped before it reaches the renderer
17
+ * (shared with the Linear dialect — kit `src/md-tables.js`).
18
+ * - `canonicalForCompare` — the shared markdown-form reduction (kit
19
+ * `src/md-compare.js`): read-backs are our own `adfToText` rendering, which
20
+ * re-emits `-` bullets, renumbered ordered lists and `| -- |` separators
21
+ * whatever the source spelled, and the reduction makes exactly those
22
+ * differences invisible.
23
+ */
24
+
25
+ const { flattenNestedTables } = require('../provider-kit/src/md-tables.js')
26
+ const { markdownCanonicalForCompare } = require('../provider-kit/src/md-compare.js')
27
+
28
+ function canonicalizeMarkdown(text) {
29
+ if (text == null) return text
30
+ return String(text)
31
+ .replace(/\r\n/g, '\n')
32
+ .split('\n')
33
+ .map((line) => line.replace(/^(\s*)[*+-]( +)/, '$1-$2').replace(/[ \t]+$/, ''))
34
+ .join('\n')
35
+ .replace(/\n{3,}/g, '\n\n')
36
+ .trim()
37
+ }
38
+
39
+ const dialect = Object.freeze({
40
+ canonicalize: canonicalizeMarkdown,
41
+ flattenTables: flattenNestedTables,
42
+ canonicalForCompare: markdownCanonicalForCompare,
43
+ })
44
+
45
+ module.exports = { dialect, canonicalizeMarkdown }
@@ -0,0 +1,129 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The MCP transport: resolve the connected Atlassian MCP server's tool names
5
+ * at runtime and wrap them in the same op shapes `api.js` exposes, so the two
6
+ * transports are interchangeable where MCP can express the operation at all.
7
+ *
8
+ * Tool names are DISCOVERED, never hardcoded as a single truth: Atlassian's
9
+ * remote server (mcp.atlassian.com) names its tools one way today
10
+ * (`getJiraIssue`, `createJiraIssue`, `editJiraIssue`,
11
+ * `searchJiraIssuesUsingJql`, `transitionJiraIssue`, …) and a self-hosted
12
+ * bridge may spell them another. Each matcher is an ordered regex list;
13
+ * first advertised tool that matches wins.
14
+ *
15
+ * The engine itself never CALLS an MCP tool — the skill does, and hands
16
+ * results to the engine as files (the same `--workspace-states` /
17
+ * `--stored` idiom the Linear provider uses). This module exists so a skill
18
+ * can ask which tools to call and so `doctor` can report a usable server.
19
+ */
20
+
21
+ const MATCHERS = {
22
+ issueRead: [/^getJiraIssue$/i, /\bget.*jira.*issue\b/i, /^get_issue$/i],
23
+ issueCreate: [/^createJiraIssue$/i, /\bcreate.*jira.*issue\b/i, /^create_issue$/i],
24
+ issueEdit: [/^editJiraIssue$/i, /\bedit.*jira.*issue\b/i, /^update_issue$/i],
25
+ issueSearch: [/^searchJiraIssuesUsingJql$/i, /\bsearch.*jira.*issues\b/i, /^search_issues$/i],
26
+ issueTransition: [/^transitionJiraIssue$/i, /\btransition.*jira.*issue\b/i],
27
+ transitionsList: [/^getTransitionsForJiraIssue$/i, /\btransitions?\b.*jira/i],
28
+ commentAdd: [/^addCommentToJiraIssue$/i, /\badd.*comment\b/i],
29
+ viewerRead: [/^atlassianUserInfo$/i, /\b(current|my)?.?user.?info\b/i, /^get_user$/i],
30
+ projectsList: [/^getVisibleJiraProjects$/i, /\bvisible.*projects\b/i, /^list_projects$/i],
31
+ }
32
+
33
+ // The minimum a server must offer for the MCP transport to be worth using at
34
+ // all: reading an issue and creating one. Everything else degrades per-command.
35
+ const REQUIRED = ['issueRead', 'issueCreate']
36
+
37
+ /**
38
+ * Match the advertised tool list against the matchers.
39
+ * @param {string[]} toolNames what the connected server advertises
40
+ * @returns {{ ok: true, tools: Record<string,string> } | { ok: false, error, tools }}
41
+ */
42
+ function discoverJira(toolNames) {
43
+ const names = (toolNames || []).filter((n) => typeof n === 'string')
44
+ const tools = {}
45
+ for (const [op, patterns] of Object.entries(MATCHERS)) {
46
+ for (const pattern of patterns) {
47
+ const hit = names.find((n) => pattern.test(n))
48
+ if (hit) {
49
+ tools[op] = hit
50
+ break
51
+ }
52
+ }
53
+ }
54
+ const missing = REQUIRED.filter((op) => !tools[op])
55
+ if (missing.length) {
56
+ return {
57
+ ok: false,
58
+ tools,
59
+ error:
60
+ `the connected MCP server is missing ${missing.join(' and ')} — ` +
61
+ `advertised ${names.length} tool(s), none matched. Is the Atlassian MCP server connected?`,
62
+ }
63
+ }
64
+ return { ok: true, tools }
65
+ }
66
+
67
+ /**
68
+ * Wrap a discovered tool set + a `call(toolName, args)` function into the op
69
+ * shapes the engine consumes. Ops the server cannot express are absent — the
70
+ * consumer feature-detects, exactly as with the API adapter's extras.
71
+ */
72
+ function makeAdapter({ tools, call, siteUrl, projectKey }) {
73
+ const urlOf = (key) => (siteUrl ? `${String(siteUrl).replace(/\/+$/, '')}/browse/${key}` : null)
74
+ const normalize = (raw) => {
75
+ if (!raw || !raw.key) return null
76
+ const f = raw.fields || {}
77
+ return {
78
+ id: String(raw.id || raw.key),
79
+ identifier: raw.key,
80
+ url: urlOf(raw.key),
81
+ title: f.summary != null ? f.summary : null,
82
+ state: f.status ? { name: f.status.name } : null,
83
+ }
84
+ }
85
+
86
+ const adapter = {}
87
+ if (tools.issueRead) {
88
+ adapter.readIssue = async (keyOrId) => normalize(await call(tools.issueRead, { issueIdOrKey: String(keyOrId) }))
89
+ }
90
+ if (tools.issueCreate) {
91
+ adapter.createIssue = async ({ title, description }) =>
92
+ normalize(
93
+ await call(tools.issueCreate, {
94
+ projectKey,
95
+ issueTypeName: 'Task',
96
+ summary: title,
97
+ description,
98
+ }),
99
+ )
100
+ adapter.createSubIssue = async (parentId, { title, description }) =>
101
+ normalize(
102
+ await call(tools.issueCreate, {
103
+ projectKey,
104
+ issueTypeName: 'Subtask',
105
+ summary: title,
106
+ description,
107
+ parent: { id: String(parentId) },
108
+ }),
109
+ )
110
+ }
111
+ if (tools.issueEdit) {
112
+ adapter.updateIssue = async (keyOrId, updates) =>
113
+ call(tools.issueEdit, { issueIdOrKey: String(keyOrId), fields: updates })
114
+ }
115
+ if (tools.issueSearch) {
116
+ adapter.searchIssues = async ({ query, projectKey: pk }) =>
117
+ call(tools.issueSearch, {
118
+ jql: [pk || projectKey ? `project = "${pk || projectKey}"` : null, query ? `summary ~ "${String(query).replace(/(["\\])/g, '\\$1')}"` : null]
119
+ .filter(Boolean)
120
+ .join(' AND '),
121
+ })
122
+ }
123
+ if (tools.viewerRead) {
124
+ adapter.readViewer = async () => call(tools.viewerRead, {})
125
+ }
126
+ return adapter
127
+ }
128
+
129
+ module.exports = { discoverJira, makeAdapter, MATCHERS, REQUIRED }
@@ -0,0 +1,41 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Provider-neutral CLI plumbing shared by skitterspec ticketing providers.
5
+ *
6
+ * sync-core is the projection/plan/snapshot ENGINE — pure, no CLI, no network.
7
+ * This package is everything a provider\'s CLI builds around that engine and
8
+ * that a second provider would otherwise duplicate: config merging, the
9
+ * namespaced credentials store, identity resolution, the doctor report
10
+ * framework, released-range scanning, transport resolution, rate-limit-aware
11
+ * HTTP retry, spec resolution/listing, terminal secret input, and a flag
12
+ * parser with the unknown-flag refusal. Nothing in here names a tracker.
13
+ */
14
+
15
+ const config = require('./src/config.js')
16
+ const credentials = require('./src/credentials.js')
17
+ const identity = require('./src/identity.js')
18
+ const doctor = require('./src/doctor.js')
19
+ const released = require('./src/released.js')
20
+ const transport = require('./src/transport.js')
21
+ const http = require('./src/http.js')
22
+ const specs = require('./src/specs.js')
23
+ const prompt = require('./src/prompt.js')
24
+ const args = require('./src/args.js')
25
+ const adapter = require('./src/adapter.js')
26
+ const apply = require('./src/apply.js')
27
+
28
+ module.exports = {
29
+ ...config,
30
+ ...credentials,
31
+ ...identity,
32
+ ...doctor,
33
+ ...released,
34
+ ...transport,
35
+ ...http,
36
+ ...specs,
37
+ ...prompt,
38
+ ...args,
39
+ ...adapter,
40
+ ...apply,
41
+ }
@@ -0,0 +1,23 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The transport op-contract check: a provider's API adapter may ADD ops beyond
5
+ * its MCP adapter, but must never be missing one — consumers feature-detect the
6
+ * extras and assume the base set. Run it in the provider's own tests against
7
+ * both adapters, so the two transports cannot drift apart silently.
8
+ */
9
+
10
+ // The function-valued op names an adapter exposes.
11
+ function adapterOps(adapter) {
12
+ return Object.keys(adapter || {})
13
+ .filter((k) => typeof adapter[k] === 'function')
14
+ .sort()
15
+ }
16
+
17
+ // Which of `required` are absent from `adapter` — [] is the passing answer.
18
+ function missingOps(required, adapter) {
19
+ const have = new Set(adapterOps(adapter))
20
+ return [].concat(required).filter((op) => !have.has(op))
21
+ }
22
+
23
+ module.exports = { adapterOps, missingOps }