@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,295 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The Jira Cloud REST v3 adapter — the one place that speaks Atlassian's
5
+ * protocol, and the boundary ADF never crosses.
6
+ *
7
+ * Every op takes and returns the engine's shapes: descriptions go in as
8
+ * markdown and come back as markdown-ish text (`adf.js` converts at this
9
+ * boundary), an issue's workflow state comes back as `{ name }`, an assignee
10
+ * as `{ id, name }` (the id is the Atlassian accountId). Nothing downstream
11
+ * needs to know ADF exists.
12
+ *
13
+ * STATUS MOVES BY TRANSITION, NEVER BY FIELD WRITE. Jira has no "set status":
14
+ * a workflow exposes the transitions legal from the current status, and the
15
+ * adapter applies the one landing on the target (`transitionTo`). No matching
16
+ * transition is a loud, named refusal listing the transitions that DO exist —
17
+ * a positive signal, never a guess.
18
+ * BLIND SPOT, deliberate: a status reachable only through intermediate
19
+ * transitions is reported as unreachable — v1 does not path-find a workflow.
20
+ *
21
+ * Auth is basic (email + API token). 401/403 are phrased for the human holding
22
+ * the token; 429 waits and retries via the kit's shared discipline
23
+ * (`provider-kit/src/http.js`); a 429 that never recovers is a retryable
24
+ * refusal, distinguishable from every other failure.
25
+ */
26
+
27
+ const { withRateLimitRetry, MAX_RETRIES } = require('../provider-kit/src/http.js')
28
+ const { mdToAdf, adfToText } = require('./adf.js')
29
+ const { authHeader } = require('./credentials.js')
30
+
31
+ // A refusal Jira itself expressed — its `errorMessages` / field `errors` —
32
+ // kept apart from transport failures so callers can report cause, not symptom.
33
+ class JiraRefusal extends Error {
34
+ constructor(message, { status = null, retryable = false, errors = null } = {}) {
35
+ super(message)
36
+ this.name = 'JiraRefusal'
37
+ this.status = status
38
+ this.retryable = retryable
39
+ this.errors = errors
40
+ }
41
+ }
42
+
43
+ function refusalFrom(status, body) {
44
+ const messages = []
45
+ if (body && Array.isArray(body.errorMessages)) messages.push(...body.errorMessages)
46
+ if (body && body.errors && typeof body.errors === 'object') {
47
+ for (const [field, msg] of Object.entries(body.errors)) messages.push(`${field}: ${msg}`)
48
+ }
49
+ const joined = messages.length ? messages.join('; ') : `HTTP ${status}`
50
+ return new JiraRefusal(`Jira refused the request: ${joined}`, { status, errors: body && body.errors })
51
+ }
52
+
53
+ /**
54
+ * Wrap credential + site into the typed ops the engine uses. Injected `fetch`
55
+ * and `sleep` make every op testable offline.
56
+ */
57
+ function makeApiAdapter({ siteUrl, email, token, fetch: fetchImpl, sleep, maxRetries = MAX_RETRIES }) {
58
+ const doFetch = fetchImpl || globalThis.fetch
59
+ if (typeof doFetch !== 'function') {
60
+ throw new Error('no fetch available — Node 18+ is required for the API transport')
61
+ }
62
+ const site = String(siteUrl || '').replace(/\/+$/, '')
63
+ if (!site) throw new Error('no Jira site URL configured — set jira.siteUrl in specs/.core/jira.config.json')
64
+ const base = `${site}/rest/api/3`
65
+ const auth = authHeader({ email, token })
66
+
67
+ async function request(method, pathname, { query, body, allow404 = false } = {}) {
68
+ const url = new URL(base + pathname)
69
+ for (const [k, v] of Object.entries(query || {})) if (v != null) url.searchParams.set(k, String(v))
70
+ const { response: res, exhausted } = await withRateLimitRetry(
71
+ async () => {
72
+ try {
73
+ return await doFetch(url.toString(), {
74
+ method,
75
+ headers: {
76
+ Authorization: auth,
77
+ Accept: 'application/json',
78
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
79
+ },
80
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
81
+ })
82
+ } catch (cause) {
83
+ throw new Error(`Jira unreachable: ${cause && cause.message ? cause.message : cause}`)
84
+ }
85
+ },
86
+ { sleep, maxRetries },
87
+ )
88
+ if (res.status === 401 || res.status === 403) {
89
+ throw new Error(
90
+ `Jira rejected the credentials (HTTP ${res.status}) — check the email and API token`,
91
+ )
92
+ }
93
+ if (exhausted) {
94
+ throw new JiraRefusal(
95
+ `Jira rate-limited this request and did not recover after ${maxRetries} retries`,
96
+ { status: 429, retryable: true },
97
+ )
98
+ }
99
+ if (res.status === 404 && allow404) return null
100
+ if (res.status === 204) return {}
101
+ let parsed = null
102
+ try {
103
+ parsed = await res.json()
104
+ } catch {
105
+ parsed = null
106
+ }
107
+ if (!res.ok) throw refusalFrom(res.status, parsed)
108
+ return parsed
109
+ }
110
+
111
+ const ISSUE_FIELDS = 'summary,description,status,assignee,parent,subtasks'
112
+
113
+ const urlOf = (key) => `${site}/browse/${key}`
114
+
115
+ function normalizeIssue(raw) {
116
+ if (!raw || !raw.key) return null
117
+ const f = raw.fields || {}
118
+ return {
119
+ id: String(raw.id),
120
+ identifier: raw.key,
121
+ url: urlOf(raw.key),
122
+ title: f.summary != null ? f.summary : null,
123
+ // Text at the boundary: ADF never escapes the adapter.
124
+ description: f.description != null ? adfToText(f.description) : null,
125
+ state: f.status ? { name: f.status.name } : null,
126
+ assignee: f.assignee ? { id: f.assignee.accountId, name: f.assignee.displayName || null } : null,
127
+ parent: f.parent ? { id: String(f.parent.id), identifier: f.parent.key } : null,
128
+ subtasks: Array.isArray(f.subtasks)
129
+ ? f.subtasks.map((s) => ({
130
+ id: String(s.id),
131
+ identifier: s.key,
132
+ title: s.fields && s.fields.summary != null ? s.fields.summary : null,
133
+ state: s.fields && s.fields.status ? { name: s.fields.status.name } : null,
134
+ }))
135
+ : [],
136
+ }
137
+ }
138
+
139
+ // Build the writable `fields` payload from engine-shaped input. Only keys
140
+ // that are PRESENT are sent — `undefined` means "say nothing", `null` means
141
+ // "clear it" (assignee only; a null description clears to empty).
142
+ function issueFields(input, { forCreate = false, projectKey, issueTypeName } = {}) {
143
+ const fields = {}
144
+ if (forCreate) {
145
+ fields.project = { key: projectKey }
146
+ fields.issuetype = { name: issueTypeName }
147
+ }
148
+ if (input.title !== undefined) fields.summary = input.title
149
+ if (input.description !== undefined) {
150
+ fields.description = input.description == null ? null : mdToAdf(input.description)
151
+ }
152
+ if (input.assigneeId !== undefined) {
153
+ fields.assignee = input.assigneeId == null ? null : { accountId: input.assigneeId }
154
+ }
155
+ return fields
156
+ }
157
+
158
+ return {
159
+ async readIssue(keyOrId) {
160
+ const raw = await request('GET', `/issue/${encodeURIComponent(keyOrId)}`, {
161
+ query: { fields: ISSUE_FIELDS },
162
+ allow404: true,
163
+ })
164
+ return normalizeIssue(raw)
165
+ },
166
+
167
+ async createIssue({ title, description, assigneeId, projectKey, issueTypeName }) {
168
+ const created = await request('POST', '/issue', {
169
+ body: { fields: issueFields({ title, description, assigneeId }, { forCreate: true, projectKey, issueTypeName }) },
170
+ })
171
+ if (!created || !created.key) throw new Error('Jira returned no issue for the create')
172
+ return { id: String(created.id), identifier: created.key, url: urlOf(created.key) }
173
+ },
174
+
175
+ async createSubIssue(parentId, { title, description, projectKey, subtaskTypeName }) {
176
+ const fields = issueFields({ title, description }, { forCreate: true, projectKey, issueTypeName: subtaskTypeName })
177
+ fields.parent = { id: String(parentId) }
178
+ const created = await request('POST', '/issue', { body: { fields } })
179
+ if (!created || !created.key) throw new Error('Jira returned no issue for the subtask create')
180
+ return { id: String(created.id), identifier: created.key, url: urlOf(created.key) }
181
+ },
182
+
183
+ async updateIssue(keyOrId, updates) {
184
+ const fields = issueFields(updates)
185
+ if (!Object.keys(fields).length) return {}
186
+ return request('PUT', `/issue/${encodeURIComponent(keyOrId)}`, { body: { fields } })
187
+ },
188
+
189
+ async listTransitions(keyOrId) {
190
+ const raw = await request('GET', `/issue/${encodeURIComponent(keyOrId)}/transitions`)
191
+ return ((raw && raw.transitions) || []).map((t) => ({
192
+ id: String(t.id),
193
+ name: t.name,
194
+ to: t.to ? { name: t.to.name } : null,
195
+ }))
196
+ },
197
+
198
+ /**
199
+ * Move an issue to the status named `targetStatus`, by whichever available
200
+ * transition lands there. `{ moved: false, already: true }` when the issue
201
+ * is already sitting on it — the idempotent case, not a failure.
202
+ */
203
+ async transitionTo(keyOrId, targetStatus) {
204
+ const want = String(targetStatus || '').toLowerCase().trim()
205
+ const transitions = await this.listTransitions(keyOrId)
206
+ const hit = transitions.find((t) => t.to && String(t.to.name).toLowerCase().trim() === want)
207
+ if (!hit) {
208
+ const current = await this.readIssue(keyOrId)
209
+ const at = current && current.state ? current.state.name : null
210
+ if (at && String(at).toLowerCase().trim() === want) return { moved: false, already: true }
211
+ const names = transitions.map((t) => `"${t.to ? t.to.name : t.name}"`).join(', ') || '(none)'
212
+ throw new JiraRefusal(
213
+ `no transition from ${keyOrId}'s current status${at ? ` "${at}"` : ''} reaches "${targetStatus}" — ` +
214
+ `available: ${names}. The workflow may need an intermediate step; this engine does not path-find.`,
215
+ { status: null },
216
+ )
217
+ }
218
+ await request('POST', `/issue/${encodeURIComponent(keyOrId)}/transitions`, {
219
+ body: { transition: { id: hit.id } },
220
+ })
221
+ return { moved: true, via: hit.name }
222
+ },
223
+
224
+ /**
225
+ * Issues whose summary contains `query`, scoped to a project when given.
226
+ * JQL strings escape embedded quotes; result shape matches `readIssue`'s
227
+ * essentials (the twin-mint guard needs identifier + title).
228
+ */
229
+ async searchIssues({ query, projectKey, limit = 50 }) {
230
+ const quoted = String(query || '').replace(/(["\\])/g, '\\$1')
231
+ const jql = [projectKey ? `project = "${projectKey}"` : null, quoted ? `summary ~ "${quoted}"` : null]
232
+ .filter(Boolean)
233
+ .join(' AND ')
234
+ const raw = await request('GET', '/search/jql', {
235
+ query: { jql, fields: 'summary,status,parent', maxResults: limit },
236
+ })
237
+ return ((raw && raw.issues) || []).map((i) => ({
238
+ id: String(i.id),
239
+ identifier: i.key,
240
+ title: i.fields && i.fields.summary != null ? i.fields.summary : null,
241
+ state: i.fields && i.fields.status ? { name: i.fields.status.name } : null,
242
+ parent: i.fields && i.fields.parent ? { id: String(i.fields.parent.id), identifier: i.fields.parent.key } : null,
243
+ }))
244
+ },
245
+
246
+ /**
247
+ * The status names this project's workflows can express, deduped across
248
+ * issue types — what `states` validation checks the config against.
249
+ */
250
+ async listIssueStates(projectKey) {
251
+ const raw = await request('GET', `/project/${encodeURIComponent(projectKey)}/statuses`)
252
+ const names = new Map()
253
+ for (const type of raw || []) {
254
+ for (const status of type.statuses || []) {
255
+ if (status && status.name && !names.has(status.name)) {
256
+ names.set(status.name, {
257
+ id: String(status.id),
258
+ name: status.name,
259
+ // The status CATEGORY (To Do / In Progress / Done) is what boards
260
+ // and reports key off — a different axis from the name, and worth
261
+ // surfacing wherever the names are listed.
262
+ category: (status.statusCategory && status.statusCategory.name) || null,
263
+ })
264
+ }
265
+ }
266
+ }
267
+ return [...names.values()]
268
+ },
269
+
270
+ async readViewer() {
271
+ const raw = await request('GET', '/myself')
272
+ if (!raw || !raw.accountId) return null
273
+ return { id: raw.accountId, name: raw.displayName || null, email: raw.emailAddress || null }
274
+ },
275
+
276
+ async searchUsers({ query, limit = 20 }) {
277
+ const raw = await request('GET', '/user/search', { query: { query, maxResults: limit } })
278
+ return (raw || []).map((u) => ({ id: u.accountId, name: u.displayName || null, email: u.emailAddress || null }))
279
+ },
280
+
281
+ async listComments(keyOrId) {
282
+ const raw = await request('GET', `/issue/${encodeURIComponent(keyOrId)}/comment`)
283
+ return ((raw && raw.comments) || []).map((c) => ({ id: String(c.id), body: adfToText(c.body) }))
284
+ },
285
+
286
+ async createComment(keyOrId, mdBody) {
287
+ const raw = await request('POST', `/issue/${encodeURIComponent(keyOrId)}/comment`, {
288
+ body: { body: mdToAdf(mdBody) },
289
+ })
290
+ return raw && raw.id ? { id: String(raw.id) } : null
291
+ },
292
+ }
293
+ }
294
+
295
+ module.exports = { makeApiAdapter, JiraRefusal, adfToText, mdToAdf }