@skitterbyte/skitterspec-jira 0.0.0-stage → 1.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 +1089 -0
- package/README.md +42 -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 +554 -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 +61 -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 +692 -0
- package/assets/rules/spec-reports.md +557 -0
- package/assets/skills/no-spec/SKILL.md +246 -0
- package/assets/skills/spec/SKILL.md +714 -0
- package/assets/skills/spec-bug/SKILL.md +583 -0
- package/assets/skills/spec-cancel/SKILL.md +261 -0
- package/assets/skills/spec-complete/SKILL.md +448 -0
- package/assets/skills/spec-diff/SKILL.md +870 -0
- package/assets/skills/spec-hotfix/SKILL.md +605 -0
- package/assets/skills/spec-init/SKILL.md +178 -0
- package/assets/skills/spec-jira-setup/SKILL.md +220 -0
- package/assets/skills/spec-list/SKILL.md +173 -0
- package/assets/skills/spec-next/SKILL.md +676 -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 +112 -0
- package/assets/skills/spec-sync/SKILL.md +218 -0
- package/assets/skills/spec-to-main/SKILL.md +139 -0
- package/bin/skitterspec-jira.js +61 -0
- package/package.json +40 -4
- package/src/cli.js +5901 -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 +753 -0
- package/src/env/dev.js +63 -0
- package/src/env/hooks.js +226 -0
- package/src/env/hotfix.js +385 -0
- package/src/env/hotfixland.js +302 -0
- package/src/env/integrate.js +126 -0
- package/src/env/live.js +504 -0
- package/src/env/mainguard.js +299 -0
- package/src/env/provision.js +466 -0
- package/src/env/proxy.js +192 -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 +2577 -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 +1256 -0
- package/src/vendor/jira/commands.js +44 -0
- package/src/vendor/jira/config.js +296 -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 +197 -0
- package/src/vendor/provider-kit/src/args.js +68 -0
- package/src/vendor/provider-kit/src/config.js +198 -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 +44 -0
- package/src/vendor/provider-kit/src/identity.js +93 -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 +226 -0
- package/src/vendor/provider-kit/src/specs.js +166 -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 +306 -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 +989 -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,85 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `push` — repo → Linear, one-way. The repo is the source of truth; Linear is a
|
|
5
|
+
* generated mirror. We never read remote content: `push` builds the local
|
|
6
|
+
* projection, diffs it against the committed **last-pushed snapshot**, and
|
|
7
|
+
* returns a **create/update plan** the provider skill applies over MCP.
|
|
8
|
+
*
|
|
9
|
+
* The skill: applies the plan → stamps each newly-created id back into the phase
|
|
10
|
+
* frontmatter / task line → calls `recordPush`, which re-reads the (now
|
|
11
|
+
* id-stamped) projection and writes the snapshot. That closes the loop: the next
|
|
12
|
+
* `push` sees an unchanged snapshot and produces an empty plan.
|
|
13
|
+
*
|
|
14
|
+
* Pure aside from reading the snapshot; no adapter, no remote read, no
|
|
15
|
+
* Date.now(). `recordPush` writes the snapshot sidecar.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { normalizeLocal, phasesWithheld, phaseModeFor } = require('./normalize.js')
|
|
19
|
+
const { planChanges, snapshotOf, isEmptyPlan } = require('./compare.js')
|
|
20
|
+
const { readBase, writeBase } = require('./base.js')
|
|
21
|
+
const { detectLegacyMirror } = require('./legacy.js')
|
|
22
|
+
|
|
23
|
+
// Build the one-way projection from a local snapshot: the spec issue's prose +
|
|
24
|
+
// status, and its phase sub-issues. `status` is the local lifecycle bucket; the
|
|
25
|
+
// skill maps it (and each sub-issue's `state`) to the Linear issue-state NAME via
|
|
26
|
+
// config.states at apply time.
|
|
27
|
+
function projectionOf(snapshotDir, config) {
|
|
28
|
+
const local = normalizeLocal(snapshotDir, config)
|
|
29
|
+
const projection = {
|
|
30
|
+
description: local.description ?? null,
|
|
31
|
+
status: local.workflowState ?? null,
|
|
32
|
+
subIssues: Array.isArray(local.subIssues) ? local.subIssues : [],
|
|
33
|
+
// How many phases `mapping.phases: 'deferred'` is holding back. Reporting
|
|
34
|
+
// only — `snapshotOf`/`specIssueHash` read named fields, so this never
|
|
35
|
+
// reaches a hash and cannot make an unchanged spec look edited.
|
|
36
|
+
phasesWithheld: phasesWithheld(snapshotDir, config),
|
|
37
|
+
// The phase mode that resolved for THIS spec's bucket. Reporting only, on
|
|
38
|
+
// the same terms: a spec with no sub-issues has to read as deliberate rather
|
|
39
|
+
// than as phase files that failed to parse, and with `mapping.phases` now a
|
|
40
|
+
// per-bucket map, which mode applied is no longer readable off the config.
|
|
41
|
+
phaseMode: phaseModeFor(local.workflowState, config),
|
|
42
|
+
}
|
|
43
|
+
// ASSIGNEE IS SET ONLY WHEN THE FIELD IS IN PLAY, and the distinction between
|
|
44
|
+
// `undefined` and `null` is load-bearing all the way down: `undefined` means
|
|
45
|
+
// the repo never opted in, so no hash is recorded and no op is ever emitted;
|
|
46
|
+
// `null` means opted in with nobody assigned, which CAN legitimately clear a
|
|
47
|
+
// previously-pushed assignee. Defaulting the key to null here would collapse
|
|
48
|
+
// the two and make an un-opted-in repo start clearing assignees on its next
|
|
49
|
+
// push. `normalizeLocal` omits the key entirely when `sync.fieldOwnership`
|
|
50
|
+
// does not list it.
|
|
51
|
+
if ('assignee' in local) projection.assignee = local.assignee ?? null
|
|
52
|
+
return projection
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function push({ dir, snapshotDir, identifier, config }) {
|
|
56
|
+
const projection = projectionOf(snapshotDir, config)
|
|
57
|
+
const snapshot = readBase(dir, identifier, config)
|
|
58
|
+
const plan = planChanges(projection, snapshot)
|
|
59
|
+
// A spec still linked under the pre-9.0 model reads as unlinked here, so the
|
|
60
|
+
// plan above is all-creates and would abandon a live mirror. Carry the finding
|
|
61
|
+
// ON THE PLAN, not as a warning: `--json` routes warnings to stderr, and the
|
|
62
|
+
// skill that applies this plan is exactly the consumer that would miss them.
|
|
63
|
+
const legacy = detectLegacyMirror({ dir, snapshotDir, identifier, config })
|
|
64
|
+
if (legacy) plan.legacy = legacy
|
|
65
|
+
// Same reasoning as `legacy`: carried ON THE PLAN, not as a stderr warning,
|
|
66
|
+
// because `--json` routes warnings to stderr and the skill applying the plan
|
|
67
|
+
// is the consumer that most needs to know the missing sub-issues are deliberate.
|
|
68
|
+
if (projection.phasesWithheld) plan.phasesDeferred = projection.phasesWithheld
|
|
69
|
+
// Always set, unlike the two above: the skill relaying this should not have to
|
|
70
|
+
// know that an absent field means `subissue`. `isEmptyPlan` and `snapshotOf`
|
|
71
|
+
// both read named fields, so an extra key cannot make a spec look edited.
|
|
72
|
+
plan.phaseMode = projection.phaseMode
|
|
73
|
+
return { ok: true, empty: isEmptyPlan(plan), plan, projection }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Record the last-pushed snapshot from the CURRENT files — call after the skill
|
|
78
|
+
* has applied the plan and stamped new ids. Returns the snapshot path.
|
|
79
|
+
*/
|
|
80
|
+
function recordPush({ dir, snapshotDir, identifier, config }) {
|
|
81
|
+
const projection = projectionOf(snapshotDir, config)
|
|
82
|
+
return writeBase(dir, identifier, config, snapshotOf(projection, config))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { push, recordPush, projectionOf }
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Retarget a mirror after the tracker's team key is renamed.
|
|
5
|
+
*
|
|
6
|
+
* Renaming a Linear team rewrites the key in every issue identifier
|
|
7
|
+
* (`SKI-7` → `SKS-7`). The repo stamps those identifiers in three places — spec
|
|
8
|
+
* frontmatter, the sync-base snapshot filenames, and the `subIssues` keys
|
|
9
|
+
* inside those snapshots — and nothing moves them, so afterwards every stamped
|
|
10
|
+
* spec is stale and `/spec-push` fails with `no issue found for SKI-7`.
|
|
11
|
+
*
|
|
12
|
+
* This module is the PURE half: it plans and applies a prefix move over the
|
|
13
|
+
* repo's machine-read fields. It is provider-neutral by design (decision 8) —
|
|
14
|
+
* "old prefix → new prefix over stamps and snapshots" involves no tracker at
|
|
15
|
+
* all, so it belongs here beside `legacy.js`. Detecting the rename and
|
|
16
|
+
* spot-checking an identifier are the only steps that touch a tracker, and they
|
|
17
|
+
* live in the provider's CLI.
|
|
18
|
+
*
|
|
19
|
+
* **Frontmatter only — never prose.** A naive repo-wide `SKI-` → `SKS-`
|
|
20
|
+
* substitution passes a casual eyeball and quietly rewrites the historical
|
|
21
|
+
* record ("Probe SKI-28 falsified the reported hypothesis").
|
|
22
|
+
* `.claude/rules/spec-planning.md` says never delete historical notes, so prose
|
|
23
|
+
* mentions, doc placeholders and test fixtures are out of scope entirely.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const fs = require('node:fs')
|
|
27
|
+
const path = require('node:path')
|
|
28
|
+
const { execFileSync } = require('node:child_process')
|
|
29
|
+
|
|
30
|
+
const { parseFrontmatter, LIFECYCLE_BUCKETS: BUCKETS } = require('./normalize.js')
|
|
31
|
+
const { fieldsOf } = require('./fields.js')
|
|
32
|
+
|
|
33
|
+
// An issue identifier: team key, dash, number. Matched CASE-INSENSITIVELY
|
|
34
|
+
// because the stamped url field carries the identifier lowercased in its path
|
|
35
|
+
// (`…/issue/reu-188/retire-…`). Matching only uppercase left 29 of 33 real URLs
|
|
36
|
+
// in a live repo pointing at the old key — the same ones the hand-repair
|
|
37
|
+
// missed. A rewrite only happens when the key matches `oldKey`, so an unrelated
|
|
38
|
+
// token like `utf-8` is never in scope.
|
|
39
|
+
const IDENTIFIER_RE = /\b([A-Za-z][A-Za-z0-9]*)-(\d+)\b/g
|
|
40
|
+
|
|
41
|
+
// The stamped identifier fields, from the provider's vocabulary.
|
|
42
|
+
const stampFields = (config) => {
|
|
43
|
+
const f = fieldsOf(config)
|
|
44
|
+
return [f.identifier, f.issueId]
|
|
45
|
+
}
|
|
46
|
+
const SNAPSHOT_SUFFIX = '.base.json'
|
|
47
|
+
|
|
48
|
+
// Every `.md` under each spec folder — overview and phase files alike, plus the
|
|
49
|
+
// legacy bare `<name>.md` shape.
|
|
50
|
+
function specMarkdownFiles(dir) {
|
|
51
|
+
const files = []
|
|
52
|
+
for (const bucket of BUCKETS) {
|
|
53
|
+
const root = path.join(dir, 'specs', bucket)
|
|
54
|
+
let entries
|
|
55
|
+
try {
|
|
56
|
+
entries = fs.readdirSync(root, { withFileTypes: true })
|
|
57
|
+
} catch {
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
const p = path.join(root, entry.name)
|
|
62
|
+
if (entry.isDirectory()) {
|
|
63
|
+
for (const f of fs.readdirSync(p)) if (f.endsWith('.md')) files.push(path.join(p, f))
|
|
64
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
65
|
+
files.push(p)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return files.sort()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const snapshotDirOf = (dir, config) => path.resolve(dir, config.sync.baseDir)
|
|
73
|
+
|
|
74
|
+
function snapshotFiles(dir, config) {
|
|
75
|
+
try {
|
|
76
|
+
return fs.readdirSync(snapshotDirOf(dir, config)).filter((f) => f.endsWith(SNAPSHOT_SUFFIX)).sort()
|
|
77
|
+
} catch {
|
|
78
|
+
return []
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Rewrite `<oldKey>-<n>` → `<newKey>-<n>` in `text`, leaving every other
|
|
83
|
+
// identifier alone. The number is preserved: a team rename does not renumber.
|
|
84
|
+
function movePrefix(text, oldKey, newKey) {
|
|
85
|
+
const want = String(oldKey).toLowerCase()
|
|
86
|
+
return String(text).replace(IDENTIFIER_RE, (whole, key, n) => {
|
|
87
|
+
if (key.toLowerCase() !== want) return whole
|
|
88
|
+
// Preserve the case it was written in: a URL path segment stays lowercase,
|
|
89
|
+
// a frontmatter stamp stays uppercase.
|
|
90
|
+
const next = key === key.toLowerCase() ? newKey.toLowerCase() : newKey
|
|
91
|
+
return `${next}-${n}`
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The recorded key this repo believes it is stamped with.
|
|
97
|
+
*
|
|
98
|
+
* `config.provider.teamKey` is authoritative when set — but it defaults to `""`
|
|
99
|
+
* and is written by `init-config` and never read, which is exactly why a stale
|
|
100
|
+
* key never failed loudly. So fall back to the prefix actually observed in the
|
|
101
|
+
* stamps. Disagreeing stamps are reported, never guessed at and never thrown:
|
|
102
|
+
* the caller phrases its own refusal.
|
|
103
|
+
*
|
|
104
|
+
* @returns {{key: string|null, source: 'config'|'stamps'|null, keys?: string[], reason?: string}}
|
|
105
|
+
*/
|
|
106
|
+
function deriveRecordedKey(dir, config) {
|
|
107
|
+
const configured = (config.provider && config.provider.teamKey) || ''
|
|
108
|
+
if (configured) return { key: configured, source: 'config' }
|
|
109
|
+
|
|
110
|
+
const keys = new Set()
|
|
111
|
+
for (const file of specMarkdownFiles(dir)) {
|
|
112
|
+
let raw
|
|
113
|
+
try {
|
|
114
|
+
raw = fs.readFileSync(file, 'utf-8')
|
|
115
|
+
} catch {
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
const { data } = parseFrontmatter(raw)
|
|
119
|
+
for (const field of stampFields(config)) {
|
|
120
|
+
if (!data[field]) continue
|
|
121
|
+
const m = /^([A-Z][A-Z0-9]*)-\d+$/.exec(String(data[field]).trim())
|
|
122
|
+
if (m) keys.add(m[1])
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
for (const name of snapshotFiles(dir, config)) {
|
|
126
|
+
const m = /^([A-Z][A-Z0-9]*)-\d+$/.exec(name.slice(0, -SNAPSHOT_SUFFIX.length))
|
|
127
|
+
if (m) keys.add(m[1])
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// BLIND SPOT: the scan sees `.md` frontmatter under the four lifecycle buckets
|
|
131
|
+
// and the snapshot filenames — nothing else. A stamp anywhere else (a spec
|
|
132
|
+
// parked outside a bucket, a non-markdown file) is invisible, so `no stamped
|
|
133
|
+
// identifiers` means "none where we looked". That is why it refuses instead of
|
|
134
|
+
// concluding the repo is unstamped: the caller stops, and a human looks.
|
|
135
|
+
const found = [...keys].sort()
|
|
136
|
+
if (found.length === 1) return { key: found[0], source: 'stamps' }
|
|
137
|
+
if (!found.length) return { key: null, source: null, keys: [], reason: 'no stamped identifiers found under specs/' }
|
|
138
|
+
return {
|
|
139
|
+
key: null,
|
|
140
|
+
source: null,
|
|
141
|
+
keys: found,
|
|
142
|
+
reason: `stamps disagree — found ${found.join(', ')}; set the provider config's team key to the recorded one`,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Plan the prefix move. Pure: reads the repo, writes nothing.
|
|
148
|
+
*
|
|
149
|
+
* @returns {{stamps: Array<{file, from, to}>, snapshots: Array<{file, from, to, keys}>, configKey: {from,to}|null}}
|
|
150
|
+
* `stamps[].from`/`to` are whole file contents; the caller writes them as-is.
|
|
151
|
+
*/
|
|
152
|
+
function planRetarget({ dir, oldKey, newKey, config }) {
|
|
153
|
+
const stamps = []
|
|
154
|
+
for (const file of specMarkdownFiles(dir)) {
|
|
155
|
+
let raw
|
|
156
|
+
try {
|
|
157
|
+
raw = fs.readFileSync(file, 'utf-8')
|
|
158
|
+
} catch {
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
// Scoped to the leading `---` block. Everything after it is prose and is
|
|
162
|
+
// returned byte-identical.
|
|
163
|
+
const m = /^(---\n[\s\S]*?\n---)([\s\S]*)$/.exec(raw)
|
|
164
|
+
if (!m) continue
|
|
165
|
+
const head = movePrefix(m[1], oldKey, newKey)
|
|
166
|
+
if (head === m[1]) continue
|
|
167
|
+
stamps.push({ file: path.relative(dir, file), from: raw, to: head + m[2] })
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const snapshots = []
|
|
171
|
+
const prefix = `${oldKey}-`
|
|
172
|
+
for (const name of snapshotFiles(dir, config)) {
|
|
173
|
+
const ident = name.slice(0, -SNAPSHOT_SUFFIX.length)
|
|
174
|
+
let body
|
|
175
|
+
try {
|
|
176
|
+
body = JSON.parse(fs.readFileSync(path.join(snapshotDirOf(dir, config), name), 'utf-8'))
|
|
177
|
+
} catch {
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
// The `subIssues` map is keyed BY IDENTIFIER, so a rename strands every key
|
|
181
|
+
// inside the file as well as the filename. The hashes are content-derived
|
|
182
|
+
// and stay valid — only the keys move.
|
|
183
|
+
const keys = {}
|
|
184
|
+
let rekeyed = false
|
|
185
|
+
for (const [k, hash] of Object.entries(body.subIssues || {})) {
|
|
186
|
+
const next = k.startsWith(prefix) ? `${newKey}-${k.slice(prefix.length)}` : k
|
|
187
|
+
if (next !== k) rekeyed = true
|
|
188
|
+
keys[next] = hash
|
|
189
|
+
}
|
|
190
|
+
const renamed = ident.startsWith(prefix) ? `${newKey}-${ident.slice(prefix.length)}${SNAPSHOT_SUFFIX}` : name
|
|
191
|
+
if (renamed === name && !rekeyed) continue
|
|
192
|
+
snapshots.push({ file: name, from: name, to: renamed, keys, body })
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const recorded = (config.provider && config.provider.teamKey) || ''
|
|
196
|
+
const configKey = recorded && recorded !== newKey ? { from: recorded, to: newKey } : null
|
|
197
|
+
|
|
198
|
+
return { stamps, snapshots, configKey }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// True when a plan would change nothing.
|
|
202
|
+
const isEmptyRetarget = (plan) => !plan.stamps.length && !plan.snapshots.length && !plan.configKey
|
|
203
|
+
|
|
204
|
+
// `git status --porcelain` over `dir`: [] clean, the lines when dirty, null when
|
|
205
|
+
// this is not a git repo.
|
|
206
|
+
function dirtyPaths(dir) {
|
|
207
|
+
let out
|
|
208
|
+
try {
|
|
209
|
+
out = execFileSync('git', ['-C', dir, 'status', '--porcelain'], { stdio: ['ignore', 'pipe', 'ignore'] })
|
|
210
|
+
.toString()
|
|
211
|
+
.trim()
|
|
212
|
+
} catch {
|
|
213
|
+
return null
|
|
214
|
+
}
|
|
215
|
+
return out ? out.split('\n') : []
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Move a file, preferring `git mv` so history follows it. Falls back to a plain
|
|
219
|
+
// rename when the file is untracked (git mv refuses those) or git is absent.
|
|
220
|
+
function moveFile(dir, from, to) {
|
|
221
|
+
try {
|
|
222
|
+
execFileSync('git', ['-C', dir, 'mv', from, to], { stdio: ['ignore', 'ignore', 'ignore'] })
|
|
223
|
+
return 'git mv'
|
|
224
|
+
} catch {
|
|
225
|
+
fs.renameSync(path.join(dir, from), path.join(dir, to))
|
|
226
|
+
return 'rename'
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Apply a plan. Everything moves together — stamps, snapshot names, the keys
|
|
232
|
+
* inside them, and the config key — because a half-retargeted repo is harder to
|
|
233
|
+
* reason about than an un-retargeted one.
|
|
234
|
+
*/
|
|
235
|
+
function applyRetarget(plan, { dir, config }) {
|
|
236
|
+
const changed = { files: [], snapshots: [], configKey: false }
|
|
237
|
+
|
|
238
|
+
for (const s of plan.stamps) {
|
|
239
|
+
fs.writeFileSync(path.join(dir, s.file), s.to, 'utf-8')
|
|
240
|
+
changed.files.push(s.file)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const baseRel = config.sync.baseDir
|
|
244
|
+
for (const snap of plan.snapshots) {
|
|
245
|
+
// Re-key BEFORE the rename, so the path being written is the one the plan
|
|
246
|
+
// recorded.
|
|
247
|
+
const src = path.join(dir, baseRel, snap.from)
|
|
248
|
+
fs.writeFileSync(src, JSON.stringify({ ...snap.body, subIssues: snap.keys }, null, 2) + '\n', 'utf-8')
|
|
249
|
+
if (snap.to !== snap.from) {
|
|
250
|
+
const how = moveFile(dir, path.join(baseRel, snap.from), path.join(baseRel, snap.to))
|
|
251
|
+
changed.snapshots.push({ ...snap, how })
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (plan.configKey && config.provider && config.provider.configFile) {
|
|
256
|
+
// Textual, not parse-and-restringify: the config is hand-edited and carries
|
|
257
|
+
// ordering (and possibly comments) a JSON round-trip would discard. The
|
|
258
|
+
// file and the JSON key both come from the provider (`config.provider`).
|
|
259
|
+
const file = path.join(dir, config.provider.configFile)
|
|
260
|
+
const keyField = config.provider.teamKeyJsonField || 'teamKey'
|
|
261
|
+
const raw = fs.readFileSync(file, 'utf-8')
|
|
262
|
+
const re = new RegExp('("' + keyField + '"\\s*:\\s*")([^"]*)(")')
|
|
263
|
+
fs.writeFileSync(file, raw.replace(re, `$1${plan.configKey.to}$3`), 'utf-8')
|
|
264
|
+
changed.configKey = true
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return changed
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
module.exports = {
|
|
271
|
+
planRetarget,
|
|
272
|
+
applyRetarget,
|
|
273
|
+
deriveRecordedKey,
|
|
274
|
+
isEmptyRetarget,
|
|
275
|
+
dirtyPaths,
|
|
276
|
+
movePrefix,
|
|
277
|
+
specMarkdownFiles,
|
|
278
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Task bullets in a phase file are hand-wrapped prose, not single lines:
|
|
4
|
+
//
|
|
5
|
+
// - [x] Add the retry column to the outbox table, modelled on the existing
|
|
6
|
+
// queue: status, attempts, `nextAttemptAt`, …
|
|
7
|
+
//
|
|
8
|
+
// A Linear issue title is single-line, so the two representations differ by
|
|
9
|
+
// wrapping alone. This module is the one place that converts between them:
|
|
10
|
+
// `findTaskBlocks` reads wrapped bullets into logical tasks, `renderTaskBlock`
|
|
11
|
+
// writes a logical task back out re-wrapped in the file's own style.
|
|
12
|
+
//
|
|
13
|
+
// Everything here is line-index based so callers can splice whole blocks.
|
|
14
|
+
|
|
15
|
+
const DEFAULT_WIDTH = 80
|
|
16
|
+
|
|
17
|
+
// Start of a task bullet. The continuation lines that follow are any indented,
|
|
18
|
+
// non-empty lines that are not themselves a bullet or heading.
|
|
19
|
+
//
|
|
20
|
+
// The mark is ANY single character, not just ` `/`x`. Projects use `[~]` for
|
|
21
|
+
// in-progress, `[>]` for deferred, `[-]` for dropped, and a parser that only
|
|
22
|
+
// knew ` xX` matched none of them — so the whole bullet was claimed by no block
|
|
23
|
+
// and vanished from the mirror (bug-phase-content-dropped). What the mark MEANS
|
|
24
|
+
// is nobody's business here; it is carried through verbatim and re-emitted as
|
|
25
|
+
// written. Only `x`/`X` counts as done (see `parseTaskLine`).
|
|
26
|
+
const TASK_START_RE = /^([ \t]*)-\s*\[([^\]])\]\s*(.*)$/
|
|
27
|
+
const CONTINUATION_RE = /^[ \t]+\S/
|
|
28
|
+
const BLOCK_BREAK_RE = /^[ \t]*(?:[-*+]\s|\d+\.\s|#{1,6}\s|>|\||```)/
|
|
29
|
+
// A list-marker line (unordered or ordered). Distinguished from other block
|
|
30
|
+
// breaks because a wrapped continuation can legitimately begin with one.
|
|
31
|
+
const LIST_MARKER_RE = /^[ \t]*(?:[-*+]|\d+\.)\s/
|
|
32
|
+
// A checkbox bullet — unambiguously a task, so it always starts its own block,
|
|
33
|
+
// at any indent. (A bare list marker is ambiguous; a checkbox never is.) Any
|
|
34
|
+
// mark, matching TASK_START_RE.
|
|
35
|
+
const CHECKBOX_RE = /^[ \t]*[-*+]\s*\[[^\]]\]/
|
|
36
|
+
// A bare list bullet — no checkbox — captured with its marker so a sub-bullet
|
|
37
|
+
// is re-rendered as the `-`/`*`/`1.` its author wrote.
|
|
38
|
+
const BULLET_RE = /^([ \t]*)([-*+]|\d+\.)\s+(.*)$/
|
|
39
|
+
|
|
40
|
+
function indentWidth(line) {
|
|
41
|
+
return line.length - line.trimStart().length
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Collect the wrapped continuation lines following the bullet opening at
|
|
45
|
+
// `start`, seeded with the opener's own text. Shared by task bullets and plain
|
|
46
|
+
// sub-bullets — both wrap the same way, and both stop at the first line that
|
|
47
|
+
// belongs to something else.
|
|
48
|
+
function collectContinuation(lines, start, hang, inFence, parts) {
|
|
49
|
+
let j = start + 1
|
|
50
|
+
for (; j < lines.length; j++) {
|
|
51
|
+
const l = lines[j]
|
|
52
|
+
if (!l.trim()) break
|
|
53
|
+
if (inFence[j]) break // a fence opening ends the bullet, never continues it
|
|
54
|
+
if (!CONTINUATION_RE.test(l)) break
|
|
55
|
+
if (BLOCK_BREAK_RE.test(l)) {
|
|
56
|
+
// Indent alone can't tell a nested child from a wrapped continuation —
|
|
57
|
+
// both sit at the hanging indent. The *marker* is the reliable signal:
|
|
58
|
+
// - a checkbox (- [ ] / - [x]) is unambiguously a task → always break;
|
|
59
|
+
// - a bare marker (-/*/+/N.) is wrapped continuation prose ONLY when it
|
|
60
|
+
// sits exactly at the hang; shallower or deeper it's a real sub/
|
|
61
|
+
// sibling list → break, and the caller's scan claims it as a block of
|
|
62
|
+
// its own. (Keeping the at-hang continuation preserves the task's
|
|
63
|
+
// stamped id, so the next push updates instead of creating a
|
|
64
|
+
// duplicate issue.)
|
|
65
|
+
// - headings, quotes, tables and fences always break.
|
|
66
|
+
if (CHECKBOX_RE.test(l)) break
|
|
67
|
+
if (!LIST_MARKER_RE.test(l) || indentWidth(l) !== hang) break
|
|
68
|
+
}
|
|
69
|
+
parts.push(l.trim())
|
|
70
|
+
}
|
|
71
|
+
return { end: j, parts }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Mark every line that lies inside a fenced code block — the opening fence, its
|
|
75
|
+
// content, and the closing fence all count as `true`. Line scanners that hunt
|
|
76
|
+
// for markdown structure (task bullets, section headings) use this to ignore an
|
|
77
|
+
// EXAMPLE of that structure shown inside a fence: a spec that documents a
|
|
78
|
+
// checklist format in a ``` block must not have those example bullets harvested
|
|
79
|
+
// as real tasks (and pushed as real tracker issues). Supports ``` and ~~~ fences
|
|
80
|
+
// of any length ≥ 3; a closing fence is a bare marker (no info string) of the
|
|
81
|
+
// same character and at least the opening length.
|
|
82
|
+
function fenceMask(lines) {
|
|
83
|
+
const mask = new Array(lines.length).fill(false)
|
|
84
|
+
let open = null // the opening marker string (``` or ~~~ …), or null
|
|
85
|
+
for (let i = 0; i < lines.length; i++) {
|
|
86
|
+
const trimmed = lines[i].trim()
|
|
87
|
+
if (open) {
|
|
88
|
+
mask[i] = true
|
|
89
|
+
if (/^(`{3,}|~{3,})$/.test(trimmed) && trimmed[0] === open[0] && trimmed.length >= open.length) {
|
|
90
|
+
open = null
|
|
91
|
+
}
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
const m = /^[ \t]*(`{3,}|~{3,})/.exec(lines[i])
|
|
95
|
+
if (m) {
|
|
96
|
+
open = m[1]
|
|
97
|
+
mask[i] = true
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return mask
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Collapse a wrapped bullet's lines into the single logical line the rest of the
|
|
104
|
+
// sync engine (and Linear) works in.
|
|
105
|
+
function collapse(text) {
|
|
106
|
+
return String(text).replace(/\s+/g, ' ').trim()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Like `collapse`, but a word wrapped at a hyphen (`state-entry-with-`⏎
|
|
110
|
+
// `assignment`) rejoins TIGHT — one compound — instead of gaining a space. Every
|
|
111
|
+
// other break collapses to a single space. Use this wherever hand-wrapped prose
|
|
112
|
+
// is flattened to one logical line, so a hard wrap at a hyphen isn't data loss.
|
|
113
|
+
function collapseHyphenAware(text) {
|
|
114
|
+
return String(text)
|
|
115
|
+
.replace(/(\w-)[ \t]*\n[ \t]*(?=\w)/g, '$1')
|
|
116
|
+
.replace(/\s+/g, ' ')
|
|
117
|
+
.trim()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Mark every character of `body` that lies inside an inline emphasis or link
|
|
121
|
+
// span — `**bold**`, `*italic*`, `[text](url)` — so wrapping can avoid breaking
|
|
122
|
+
// one across a line (Linear mangles a straddling `**`/`*`/link on save). Code
|
|
123
|
+
// spans are exempt (Linear only rejoins them, harmlessly) but their contents are
|
|
124
|
+
// neutralised first so a `*` inside code can't spoof an italic marker.
|
|
125
|
+
function spanMask(body) {
|
|
126
|
+
const mask = new Array(body.length).fill(false)
|
|
127
|
+
// Neutralise code-span contents to same-length filler (indices stay aligned).
|
|
128
|
+
const chars = body.split('')
|
|
129
|
+
let m
|
|
130
|
+
const codeRe = /`[^`]*`/g
|
|
131
|
+
while ((m = codeRe.exec(body)) !== null) {
|
|
132
|
+
for (let i = m.index; i < m.index + m[0].length; i++) chars[i] = 'x'
|
|
133
|
+
}
|
|
134
|
+
let masked = chars.join('')
|
|
135
|
+
const cover = (re) => {
|
|
136
|
+
re.lastIndex = 0
|
|
137
|
+
let mm
|
|
138
|
+
while ((mm = re.exec(masked)) !== null) {
|
|
139
|
+
for (let i = mm.index; i < mm.index + mm[0].length; i++) mask[i] = true
|
|
140
|
+
if (mm[0].length === 0) re.lastIndex++
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
cover(/\*\*.+?\*\*/g) // bold
|
|
144
|
+
cover(/\[[^\]]*\]\([^)]*\)/g) // link
|
|
145
|
+
// Neutralise the bold/link spans already found, so their asterisks aren't
|
|
146
|
+
// reused when scanning for single-`*` italics.
|
|
147
|
+
const m2 = masked.split('')
|
|
148
|
+
for (let i = 0; i < mask.length; i++) if (mask[i]) m2[i] = 'x'
|
|
149
|
+
masked = m2.join('')
|
|
150
|
+
cover(/\*[^*]+?\*/g) // italic
|
|
151
|
+
return mask
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Find every task bullet in `lines` as a logical block, plus the non-checkbox
|
|
156
|
+
* bullets that live inside a task's list subtree.
|
|
157
|
+
* @returns {Array<{start:number, end:number, indent:string, marker:string,
|
|
158
|
+
* checkbox:boolean, mark:string|null, text:string}>}
|
|
159
|
+
* `end` is exclusive. `text` is the collapsed single-line form, id included.
|
|
160
|
+
* `checkbox` is false for a plain sub-bullet; its `mark` is then null and its
|
|
161
|
+
* `marker` is the bullet it was written with (`-`, `*`, `1.` …).
|
|
162
|
+
*/
|
|
163
|
+
function findTaskBlocks(lines) {
|
|
164
|
+
const blocks = []
|
|
165
|
+
const inFence = fenceMask(lines)
|
|
166
|
+
// The marker indents of the task bullets whose list subtree is still open,
|
|
167
|
+
// outermost first. Without this the scan had no model of nesting at all: a
|
|
168
|
+
// bare bullet that dedented out of a nested checkbox belonged to nothing and
|
|
169
|
+
// was silently dropped, taking its wrapped continuations with it.
|
|
170
|
+
const open = []
|
|
171
|
+
|
|
172
|
+
for (let i = 0; i < lines.length; i++) {
|
|
173
|
+
const line = lines[i]
|
|
174
|
+
// A blank line alone doesn't close a subtree — a loose list is still a list.
|
|
175
|
+
// What closes it is a later line at or shallower than the task's own indent.
|
|
176
|
+
if (!line.trim()) continue
|
|
177
|
+
const indent = indentWidth(line)
|
|
178
|
+
while (open.length && indent <= open[open.length - 1]) open.pop()
|
|
179
|
+
if (inFence[i]) continue // an example bullet inside a ``` block is not a task
|
|
180
|
+
|
|
181
|
+
const t = TASK_START_RE.exec(line)
|
|
182
|
+
if (t) {
|
|
183
|
+
// The hanging indent: where the bullet's text starts (marker width). A
|
|
184
|
+
// wrapped continuation aligns exactly AT this column.
|
|
185
|
+
const { end, parts } = collectContinuation(lines, i, line.length - t[3].length, inFence, [t[3]])
|
|
186
|
+
blocks.push({
|
|
187
|
+
start: i,
|
|
188
|
+
end,
|
|
189
|
+
indent: t[1],
|
|
190
|
+
marker: '-',
|
|
191
|
+
checkbox: true,
|
|
192
|
+
// Case-folded for `x` (the one mark whose meaning we act on), otherwise
|
|
193
|
+
// verbatim — a project's `~`/`>`/`-` must round-trip as written.
|
|
194
|
+
mark: t[2].toLowerCase() === 'x' ? 'x' : t[2],
|
|
195
|
+
text: collapseHyphenAware(parts.join('\n')),
|
|
196
|
+
})
|
|
197
|
+
open.push(indent)
|
|
198
|
+
i = end - 1
|
|
199
|
+
continue
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// A bare bullet is claimed ONLY inside an open task's subtree — there it is
|
|
203
|
+
// part of the task and must be re-rendered with it. Outside one it is
|
|
204
|
+
// ordinary prose, which the projection now passes through verbatim, so
|
|
205
|
+
// claiming it here would only re-wrap a list nobody asked us to touch.
|
|
206
|
+
if (!open.length) continue
|
|
207
|
+
const b = BULLET_RE.exec(line)
|
|
208
|
+
if (!b) continue
|
|
209
|
+
const { end, parts } = collectContinuation(lines, i, line.length - b[3].length, inFence, [b[3]])
|
|
210
|
+
blocks.push({
|
|
211
|
+
start: i,
|
|
212
|
+
end,
|
|
213
|
+
indent: b[1],
|
|
214
|
+
marker: b[2],
|
|
215
|
+
checkbox: false,
|
|
216
|
+
mark: null,
|
|
217
|
+
text: collapseHyphenAware(parts.join('\n')),
|
|
218
|
+
})
|
|
219
|
+
i = end - 1
|
|
220
|
+
}
|
|
221
|
+
return blocks
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Render a logical task back into wrapped file lines, matching the surrounding
|
|
226
|
+
* style: `- [x] ` opener, continuations aligned under the text.
|
|
227
|
+
* @returns {string[]}
|
|
228
|
+
*/
|
|
229
|
+
// Wrap `body` (a single logical line) into file lines, emphasis-aware: a break is
|
|
230
|
+
// only taken at a word gap that lies OUTSIDE every `**`/`*`/link span, so an
|
|
231
|
+
// emphasis run never straddles a line (Linear mangles one that does). The first
|
|
232
|
+
// line is prefixed with `firstPrefix`, continuations with `hang`. An over-width
|
|
233
|
+
// single span overflows rather than being split.
|
|
234
|
+
function wrapEmphasisAware(body, { firstPrefix = '', hang = '', width = DEFAULT_WIDTH } = {}) {
|
|
235
|
+
const mask = spanMask(body)
|
|
236
|
+
const words = body.split(' ')
|
|
237
|
+
// The index in `body` of the space that precedes each word.
|
|
238
|
+
const spaceBefore = []
|
|
239
|
+
let pos = 0
|
|
240
|
+
for (let k = 0; k < words.length; k++) {
|
|
241
|
+
if (k > 0) {
|
|
242
|
+
spaceBefore[k] = pos
|
|
243
|
+
pos += 1
|
|
244
|
+
}
|
|
245
|
+
pos += words[k].length
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const out = []
|
|
249
|
+
let line = firstPrefix
|
|
250
|
+
let first = true
|
|
251
|
+
for (let k = 0; k < words.length; k++) {
|
|
252
|
+
const word = words[k]
|
|
253
|
+
// Break only at a safe gap; a gap inside a span overflows instead of
|
|
254
|
+
// splitting it (so an over-width single span stays whole on one line).
|
|
255
|
+
const canBreak = k > 0 && !mask[spaceBefore[k]]
|
|
256
|
+
if (!first && canBreak && line.length + 1 + word.length > width) {
|
|
257
|
+
out.push(line)
|
|
258
|
+
line = hang + word
|
|
259
|
+
} else {
|
|
260
|
+
line += (first ? '' : ' ') + word
|
|
261
|
+
first = false
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
out.push(line)
|
|
265
|
+
return out
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function renderTaskBlock({ indent = '', done, text, id }, width = DEFAULT_WIDTH) {
|
|
269
|
+
const opener = `${indent}- [${done ? 'x' : ' '}] `
|
|
270
|
+
const hang = ' '.repeat(opener.length)
|
|
271
|
+
const body = collapse(text) + (id ? ` (${id})` : '')
|
|
272
|
+
return wrapEmphasisAware(body, { firstPrefix: opener, hang, width })
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Infer the wrap width a file already uses, so a rewrite doesn't reflow it to a
|
|
276
|
+
// different column. Only *prose* lines count — a single wide table row or a long
|
|
277
|
+
// line of fenced code would otherwise pull the whole file's prose to a wider
|
|
278
|
+
// column than the author wrapped at. Falls back to the default when there's
|
|
279
|
+
// nothing prose-like to learn from.
|
|
280
|
+
function inferWidth(lines, fallback = DEFAULT_WIDTH) {
|
|
281
|
+
let inFence = false
|
|
282
|
+
const widths = []
|
|
283
|
+
for (const l of lines) {
|
|
284
|
+
if (/^[ \t]*```/.test(l)) {
|
|
285
|
+
inFence = !inFence
|
|
286
|
+
continue
|
|
287
|
+
}
|
|
288
|
+
if (inFence) continue
|
|
289
|
+
if (!l.trim()) continue
|
|
290
|
+
if (l.includes('|')) continue // table row
|
|
291
|
+
widths.push(l.length)
|
|
292
|
+
}
|
|
293
|
+
if (!widths.length) return fallback
|
|
294
|
+
const max = Math.max(...widths)
|
|
295
|
+
return max > 40 && max <= 120 ? Math.max(max, 60) : fallback
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
module.exports = {
|
|
299
|
+
fenceMask,
|
|
300
|
+
findTaskBlocks,
|
|
301
|
+
renderTaskBlock,
|
|
302
|
+
wrapEmphasisAware,
|
|
303
|
+
spanMask,
|
|
304
|
+
collapse,
|
|
305
|
+
collapseHyphenAware,
|
|
306
|
+
inferWidth,
|
|
307
|
+
DEFAULT_WIDTH,
|
|
308
|
+
}
|