@skitterbyte/skitterspec-linear 10.6.0 → 10.8.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/assets/claude-md-section.md +19 -9
- package/assets/commands/spec-connect.md +13 -0
- package/assets/commands/spec-live.md +14 -0
- package/assets/core/ci-stages.md +110 -0
- package/assets/core/env.config.md +18 -0
- package/assets/core/linear.config.json.example +4 -0
- package/assets/core/linear.config.md +88 -0
- package/assets/rules/commit-trailers.md +37 -5
- package/assets/rules/spec-planning.md +19 -4
- package/assets/skills/spec/SKILL.md +20 -0
- package/assets/skills/spec-bug/SKILL.md +48 -2
- package/assets/skills/spec-cancel/SKILL.md +9 -0
- package/assets/skills/spec-complete/SKILL.md +12 -1
- package/assets/skills/spec-go/SKILL.md +10 -8
- package/assets/skills/spec-hotfix/SKILL.md +49 -3
- package/assets/skills/spec-linear-setup/SKILL.md +38 -2
- package/assets/skills/spec-status/SKILL.md +1 -0
- package/assets/skills/spec-sync/SKILL.md +9 -0
- package/assets/skills/spec-to-main/SKILL.md +2 -1
- package/bin/skitterspec-linear.js +10 -0
- package/package.json +1 -1
- package/src/cli.js +211 -44
- package/src/env/config.js +19 -0
- package/src/env/teardown.js +62 -4
- package/src/init.js +120 -2
- package/src/vendor/linear/cli-sync.js +454 -34
- package/src/vendor/linear/config.js +158 -1
- package/src/vendor/linear/doctor.js +67 -1
- package/src/vendor/linear/released.js +149 -5
- package/src/vendor/sync-core/index.js +4 -1
- package/src/vendor/sync-core/src/compare.js +59 -3
- package/src/vendor/sync-core/src/normalize.js +65 -2
- package/assets/skills/spec-connect/SKILL.md +0 -59
- package/assets/skills/spec-live/SKILL.md +0 -73
|
@@ -25,7 +25,7 @@ const path = require('node:path')
|
|
|
25
25
|
|
|
26
26
|
const { BUCKETS, findSpecFolder, branchFor, splitPrefix, currentBranch } = require('../../env/resolve.js')
|
|
27
27
|
const { loadEnvConfig } = require('../../env/config.js')
|
|
28
|
-
const { ticketsInRange } = require('./released.js')
|
|
28
|
+
const { ticketsInRange, partitionStageMoves, stageOrderWarning } = require('./released.js')
|
|
29
29
|
const { execFileSync } = require('node:child_process')
|
|
30
30
|
const {
|
|
31
31
|
normalizeLocal,
|
|
@@ -38,6 +38,8 @@ const {
|
|
|
38
38
|
planChanges,
|
|
39
39
|
isEmptyPlan,
|
|
40
40
|
remoteWorkflowState,
|
|
41
|
+
remoteStage,
|
|
42
|
+
LADDER_ORIGIN_BUCKET,
|
|
41
43
|
validateStates,
|
|
42
44
|
stateSuggestions,
|
|
43
45
|
lintPhases,
|
|
@@ -52,7 +54,16 @@ const {
|
|
|
52
54
|
dirtyPaths,
|
|
53
55
|
} = require('../sync-core')
|
|
54
56
|
|
|
55
|
-
const {
|
|
57
|
+
const {
|
|
58
|
+
loadLinearConfig,
|
|
59
|
+
mergeConfig,
|
|
60
|
+
defaults: configDefaults,
|
|
61
|
+
CONFIG_FILE,
|
|
62
|
+
LIFECYCLE_BUCKETS,
|
|
63
|
+
releaseStages,
|
|
64
|
+
releaseIgnorePaths,
|
|
65
|
+
stageFor,
|
|
66
|
+
} = require('./config.js')
|
|
56
67
|
const { resolveApiKey, makeApiAdapter, stateIdFor, fetchWorkspaceStates } = require('./api.js')
|
|
57
68
|
const { runChecks } = require('./doctor.js')
|
|
58
69
|
const {
|
|
@@ -326,8 +337,8 @@ function stateCheckFailure(config, flags) {
|
|
|
326
337
|
// Say what IS available, and what to use instead. "Done is not a state" sends
|
|
327
338
|
// you to the Linear UI to go and look; naming the replacement does not.
|
|
328
339
|
const lines = ['spec-sync push: refusing — configured state name(s) not in the workspace', '']
|
|
329
|
-
for (const {
|
|
330
|
-
lines.push(`
|
|
340
|
+
for (const { label, configured, suggestion } of stateSuggestions(config, list)) {
|
|
341
|
+
lines.push(` ${label}: "${configured}" is not an issue state in this workspace`)
|
|
331
342
|
if (suggestion) lines.push(` use "${suggestion}" instead`)
|
|
332
343
|
}
|
|
333
344
|
lines.push(
|
|
@@ -477,7 +488,15 @@ function specSyncStatus(dir, config, specArg, flags, out) {
|
|
|
477
488
|
const remote = JSON.parse(fs.readFileSync(flags.remote, 'utf-8'))
|
|
478
489
|
const rState = remoteWorkflowState(remote, config)
|
|
479
490
|
const lState = projection.status
|
|
480
|
-
|
|
491
|
+
// A declared deployment stage is POSITION, not disagreement: the spec is
|
|
492
|
+
// complete and the pipeline has moved it on. Reporting that as drift would
|
|
493
|
+
// accuse every deployed spec, for as long as it sits in the pipeline — and
|
|
494
|
+
// "repo wins on next push" would be a lie, since a finished spec's state no
|
|
495
|
+
// longer re-pushes (see compare.js `issueChanges`).
|
|
496
|
+
const stage = remoteStage(remote, config)
|
|
497
|
+
if (stage) {
|
|
498
|
+
lines.push(` stage: Linear is at "${stage.state}" (release stage "${stage.key}", past ${lState})`)
|
|
499
|
+
} else if (rState && lState && rState !== lState) {
|
|
481
500
|
lines.push(` drift: Linear workflow-state is "${rState}" but the spec is "${lState}" (repo wins on next push)`)
|
|
482
501
|
} else {
|
|
483
502
|
lines.push(' drift: none — Linear workflow-state matches the spec')
|
|
@@ -616,6 +635,7 @@ async function specSyncDoctor(dir, flags, out) {
|
|
|
616
635
|
state.mcp = read.facts
|
|
617
636
|
}
|
|
618
637
|
if (flags.remoteCheck) state.remote = await checkRemote(state, flags)
|
|
638
|
+
if (state.remote && state.remote.issueStates) state.ladder.workspaceStates = state.remote.issueStates
|
|
619
639
|
|
|
620
640
|
const report = runChecks(state)
|
|
621
641
|
|
|
@@ -635,7 +655,7 @@ async function specSyncDoctor(dir, flags, out) {
|
|
|
635
655
|
// Everything not `ok`/`skipped` needs a human's attention — including a
|
|
636
656
|
// `missing` one, which is why the count and the EXIT CODE differ: a declined
|
|
637
657
|
// opt-in is worth reporting but must not fail the run (see `runChecks`).
|
|
638
|
-
const attention = report.checks.filter((c) => c.state === 'broken' || c.state === 'missing').length
|
|
658
|
+
const attention = report.checks.filter((c) => c.state === 'broken' || c.state === 'missing' || c.state === 'warn').length
|
|
639
659
|
lines.push('')
|
|
640
660
|
lines.push(attention ? ` ${attention} check(s) need attention.` : ' ready.')
|
|
641
661
|
|
|
@@ -725,7 +745,20 @@ async function checkRemote(state, flags) {
|
|
|
725
745
|
}
|
|
726
746
|
}
|
|
727
747
|
|
|
728
|
-
|
|
748
|
+
// Only fetched when a ladder is declared: the rows that do not need state
|
|
749
|
+
// types must not pay for a call that answers a question nobody asked.
|
|
750
|
+
let issueStates = null
|
|
751
|
+
if (releaseStages(state._config).length && typeof adapter.listIssueStates === 'function') {
|
|
752
|
+
try {
|
|
753
|
+
issueStates = await adapter.listIssueStates(state.tracker.teamId)
|
|
754
|
+
} catch {
|
|
755
|
+
// Unexamined, on the same rule as the project read: a transport failure is
|
|
756
|
+
// not evidence about the ladder's shape.
|
|
757
|
+
issueStates = null
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
return { checked: true, ok: true, teamKey: team.key, teamId: team.id, organization, recordedKey: state.tracker.teamKey, project, issueStates }
|
|
729
762
|
}
|
|
730
763
|
|
|
731
764
|
/**
|
|
@@ -847,6 +880,9 @@ function gatherState(dir, flags) {
|
|
|
847
880
|
}
|
|
848
881
|
|
|
849
882
|
state._config = config
|
|
883
|
+
// Declared here, checked only if `--check-remote` fetches the state TYPES the
|
|
884
|
+
// shape check needs (see `ladderCheck`). Offline, the row stays `skipped`.
|
|
885
|
+
state.ladder = { stages: releaseStages(config) }
|
|
850
886
|
return state
|
|
851
887
|
}
|
|
852
888
|
|
|
@@ -877,7 +913,76 @@ function countSkills(dir) {
|
|
|
877
913
|
* `scripts/`, which is not shipped. The chosen range is always printed, so a
|
|
878
914
|
* wrong guess is visible rather than silent.
|
|
879
915
|
*/
|
|
880
|
-
|
|
916
|
+
/**
|
|
917
|
+
* The base of a two-dot range that git cannot actually reach from the head, or
|
|
918
|
+
* null when the range is trustworthy (or is not a shape we can check).
|
|
919
|
+
*
|
|
920
|
+
* Deliberately NOT "is this repository shallow?". A shallow clone deep enough to
|
|
921
|
+
* contain the base has a COMPLETE range and is perfectly healthy — refusing it
|
|
922
|
+
* would accuse a working setup, which is the failure the stays-silent test in
|
|
923
|
+
* `cli-shallow-range.test.js` pins down. `merge-base --is-ancestor` asks the
|
|
924
|
+
* narrower question the range actually depends on.
|
|
925
|
+
*
|
|
926
|
+
* A range that is not `A..B` (a bare ref, a `...` symmetric difference, a
|
|
927
|
+
* revision expression) has no single base to check, so it is left alone: a guard
|
|
928
|
+
* that cannot see the shape must not pretend to have checked it. The shallow
|
|
929
|
+
* warning in the caller covers that case instead.
|
|
930
|
+
*/
|
|
931
|
+
function unreachableBase(git, range) {
|
|
932
|
+
const text = String(range || '')
|
|
933
|
+
if (text.includes('...')) return null
|
|
934
|
+
const parts = text.split('..')
|
|
935
|
+
if (parts.length !== 2) return null
|
|
936
|
+
const [base, head] = [parts[0].trim(), parts[1].trim() || 'HEAD']
|
|
937
|
+
if (!base) return null
|
|
938
|
+
// `is-ancestor` answers with its EXIT CODE, so a null return (non-zero) is the
|
|
939
|
+
// "not an ancestor" answer, not a missing tool. A base git cannot resolve at
|
|
940
|
+
// all falls through to the existing `git log` refusal, which names it better.
|
|
941
|
+
if (git(['rev-parse', '-q', '--verify', `${base}^{commit}`]) === null) return null
|
|
942
|
+
if (git(['rev-parse', '-q', '--verify', `${head}^{commit}`]) === null) return null
|
|
943
|
+
return git(['merge-base', '--is-ancestor', base, head]) === null ? base : null
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* The paths each commit in the range changed, as sha → string[], or null when
|
|
948
|
+
* git could not be asked at all.
|
|
949
|
+
*
|
|
950
|
+
* A SECOND `git log` rather than `--name-only` on the first one: that format is
|
|
951
|
+
* NUL-delimited so a body cannot split a record, and appending a file list to
|
|
952
|
+
* the same stream would put one commit's files inside the next commit's record.
|
|
953
|
+
* `-z` also stops git C-quoting a non-ASCII filename (`"specs/\303\251.md"`),
|
|
954
|
+
* which a prefix match would then miss.
|
|
955
|
+
*
|
|
956
|
+
* Returning null on failure is deliberate: the caller leaves every commit's
|
|
957
|
+
* paths unknown, nothing is filtered, and the report is exactly what it was
|
|
958
|
+
* before the filter existed. A read that failed must not be able to empty a
|
|
959
|
+
* release.
|
|
960
|
+
*/
|
|
961
|
+
function readChangedPaths(git, range) {
|
|
962
|
+
const raw = git(['log', '-z', '--format=%x1e%H', '--name-only', range])
|
|
963
|
+
if (raw === null) return null
|
|
964
|
+
const bySha = new Map()
|
|
965
|
+
for (const chunk of raw.split('\x1e')) {
|
|
966
|
+
if (!chunk.trim()) continue
|
|
967
|
+
const [sha, ...names] = chunk.split('\x00')
|
|
968
|
+
const id = String(sha || '').trim()
|
|
969
|
+
if (!id) continue
|
|
970
|
+
bySha.set(
|
|
971
|
+
id,
|
|
972
|
+
names.map((n) => n.replace(/^\n+/, '').trim()).filter(Boolean),
|
|
973
|
+
)
|
|
974
|
+
}
|
|
975
|
+
return bySha
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Resolve a commit range and read it, shared by `released` (which reports on it)
|
|
980
|
+
* and `stage` (which acts on it). Both must agree on what a release contains,
|
|
981
|
+
* and a second copy of this would be a second answer.
|
|
982
|
+
*
|
|
983
|
+
* @returns {{range:string, commits:Array}|{error:string[]}}
|
|
984
|
+
*/
|
|
985
|
+
function readCommitRange(dir, rangeArg, verb) {
|
|
881
986
|
const git = (argv) => {
|
|
882
987
|
try {
|
|
883
988
|
return execFileSync('git', ['-C', dir, ...argv], { stdio: ['ignore', 'pipe', 'ignore'] }).toString()
|
|
@@ -890,21 +995,40 @@ async function specSyncReleased(dir, config, rangeArg, flags, out) {
|
|
|
890
995
|
if (!range) {
|
|
891
996
|
const tag = (git(['describe', '--tags', '--abbrev=0']) || '').trim()
|
|
892
997
|
if (!tag) {
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
998
|
+
return {
|
|
999
|
+
error: [
|
|
1000
|
+
`spec-sync ${verb}: no range given and no tag to default from.`,
|
|
1001
|
+
` Pass one explicitly, e.g. spec-sync ${verb} v1.2.0..HEAD`,
|
|
1002
|
+
],
|
|
1003
|
+
}
|
|
898
1004
|
}
|
|
899
1005
|
range = `${tag}..HEAD`
|
|
900
1006
|
}
|
|
901
1007
|
|
|
1008
|
+
// WHAT WOULD FOOL THIS: `git log` reports a TRUNCATED history as a successful
|
|
1009
|
+
// one. On a shallow clone whose depth stops before the base, `git log
|
|
1010
|
+
// base..HEAD` exits 0 and returns only the commits it happens to hold, so the
|
|
1011
|
+
// range silently loses tickets. The tag itself resolves — `rev-parse --verify`
|
|
1012
|
+
// passes — so "does the base exist?" is not the question. The question is
|
|
1013
|
+
// whether the base is in THIS history, which is what `merge-base` answers.
|
|
1014
|
+
const unreachable = unreachableBase(git, range)
|
|
1015
|
+
if (unreachable) {
|
|
1016
|
+
return {
|
|
1017
|
+
error: [
|
|
1018
|
+
`spec-sync ${verb}: refusing — "${unreachable}" is not reachable in this clone's history.`,
|
|
1019
|
+
' The range would silently report fewer commits than it contains.',
|
|
1020
|
+
' This is usually a shallow clone: fetching tags makes the tag resolve',
|
|
1021
|
+
' without deepening the history. Fetch full history (CI: fetch-depth: 0),',
|
|
1022
|
+
' or pass a range whose base is present.',
|
|
1023
|
+
],
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
902
1027
|
// NUL-delimited so a subject or body containing the separator cannot split a
|
|
903
1028
|
// record; RS between commits for the same reason.
|
|
904
1029
|
const raw = git(['log', '--format=%H%x00%s%x00%b%x1e', range])
|
|
905
1030
|
if (raw === null) {
|
|
906
|
-
|
|
907
|
-
return 1
|
|
1031
|
+
return { error: [`spec-sync ${verb}: git could not resolve the range "${range}".`] }
|
|
908
1032
|
}
|
|
909
1033
|
|
|
910
1034
|
const commits = raw
|
|
@@ -916,7 +1040,26 @@ async function specSyncReleased(dir, config, rangeArg, flags, out) {
|
|
|
916
1040
|
return { sha, subject, body: rest.join('\x00') }
|
|
917
1041
|
})
|
|
918
1042
|
|
|
919
|
-
|
|
1043
|
+
// What each commit CHANGED, so bookkeeping can be told from shipped work. A
|
|
1044
|
+
// commit git listed no files for keeps `paths: null` — unknown, never
|
|
1045
|
+
// "changed nothing" — see `onlyIgnoredPaths`.
|
|
1046
|
+
const pathsBySha = readChangedPaths(git, range)
|
|
1047
|
+
for (const commit of commits) {
|
|
1048
|
+
commit.paths = (pathsBySha && pathsBySha.get(commit.sha)) || null
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
return { range, commits }
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
async function specSyncReleased(dir, config, rangeArg, flags, out) {
|
|
1055
|
+
const read = readCommitRange(dir, rangeArg, 'released')
|
|
1056
|
+
if (read.error) {
|
|
1057
|
+
out.write(read.error.join('\n') + '\n')
|
|
1058
|
+
return 1
|
|
1059
|
+
}
|
|
1060
|
+
const { range, commits } = read
|
|
1061
|
+
|
|
1062
|
+
const report = ticketsInRange(commits, { ignorePaths: releaseIgnorePaths(config) })
|
|
920
1063
|
|
|
921
1064
|
// Titles are an ENRICHMENT: the scan itself is offline. No key, the MCP
|
|
922
1065
|
// transport, or a read failure degrades to bare refs — never a failure.
|
|
@@ -959,18 +1102,231 @@ async function specSyncReleased(dir, config, rangeArg, flags, out) {
|
|
|
959
1102
|
// MISSED trailer looks identical, so silence here would read as "everything is
|
|
960
1103
|
// accounted for".
|
|
961
1104
|
lines.push(` ${report.unreferenced} commit(s) carry no ref`)
|
|
1105
|
+
// A filter that removes commits without saying so reads as "there was nothing
|
|
1106
|
+
// there". Named only when it actually fired — on a project with no paperwork
|
|
1107
|
+
// in the range there is nothing to disclose.
|
|
1108
|
+
if (report.ignored) {
|
|
1109
|
+
lines.push(` ${report.ignored} commit(s) ignored as bookkeeping (release.ignorePaths)`)
|
|
1110
|
+
}
|
|
962
1111
|
out.write(lines.join('\n') + '\n')
|
|
963
1112
|
return 0
|
|
964
1113
|
}
|
|
965
1114
|
|
|
966
1115
|
/**
|
|
967
|
-
* `spec-sync
|
|
1116
|
+
* `spec-sync stage <key> [<range>] [--apply] [--json]` — move a release's
|
|
1117
|
+
* tickets onto a declared deployment rung.
|
|
1118
|
+
*
|
|
1119
|
+
* The counterpart to `released`, which reports and cannot write. This is the
|
|
1120
|
+
* write, and it is deliberately a separate verb: someone typing `released` in a
|
|
1121
|
+
* terminal should never be able to move anything.
|
|
1122
|
+
*
|
|
1123
|
+
* **Dry run is the default.** `--apply` is required to touch Linear, and the
|
|
1124
|
+
* resolved range, the target state and the full plan print either way — a wrong
|
|
1125
|
+
* range is then visible before it is acted on rather than after.
|
|
1126
|
+
*
|
|
1127
|
+
* What it refuses to move is as important as what it moves: see
|
|
1128
|
+
* `partitionStageMoves`. Every excluded ref is named with its reason, because a
|
|
1129
|
+
* silent exclusion and a successful move look identical in a pipeline log.
|
|
1130
|
+
*/
|
|
1131
|
+
async function specSyncStage(dir, config, stageKey, rangeArg, flags, out) {
|
|
1132
|
+
const stages = releaseStages(config)
|
|
1133
|
+
if (!stages.length) {
|
|
1134
|
+
out.write(
|
|
1135
|
+
'spec-sync stage: no deployment ladder is declared.\n' +
|
|
1136
|
+
` Add release.stages to ${CONFIG_FILE} — see its docs for the shape.\n`,
|
|
1137
|
+
)
|
|
1138
|
+
return 1
|
|
1139
|
+
}
|
|
1140
|
+
if (!stageKey) {
|
|
1141
|
+
out.write(`spec-sync stage: no stage given. Declared: ${stages.map((s) => s.key).join(', ')}\n`)
|
|
1142
|
+
return 1
|
|
1143
|
+
}
|
|
1144
|
+
const stage = stageFor(config, stageKey)
|
|
1145
|
+
if (!stage) {
|
|
1146
|
+
out.write(
|
|
1147
|
+
`spec-sync stage: no stage named ${JSON.stringify(stageKey)}.\n` +
|
|
1148
|
+
` Declared: ${stages.map((s) => s.key).join(', ')}\n`,
|
|
1149
|
+
)
|
|
1150
|
+
return 1
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
const read = readCommitRange(dir, rangeArg, 'stage')
|
|
1154
|
+
if (read.error) {
|
|
1155
|
+
out.write(read.error.join('\n') + '\n')
|
|
1156
|
+
return 1
|
|
1157
|
+
}
|
|
1158
|
+
const { range, commits } = read
|
|
1159
|
+
|
|
1160
|
+
const report = ticketsInRange(commits, { ignorePaths: releaseIgnorePaths(config) })
|
|
1161
|
+
const teamKey = (config.linear && config.linear.teamKey) || ''
|
|
1162
|
+
const parts = partitionStageMoves({
|
|
1163
|
+
tickets: report.tickets,
|
|
1164
|
+
teamKey,
|
|
1165
|
+
specs: listSpecs(dir, config),
|
|
1166
|
+
cededBucket: LADDER_ORIGIN_BUCKET,
|
|
1167
|
+
})
|
|
1168
|
+
|
|
1169
|
+
const key = resolveApiKey(config, flags.env || process.env)
|
|
1170
|
+
const transport = flags.via || (config.apply && config.apply.transport) || (key.ok ? 'api' : 'mcp')
|
|
1171
|
+
const applying = Boolean(flags.apply)
|
|
1172
|
+
|
|
1173
|
+
if (applying && transport !== 'api') {
|
|
1174
|
+
out.write(
|
|
1175
|
+
`spec-sync stage: refusing to apply over ${transport}.\n` +
|
|
1176
|
+
` ${key.ok ? '--via api is required' : key.error}\n` +
|
|
1177
|
+
' Re-run without --apply for the plan.\n',
|
|
1178
|
+
)
|
|
1179
|
+
return 1
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// Read each movable issue BEFORE any write: it yields the id the update needs,
|
|
1183
|
+
// its title for the report, and its current state for the order warning. A ref
|
|
1184
|
+
// that cannot be read is dropped from the move rather than guessed at.
|
|
1185
|
+
const adapter = transport === 'api' && key.ok ? flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch }) : null
|
|
1186
|
+
const moves = []
|
|
1187
|
+
const unreadable = []
|
|
1188
|
+
for (const ticket of parts.movable) {
|
|
1189
|
+
if (!adapter) {
|
|
1190
|
+
moves.push({ ...ticket, title: null, from: null, warning: null })
|
|
1191
|
+
continue
|
|
1192
|
+
}
|
|
1193
|
+
let issue = null
|
|
1194
|
+
try {
|
|
1195
|
+
issue = await adapter.readIssue(ticket.ref)
|
|
1196
|
+
} catch {
|
|
1197
|
+
issue = null
|
|
1198
|
+
}
|
|
1199
|
+
if (!issue || !issue.id) {
|
|
1200
|
+
unreadable.push(ticket)
|
|
1201
|
+
continue
|
|
1202
|
+
}
|
|
1203
|
+
const from = issue.state && issue.state.name ? issue.state.name : null
|
|
1204
|
+
moves.push({
|
|
1205
|
+
...ticket,
|
|
1206
|
+
id: issue.id,
|
|
1207
|
+
title: issue.title || null,
|
|
1208
|
+
from,
|
|
1209
|
+
warning: stageOrderWarning(stages, from, stage.key, Object.values(config.states || {})),
|
|
1210
|
+
})
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
let moved = []
|
|
1214
|
+
let failed = []
|
|
1215
|
+
if (applying && moves.length) {
|
|
1216
|
+
let states
|
|
1217
|
+
try {
|
|
1218
|
+
states = await adapter.listIssueStates((config.linear && config.linear.teamId) || null)
|
|
1219
|
+
} catch (error) {
|
|
1220
|
+
out.write(`spec-sync stage: could not read the workspace states — ${error.message}\n`)
|
|
1221
|
+
return 1
|
|
1222
|
+
}
|
|
1223
|
+
// Resolve the target id BEFORE the first write, mirroring `apply`: a state
|
|
1224
|
+
// name the workspace lacks must fail with nothing moved, not halfway through.
|
|
1225
|
+
const target = states.find((st) => st && st.name && st.name.toLowerCase().trim() === stage.state.toLowerCase().trim())
|
|
1226
|
+
if (!target) {
|
|
1227
|
+
out.write(
|
|
1228
|
+
`spec-sync stage: refusing — no issue state named ${JSON.stringify(stage.state)} in this workspace.\n` +
|
|
1229
|
+
` available: ${states.map((st) => st.name).join(', ') || '(none reported)'}\n` +
|
|
1230
|
+
' Linear silently ignores an unknown state, so this would have moved nothing.\n',
|
|
1231
|
+
)
|
|
1232
|
+
return 1
|
|
1233
|
+
}
|
|
1234
|
+
for (const move of moves) {
|
|
1235
|
+
try {
|
|
1236
|
+
await adapter.updateIssue(move.id, { stateId: target.id })
|
|
1237
|
+
moved.push(move)
|
|
1238
|
+
} catch (error) {
|
|
1239
|
+
failed.push({ ...move, error: error.message })
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
if (flags.json) {
|
|
1245
|
+
out.write(
|
|
1246
|
+
JSON.stringify(
|
|
1247
|
+
{
|
|
1248
|
+
range,
|
|
1249
|
+
stage: { key: stage.key, state: stage.state },
|
|
1250
|
+
applied: applying,
|
|
1251
|
+
moves: moves.map((m) => ({ ref: m.ref, title: m.title, from: m.from, warning: m.warning })),
|
|
1252
|
+
moved: moved.map((m) => m.ref),
|
|
1253
|
+
failed: failed.map((m) => ({ ref: m.ref, error: m.error })),
|
|
1254
|
+
skipped: {
|
|
1255
|
+
foreign: parts.foreign.map((t) => t.ref),
|
|
1256
|
+
unlinked: parts.unlinked.map((t) => t.ref),
|
|
1257
|
+
unfinished: parts.unfinished.map((t) => ({ ref: t.ref, bucket: t.bucket })),
|
|
1258
|
+
unreadable: unreadable.map((t) => t.ref),
|
|
1259
|
+
},
|
|
1260
|
+
unreferencedCommits: report.unreferenced,
|
|
1261
|
+
ignoredCommits: report.ignored,
|
|
1262
|
+
totalCommits: report.total,
|
|
1263
|
+
},
|
|
1264
|
+
null,
|
|
1265
|
+
2,
|
|
1266
|
+
) + '\n',
|
|
1267
|
+
)
|
|
1268
|
+
return failed.length ? 1 : 0
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
const lines = [`spec-sync stage: ${stage.key} -> "${stage.state}" (${range})`, '']
|
|
1272
|
+
if (!moves.length) {
|
|
1273
|
+
lines.push(applying ? ' moved nothing' : ' would move nothing')
|
|
1274
|
+
} else {
|
|
1275
|
+
lines.push(` ${applying ? 'moved' : 'would move'} ${applying ? moved.length : moves.length} ticket(s)`)
|
|
1276
|
+
for (const m of moves) {
|
|
1277
|
+
const title = m.title ? ` ${m.title}` : ''
|
|
1278
|
+
lines.push(` ${m.ref}${title}`)
|
|
1279
|
+
if (m.warning) lines.push(` warning: ${m.warning}`)
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
for (const { label, items } of [
|
|
1283
|
+
{ label: 'not team ' + (teamKey || '(unset)'), items: parts.foreign.map((t) => t.ref) },
|
|
1284
|
+
{ label: 'no spec in this repo claims it', items: parts.unlinked.map((t) => t.ref) },
|
|
1285
|
+
{
|
|
1286
|
+
label: 'spec not complete — push still owns its state',
|
|
1287
|
+
items: parts.unfinished.map((t) => `${t.ref} (${t.bucket})`),
|
|
1288
|
+
},
|
|
1289
|
+
{ label: 'could not be read from Linear', items: unreadable.map((t) => t.ref) },
|
|
1290
|
+
]) {
|
|
1291
|
+
if (items.length) lines.push(` skipped ${items.length} — ${label}: ${items.join(', ')}`)
|
|
1292
|
+
}
|
|
1293
|
+
for (const f of failed) lines.push(` FAILED ${f.ref}: ${f.error}`)
|
|
1294
|
+
lines.push('')
|
|
1295
|
+
// Said even when zero, for the reason `released` says it: a chore commit
|
|
1296
|
+
// legitimately carries no ref and a MISSED trailer looks identical, so silence
|
|
1297
|
+
// would read as "every commit is accounted for".
|
|
1298
|
+
lines.push(` ${report.unreferenced} commit(s) carry no ref, of ${report.total}`)
|
|
1299
|
+
if (report.ignored) {
|
|
1300
|
+
lines.push(` ${report.ignored} commit(s) ignored as bookkeeping (release.ignorePaths)`)
|
|
1301
|
+
}
|
|
1302
|
+
if (!applying) lines.push(' dry run — pass --apply to move them')
|
|
1303
|
+
out.write(lines.join('\n') + '\n')
|
|
1304
|
+
return failed.length ? 1 : 0
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* `spec-sync ref [<spec>] [--json]` — the ticket a commit's work belongs to.
|
|
968
1309
|
*
|
|
969
1310
|
* Exists so neither a person nor a model has to go spelunking for the id when
|
|
970
1311
|
* writing a commit: `Refs: $(spec-sync ref)`. With a fast-forward-only history
|
|
971
1312
|
* the commit message is the ONLY place a ticket survives into the range a
|
|
972
1313
|
* release scans — branch names never reach it.
|
|
973
1314
|
*
|
|
1315
|
+
* **Bare, it answers from the branch**, which is the same thing as "the ticket
|
|
1316
|
+
* this commit belongs to" only while you are committing that branch's own
|
|
1317
|
+
* implementation work. `/spec` requires no particular branch, so authoring a
|
|
1318
|
+
* backlog spec part-way through another spec makes the two diverge: the commit
|
|
1319
|
+
* is entirely the new spec's and the branch answer is a wrong ref stamped on it.
|
|
1320
|
+
* **Naming the spec resolves it directly** — the branch is then not consulted at
|
|
1321
|
+
* all, so it also works from `main`.
|
|
1322
|
+
*
|
|
1323
|
+
* The default deliberately stays branch-derived rather than inferring the spec
|
|
1324
|
+
* from staged paths. Inference would change what gets stamped based on a lookup
|
|
1325
|
+
* that `git commit -a`, partial staging, or a spec touching shared code each
|
|
1326
|
+
* blind — silently, and in the destructive direction (a plausible wrong ref
|
|
1327
|
+
* beats no ref for looking correct). An explicit override cannot be blinded, and
|
|
1328
|
+
* needs no answer for mixed staging.
|
|
1329
|
+
*
|
|
974
1330
|
* The branch→spec direction is the INVERSE of what `/spec-go` provisions with,
|
|
975
1331
|
* so it is computed by running `branchFor` over each spec and matching, rather
|
|
976
1332
|
* than by re-deriving the pattern here. A second implementation of the naming
|
|
@@ -979,11 +1335,25 @@ async function specSyncReleased(dir, config, rangeArg, flags, out) {
|
|
|
979
1335
|
* **Every no-ref case prints nothing on stdout** and exits non-zero. A commit on
|
|
980
1336
|
* `main`, or on a spec kept deliberately local, has no ticket — and a command
|
|
981
1337
|
* that wrote an error message to stdout would see a shell splice it straight
|
|
982
|
-
* into the commit body via `$(…)`.
|
|
1338
|
+
* into the commit body via `$(…)`. That contract covers the named form too: an
|
|
1339
|
+
* unknown spec name must FAIL rather than quietly falling back to the branch,
|
|
1340
|
+
* which would turn a typo into a confidently wrong ref.
|
|
983
1341
|
*/
|
|
984
|
-
function specSyncRef(dir, config, flags, out, err) {
|
|
1342
|
+
function specSyncRef(dir, config, specArg, flags, out, err) {
|
|
985
1343
|
const say = (msg) => err.write(`spec-sync ref: ${msg}\n`)
|
|
986
1344
|
|
|
1345
|
+
// Shared tail: an unlinked spec is a no-ref case like any other, so it keeps
|
|
1346
|
+
// stdout empty whichever form asked.
|
|
1347
|
+
const emitRef = (spec, branch) => {
|
|
1348
|
+
const identifier = linkedIdentifier(path.join(spec.path, (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'))
|
|
1349
|
+
if (!identifier) {
|
|
1350
|
+
say(`${spec.folder} is not linked to Linear — /spec-push to mirror it`)
|
|
1351
|
+
return 1
|
|
1352
|
+
}
|
|
1353
|
+
out.write(flags.json ? JSON.stringify({ ref: identifier, spec: spec.folder, branch }, null, 2) + '\n' : `${identifier}\n`)
|
|
1354
|
+
return 0
|
|
1355
|
+
}
|
|
1356
|
+
|
|
987
1357
|
const git = (argv) => {
|
|
988
1358
|
try {
|
|
989
1359
|
return execFileSync('git', ['-C', dir, ...argv], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
|
|
@@ -992,6 +1362,18 @@ function specSyncRef(dir, config, flags, out, err) {
|
|
|
992
1362
|
}
|
|
993
1363
|
}
|
|
994
1364
|
const branch = currentBranch(git)
|
|
1365
|
+
|
|
1366
|
+
// The named form answers about the SPEC, so a missing branch is irrelevant to
|
|
1367
|
+
// it — resolve before the branch is required at all.
|
|
1368
|
+
if (specArg) {
|
|
1369
|
+
const named = findSpecFolder(specArg, dir)
|
|
1370
|
+
if (!named) {
|
|
1371
|
+
say(`no spec folder named "${specArg}" in specs/ — check the name`)
|
|
1372
|
+
return 1
|
|
1373
|
+
}
|
|
1374
|
+
return emitRef(named, branch || null)
|
|
1375
|
+
}
|
|
1376
|
+
|
|
995
1377
|
if (!branch) {
|
|
996
1378
|
say('not on a git branch (detached HEAD, or not a git repository)')
|
|
997
1379
|
return 1
|
|
@@ -1023,14 +1405,7 @@ function specSyncRef(dir, config, flags, out, err) {
|
|
|
1023
1405
|
return 1
|
|
1024
1406
|
}
|
|
1025
1407
|
|
|
1026
|
-
|
|
1027
|
-
if (!identifier) {
|
|
1028
|
-
say(`${match.folder} is not linked to Linear — /spec-push to mirror it`)
|
|
1029
|
-
return 1
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
out.write(flags.json ? JSON.stringify({ ref: identifier, spec: match.folder, branch }, null, 2) + '\n' : `${identifier}\n`)
|
|
1033
|
-
return 0
|
|
1408
|
+
return emitRef(match, branch)
|
|
1034
1409
|
}
|
|
1035
1410
|
|
|
1036
1411
|
/**
|
|
@@ -1250,13 +1625,41 @@ async function specSyncStates(dir, config, flags, out) {
|
|
|
1250
1625
|
}
|
|
1251
1626
|
if (flags.json) {
|
|
1252
1627
|
// The bare array `--workspace-states` takes, so this can be piped into it.
|
|
1628
|
+
// Deliberately NOT widened to carry the configured vocabulary: every caller
|
|
1629
|
+
// pipes this straight into that flag, and an object here would break them.
|
|
1253
1630
|
out.write(JSON.stringify(names, null, 2) + '\n')
|
|
1254
1631
|
return 0
|
|
1255
1632
|
}
|
|
1256
|
-
|
|
1633
|
+
const lines = ['spec-sync states: transport = api', ` workspace: ${names.join(', ')}`]
|
|
1634
|
+
lines.push(...configuredVocabularyLines(config, names))
|
|
1635
|
+
out.write(lines.join('\n') + '\n')
|
|
1257
1636
|
return 0
|
|
1258
1637
|
}
|
|
1259
1638
|
|
|
1639
|
+
// The state names this project's config points at, shown against what the
|
|
1640
|
+
// workspace actually has — the bucket map, then the deployment ladder in its
|
|
1641
|
+
// declared order. One command then shows the whole vocabulary, instead of the
|
|
1642
|
+
// ladder living only in a file nobody re-reads.
|
|
1643
|
+
//
|
|
1644
|
+
// A name the workspace lacks is flagged here but nothing is refused: `states`
|
|
1645
|
+
// is a report. The push path is where that is fatal.
|
|
1646
|
+
function configuredVocabularyLines(config, names) {
|
|
1647
|
+
const have = new Set((names || []).map((n) => String(n).toLowerCase().trim()))
|
|
1648
|
+
const mark = (name) => (have.has(String(name).toLowerCase().trim()) ? '' : ' <- not in the workspace')
|
|
1649
|
+
const lines = ['', ' configured:']
|
|
1650
|
+
for (const [bucket, name] of Object.entries((config && config.states) || {})) {
|
|
1651
|
+
lines.push(` states.${bucket}: ${name}${mark(name)}`)
|
|
1652
|
+
}
|
|
1653
|
+
const stages = releaseStages(config)
|
|
1654
|
+
if (stages.length) {
|
|
1655
|
+
lines.push(` release.stages: ${stages.length} rung(s), in order`)
|
|
1656
|
+
stages.forEach((stage, i) => {
|
|
1657
|
+
lines.push(` ${i + 1}. ${stage.key} -> ${stage.state}${mark(stage.state)}`)
|
|
1658
|
+
})
|
|
1659
|
+
}
|
|
1660
|
+
return lines
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1260
1663
|
/**
|
|
1261
1664
|
* `spec-sync apply <spec> --plan <file> [--via api|mcp] [--project <id>]`
|
|
1262
1665
|
*
|
|
@@ -1729,6 +2132,10 @@ function specSyncInitConfig(dir, flags, out) {
|
|
|
1729
2132
|
if (flags.hotfixLabels.length) intake.hotfixLabels = flags.hotfixLabels
|
|
1730
2133
|
if (Object.keys(intake).length) draft.intake = intake
|
|
1731
2134
|
if (Object.keys(flags.stateNames).length) draft.states = { ...flags.stateNames }
|
|
2135
|
+
// The ladder, in the order given. `mergeConfig` below is what validates the
|
|
2136
|
+
// shape — a blank key or a duplicate fails there, in one place, rather than
|
|
2137
|
+
// being re-checked here and drifting from the loader.
|
|
2138
|
+
if (flags.stages.length) draft.release = { stages: flags.stages }
|
|
1732
2139
|
|
|
1733
2140
|
for (const bucket of Object.keys(flags.stateNames)) {
|
|
1734
2141
|
if (!LIFECYCLE_BUCKETS.includes(bucket)) {
|
|
@@ -1768,9 +2175,11 @@ function specSyncInitConfig(dir, flags, out) {
|
|
|
1768
2175
|
const missing = validateStates(effective, workspace)
|
|
1769
2176
|
if (missing.length) {
|
|
1770
2177
|
const lines = ['spec-sync init-config: refusing — configured state name(s) not in the workspace', '']
|
|
1771
|
-
for (const { bucket, configured, suggestion } of stateSuggestions(effective, workspace)) {
|
|
1772
|
-
lines.push(`
|
|
1773
|
-
|
|
2178
|
+
for (const { bucket, label, configured, suggestion } of stateSuggestions(effective, workspace)) {
|
|
2179
|
+
lines.push(` ${label}: "${configured}" is not an issue state in this workspace`)
|
|
2180
|
+
// `--state <bucket>=<name>` only addresses the bucket map; a ladder rung
|
|
2181
|
+
// is edited in the config, and never carries a suggestion anyway.
|
|
2182
|
+
if (suggestion && bucket) lines.push(` pass --state ${bucket}="${suggestion}"`)
|
|
1774
2183
|
}
|
|
1775
2184
|
lines.push(
|
|
1776
2185
|
'',
|
|
@@ -2083,10 +2492,13 @@ async function specSync(rest, io = {}) {
|
|
|
2083
2492
|
// after the loop.
|
|
2084
2493
|
const unknownFlags = []
|
|
2085
2494
|
const flags = { json: false, remote: null, workspaceStates: null, skipStateCheck: false, issue: null, url: null, subs: [], stored: null, plan: null, via: null, project: null, all: null,
|
|
2086
|
-
mcp: null, force: false, yes: false, remoteCheck: false, teamId: '', teamKey: '', projectId: '', intakeLabel: '', bugLabels: [], hotfixLabels: [], stateNames: {}, statesFile: null }
|
|
2495
|
+
mcp: null, force: false, yes: false, apply: false, remoteCheck: false, teamId: '', teamKey: '', projectId: '', intakeLabel: '', bugLabels: [], hotfixLabels: [], stateNames: {}, statesFile: null, stages: [] }
|
|
2087
2496
|
for (let i = 0; i < args.length; i++) {
|
|
2088
2497
|
if (args[i] === '--dir') dir = path.resolve(args[++i])
|
|
2089
2498
|
else if (args[i] === '--json') flags.json = true
|
|
2499
|
+
// `stage` writes only on --apply: the dry run is the default so a wrong
|
|
2500
|
+
// range is seen before it is acted on, not after.
|
|
2501
|
+
else if (args[i] === '--apply') flags.apply = true
|
|
2090
2502
|
else if (args[i] === '--remote') flags.remote = path.resolve(args[++i])
|
|
2091
2503
|
else if (args[i] === '--stored') flags.stored = path.resolve(args[++i])
|
|
2092
2504
|
else if (args[i] === '--mcp') flags.mcp = path.resolve(args[++i])
|
|
@@ -2123,6 +2535,11 @@ async function specSync(rest, io = {}) {
|
|
|
2123
2535
|
// actually renamed get written; the rest keep the defaults.
|
|
2124
2536
|
const [bucket, ...rest] = String(args[++i] || '').split('=')
|
|
2125
2537
|
flags.stateNames[String(bucket).trim()] = rest.join('=').trim()
|
|
2538
|
+
} else if (args[i] === '--stage') {
|
|
2539
|
+
// `--stage test="On Test"`, repeatable. Order matters and is the order
|
|
2540
|
+
// given, so the ladder is written exactly as the operator listed it.
|
|
2541
|
+
const [key, ...rest] = String(args[++i] || '').split('=')
|
|
2542
|
+
flags.stages.push({ key: String(key).trim(), state: rest.join('=').trim() })
|
|
2126
2543
|
} else if (args[i] === '--states') flags.statesFile = path.resolve(args[++i])
|
|
2127
2544
|
else if (args[i].startsWith('--')) unknownFlags.push(args[i])
|
|
2128
2545
|
else positional.push(args[i])
|
|
@@ -2200,8 +2617,10 @@ async function specSync(rest, io = {}) {
|
|
|
2200
2617
|
return (await specSyncStates(dir, config, flags, out)) || 0
|
|
2201
2618
|
case 'released':
|
|
2202
2619
|
return (await specSyncReleased(dir, config, positional[0], flags, out)) || 0
|
|
2620
|
+
case 'stage':
|
|
2621
|
+
return (await specSyncStage(dir, config, positional[0], positional[1], flags, out)) || 0
|
|
2203
2622
|
case 'ref':
|
|
2204
|
-
return specSyncRef(dir, config, flags, out, err) || 0
|
|
2623
|
+
return specSyncRef(dir, config, positional[0], flags, out, err) || 0
|
|
2205
2624
|
case 'retarget':
|
|
2206
2625
|
return (await specSyncRetarget(dir, config, flags, out)) || 0
|
|
2207
2626
|
case 'apply':
|
|
@@ -2224,8 +2643,9 @@ async function specSync(rest, io = {}) {
|
|
|
2224
2643
|
' skitterspec spec-sync apply --all <bucket> [--via api|mcp] [--json]\n' +
|
|
2225
2644
|
' skitterspec spec-sync verify <spec> --stored <file>\n' +
|
|
2226
2645
|
' skitterspec spec-sync linked [--json]\n' +
|
|
2227
|
-
' skitterspec spec-sync ref [--json]\n' +
|
|
2646
|
+
' skitterspec spec-sync ref [<spec>] [--json]\n' +
|
|
2228
2647
|
' skitterspec spec-sync released [<range>] [--json]\n' +
|
|
2648
|
+
' skitterspec spec-sync stage <key> [<range>] [--apply] [--json]\n' +
|
|
2229
2649
|
' skitterspec spec-sync retarget [--yes]\n' +
|
|
2230
2650
|
' skitterspec spec-sync doctor [--check-remote] [--mcp <file>] [--json]\n' +
|
|
2231
2651
|
' skitterspec spec-sync init-config --team-id <id> [--team-key K] [--project-id id]\n' +
|