@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,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 }
|