@skitterbyte/skitterspec-linear 10.5.2 → 10.7.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 +3 -0
- package/assets/core/linear.config.md +46 -0
- package/assets/rules/commit-trailers.md +84 -0
- package/assets/rules/spec-planning.md +17 -3
- package/assets/skills/spec/SKILL.md +20 -0
- package/assets/skills/spec-bug/SKILL.md +40 -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 +60 -28
- package/assets/skills/spec-hotfix/SKILL.md +41 -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 +27 -2
- 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 +176 -39
- 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 +560 -12
- package/src/vendor/linear/config.js +91 -1
- package/src/vendor/linear/doctor.js +67 -1
- package/src/vendor/linear/released.js +164 -0
- 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
|
@@ -23,7 +23,10 @@
|
|
|
23
23
|
const fs = require('node:fs')
|
|
24
24
|
const path = require('node:path')
|
|
25
25
|
|
|
26
|
-
const { BUCKETS, findSpecFolder } = require('../../env/resolve.js')
|
|
26
|
+
const { BUCKETS, findSpecFolder, branchFor, splitPrefix, currentBranch } = require('../../env/resolve.js')
|
|
27
|
+
const { loadEnvConfig } = require('../../env/config.js')
|
|
28
|
+
const { ticketsInRange, partitionStageMoves, stageOrderWarning } = require('./released.js')
|
|
29
|
+
const { execFileSync } = require('node:child_process')
|
|
27
30
|
const {
|
|
28
31
|
normalizeLocal,
|
|
29
32
|
readSnapshot,
|
|
@@ -35,6 +38,8 @@ const {
|
|
|
35
38
|
planChanges,
|
|
36
39
|
isEmptyPlan,
|
|
37
40
|
remoteWorkflowState,
|
|
41
|
+
remoteStage,
|
|
42
|
+
LADDER_ORIGIN_BUCKET,
|
|
38
43
|
validateStates,
|
|
39
44
|
stateSuggestions,
|
|
40
45
|
lintPhases,
|
|
@@ -49,7 +54,15 @@ const {
|
|
|
49
54
|
dirtyPaths,
|
|
50
55
|
} = require('../sync-core')
|
|
51
56
|
|
|
52
|
-
const {
|
|
57
|
+
const {
|
|
58
|
+
loadLinearConfig,
|
|
59
|
+
mergeConfig,
|
|
60
|
+
defaults: configDefaults,
|
|
61
|
+
CONFIG_FILE,
|
|
62
|
+
LIFECYCLE_BUCKETS,
|
|
63
|
+
releaseStages,
|
|
64
|
+
stageFor,
|
|
65
|
+
} = require('./config.js')
|
|
53
66
|
const { resolveApiKey, makeApiAdapter, stateIdFor, fetchWorkspaceStates } = require('./api.js')
|
|
54
67
|
const { runChecks } = require('./doctor.js')
|
|
55
68
|
const {
|
|
@@ -323,8 +336,8 @@ function stateCheckFailure(config, flags) {
|
|
|
323
336
|
// Say what IS available, and what to use instead. "Done is not a state" sends
|
|
324
337
|
// you to the Linear UI to go and look; naming the replacement does not.
|
|
325
338
|
const lines = ['spec-sync push: refusing — configured state name(s) not in the workspace', '']
|
|
326
|
-
for (const {
|
|
327
|
-
lines.push(`
|
|
339
|
+
for (const { label, configured, suggestion } of stateSuggestions(config, list)) {
|
|
340
|
+
lines.push(` ${label}: "${configured}" is not an issue state in this workspace`)
|
|
328
341
|
if (suggestion) lines.push(` use "${suggestion}" instead`)
|
|
329
342
|
}
|
|
330
343
|
lines.push(
|
|
@@ -474,7 +487,15 @@ function specSyncStatus(dir, config, specArg, flags, out) {
|
|
|
474
487
|
const remote = JSON.parse(fs.readFileSync(flags.remote, 'utf-8'))
|
|
475
488
|
const rState = remoteWorkflowState(remote, config)
|
|
476
489
|
const lState = projection.status
|
|
477
|
-
|
|
490
|
+
// A declared deployment stage is POSITION, not disagreement: the spec is
|
|
491
|
+
// complete and the pipeline has moved it on. Reporting that as drift would
|
|
492
|
+
// accuse every deployed spec, for as long as it sits in the pipeline — and
|
|
493
|
+
// "repo wins on next push" would be a lie, since a finished spec's state no
|
|
494
|
+
// longer re-pushes (see compare.js `issueChanges`).
|
|
495
|
+
const stage = remoteStage(remote, config)
|
|
496
|
+
if (stage) {
|
|
497
|
+
lines.push(` stage: Linear is at "${stage.state}" (release stage "${stage.key}", past ${lState})`)
|
|
498
|
+
} else if (rState && lState && rState !== lState) {
|
|
478
499
|
lines.push(` drift: Linear workflow-state is "${rState}" but the spec is "${lState}" (repo wins on next push)`)
|
|
479
500
|
} else {
|
|
480
501
|
lines.push(' drift: none — Linear workflow-state matches the spec')
|
|
@@ -613,6 +634,7 @@ async function specSyncDoctor(dir, flags, out) {
|
|
|
613
634
|
state.mcp = read.facts
|
|
614
635
|
}
|
|
615
636
|
if (flags.remoteCheck) state.remote = await checkRemote(state, flags)
|
|
637
|
+
if (state.remote && state.remote.issueStates) state.ladder.workspaceStates = state.remote.issueStates
|
|
616
638
|
|
|
617
639
|
const report = runChecks(state)
|
|
618
640
|
|
|
@@ -632,7 +654,7 @@ async function specSyncDoctor(dir, flags, out) {
|
|
|
632
654
|
// Everything not `ok`/`skipped` needs a human's attention — including a
|
|
633
655
|
// `missing` one, which is why the count and the EXIT CODE differ: a declined
|
|
634
656
|
// opt-in is worth reporting but must not fail the run (see `runChecks`).
|
|
635
|
-
const attention = report.checks.filter((c) => c.state === 'broken' || c.state === 'missing').length
|
|
657
|
+
const attention = report.checks.filter((c) => c.state === 'broken' || c.state === 'missing' || c.state === 'warn').length
|
|
636
658
|
lines.push('')
|
|
637
659
|
lines.push(attention ? ` ${attention} check(s) need attention.` : ' ready.')
|
|
638
660
|
|
|
@@ -722,7 +744,20 @@ async function checkRemote(state, flags) {
|
|
|
722
744
|
}
|
|
723
745
|
}
|
|
724
746
|
|
|
725
|
-
|
|
747
|
+
// Only fetched when a ladder is declared: the rows that do not need state
|
|
748
|
+
// types must not pay for a call that answers a question nobody asked.
|
|
749
|
+
let issueStates = null
|
|
750
|
+
if (releaseStages(state._config).length && typeof adapter.listIssueStates === 'function') {
|
|
751
|
+
try {
|
|
752
|
+
issueStates = await adapter.listIssueStates(state.tracker.teamId)
|
|
753
|
+
} catch {
|
|
754
|
+
// Unexamined, on the same rule as the project read: a transport failure is
|
|
755
|
+
// not evidence about the ladder's shape.
|
|
756
|
+
issueStates = null
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
return { checked: true, ok: true, teamKey: team.key, teamId: team.id, organization, recordedKey: state.tracker.teamKey, project, issueStates }
|
|
726
761
|
}
|
|
727
762
|
|
|
728
763
|
/**
|
|
@@ -844,6 +879,9 @@ function gatherState(dir, flags) {
|
|
|
844
879
|
}
|
|
845
880
|
|
|
846
881
|
state._config = config
|
|
882
|
+
// Declared here, checked only if `--check-remote` fetches the state TYPES the
|
|
883
|
+
// shape check needs (see `ladderCheck`). Offline, the row stays `skipped`.
|
|
884
|
+
state.ladder = { stages: releaseStages(config) }
|
|
847
885
|
return state
|
|
848
886
|
}
|
|
849
887
|
|
|
@@ -860,6 +898,465 @@ function countSkills(dir) {
|
|
|
860
898
|
}
|
|
861
899
|
}
|
|
862
900
|
|
|
901
|
+
/**
|
|
902
|
+
* `spec-sync released [<range>] [--json]` — the tickets a release contains.
|
|
903
|
+
*
|
|
904
|
+
* Read-only, and deliberately so: it reports what shipped, it does not move
|
|
905
|
+
* anything. A release can be cut and never deployed, and workflow state is
|
|
906
|
+
* pushed from a spec's lifecycle bucket — "released" is not one, so moving
|
|
907
|
+
* tickets here would be a new kind of write with no dry run.
|
|
908
|
+
*
|
|
909
|
+
* The default range is the most recent tag reachable from HEAD, resolved with
|
|
910
|
+
* `git describe` rather than by knowing a tag scheme. This repo tags
|
|
911
|
+
* `name@version`, but a consumer may tag `v1.2.3` — and the scheme lives in
|
|
912
|
+
* `scripts/`, which is not shipped. The chosen range is always printed, so a
|
|
913
|
+
* wrong guess is visible rather than silent.
|
|
914
|
+
*/
|
|
915
|
+
/**
|
|
916
|
+
* The base of a two-dot range that git cannot actually reach from the head, or
|
|
917
|
+
* null when the range is trustworthy (or is not a shape we can check).
|
|
918
|
+
*
|
|
919
|
+
* Deliberately NOT "is this repository shallow?". A shallow clone deep enough to
|
|
920
|
+
* contain the base has a COMPLETE range and is perfectly healthy — refusing it
|
|
921
|
+
* would accuse a working setup, which is the failure the stays-silent test in
|
|
922
|
+
* `cli-shallow-range.test.js` pins down. `merge-base --is-ancestor` asks the
|
|
923
|
+
* narrower question the range actually depends on.
|
|
924
|
+
*
|
|
925
|
+
* A range that is not `A..B` (a bare ref, a `...` symmetric difference, a
|
|
926
|
+
* revision expression) has no single base to check, so it is left alone: a guard
|
|
927
|
+
* that cannot see the shape must not pretend to have checked it. The shallow
|
|
928
|
+
* warning in the caller covers that case instead.
|
|
929
|
+
*/
|
|
930
|
+
function unreachableBase(git, range) {
|
|
931
|
+
const text = String(range || '')
|
|
932
|
+
if (text.includes('...')) return null
|
|
933
|
+
const parts = text.split('..')
|
|
934
|
+
if (parts.length !== 2) return null
|
|
935
|
+
const [base, head] = [parts[0].trim(), parts[1].trim() || 'HEAD']
|
|
936
|
+
if (!base) return null
|
|
937
|
+
// `is-ancestor` answers with its EXIT CODE, so a null return (non-zero) is the
|
|
938
|
+
// "not an ancestor" answer, not a missing tool. A base git cannot resolve at
|
|
939
|
+
// all falls through to the existing `git log` refusal, which names it better.
|
|
940
|
+
if (git(['rev-parse', '-q', '--verify', `${base}^{commit}`]) === null) return null
|
|
941
|
+
if (git(['rev-parse', '-q', '--verify', `${head}^{commit}`]) === null) return null
|
|
942
|
+
return git(['merge-base', '--is-ancestor', base, head]) === null ? base : null
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Resolve a commit range and read it, shared by `released` (which reports on it)
|
|
947
|
+
* and `stage` (which acts on it). Both must agree on what a release contains,
|
|
948
|
+
* and a second copy of this would be a second answer.
|
|
949
|
+
*
|
|
950
|
+
* @returns {{range:string, commits:Array}|{error:string[]}}
|
|
951
|
+
*/
|
|
952
|
+
function readCommitRange(dir, rangeArg, verb) {
|
|
953
|
+
const git = (argv) => {
|
|
954
|
+
try {
|
|
955
|
+
return execFileSync('git', ['-C', dir, ...argv], { stdio: ['ignore', 'pipe', 'ignore'] }).toString()
|
|
956
|
+
} catch {
|
|
957
|
+
return null
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
let range = rangeArg
|
|
962
|
+
if (!range) {
|
|
963
|
+
const tag = (git(['describe', '--tags', '--abbrev=0']) || '').trim()
|
|
964
|
+
if (!tag) {
|
|
965
|
+
return {
|
|
966
|
+
error: [
|
|
967
|
+
`spec-sync ${verb}: no range given and no tag to default from.`,
|
|
968
|
+
` Pass one explicitly, e.g. spec-sync ${verb} v1.2.0..HEAD`,
|
|
969
|
+
],
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
range = `${tag}..HEAD`
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// WHAT WOULD FOOL THIS: `git log` reports a TRUNCATED history as a successful
|
|
976
|
+
// one. On a shallow clone whose depth stops before the base, `git log
|
|
977
|
+
// base..HEAD` exits 0 and returns only the commits it happens to hold, so the
|
|
978
|
+
// range silently loses tickets. The tag itself resolves — `rev-parse --verify`
|
|
979
|
+
// passes — so "does the base exist?" is not the question. The question is
|
|
980
|
+
// whether the base is in THIS history, which is what `merge-base` answers.
|
|
981
|
+
const unreachable = unreachableBase(git, range)
|
|
982
|
+
if (unreachable) {
|
|
983
|
+
return {
|
|
984
|
+
error: [
|
|
985
|
+
`spec-sync ${verb}: refusing — "${unreachable}" is not reachable in this clone's history.`,
|
|
986
|
+
' The range would silently report fewer commits than it contains.',
|
|
987
|
+
' This is usually a shallow clone: fetching tags makes the tag resolve',
|
|
988
|
+
' without deepening the history. Fetch full history (CI: fetch-depth: 0),',
|
|
989
|
+
' or pass a range whose base is present.',
|
|
990
|
+
],
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// NUL-delimited so a subject or body containing the separator cannot split a
|
|
995
|
+
// record; RS between commits for the same reason.
|
|
996
|
+
const raw = git(['log', '--format=%H%x00%s%x00%b%x1e', range])
|
|
997
|
+
if (raw === null) {
|
|
998
|
+
return { error: [`spec-sync ${verb}: git could not resolve the range "${range}".`] }
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const commits = raw
|
|
1002
|
+
.split('\x1e')
|
|
1003
|
+
.map((r) => r.replace(/^\n/, ''))
|
|
1004
|
+
.filter((r) => r.trim())
|
|
1005
|
+
.map((record) => {
|
|
1006
|
+
const [sha, subject, ...rest] = record.split('\x00')
|
|
1007
|
+
return { sha, subject, body: rest.join('\x00') }
|
|
1008
|
+
})
|
|
1009
|
+
|
|
1010
|
+
return { range, commits }
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
async function specSyncReleased(dir, config, rangeArg, flags, out) {
|
|
1014
|
+
const read = readCommitRange(dir, rangeArg, 'released')
|
|
1015
|
+
if (read.error) {
|
|
1016
|
+
out.write(read.error.join('\n') + '\n')
|
|
1017
|
+
return 1
|
|
1018
|
+
}
|
|
1019
|
+
const { range, commits } = read
|
|
1020
|
+
|
|
1021
|
+
const report = ticketsInRange(commits)
|
|
1022
|
+
|
|
1023
|
+
// Titles are an ENRICHMENT: the scan itself is offline. No key, the MCP
|
|
1024
|
+
// transport, or a read failure degrades to bare refs — never a failure.
|
|
1025
|
+
const key = resolveApiKey(config, flags.env || process.env)
|
|
1026
|
+
const transport = flags.via || (config.apply && config.apply.transport) || (key.ok ? 'api' : 'mcp')
|
|
1027
|
+
let titles = null
|
|
1028
|
+
let titleNote = ''
|
|
1029
|
+
if (report.tickets.length && transport === 'api') {
|
|
1030
|
+
const adapter = flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch })
|
|
1031
|
+
titles = {}
|
|
1032
|
+
for (const t of report.tickets) {
|
|
1033
|
+
try {
|
|
1034
|
+
const issue = await adapter.readIssue(t.ref)
|
|
1035
|
+
if (issue && issue.title) titles[t.ref] = issue.title
|
|
1036
|
+
} catch {
|
|
1037
|
+
titleNote = ' (titles unavailable — Linear could not be read)'
|
|
1038
|
+
titles = null
|
|
1039
|
+
break
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
} else if (report.tickets.length) {
|
|
1043
|
+
titleNote = ` (titles unavailable — transport = ${transport})`
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
if (flags.json) {
|
|
1047
|
+
out.write(JSON.stringify({ range, ...report, titles }, null, 2) + '\n')
|
|
1048
|
+
return 0
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
const lines = [`spec-sync released: ${range}`, '']
|
|
1052
|
+
if (!report.tickets.length) lines.push(' no ticket references found')
|
|
1053
|
+
for (const t of report.tickets) {
|
|
1054
|
+
const title = titles && titles[t.ref] ? ` ${titles[t.ref]}` : ''
|
|
1055
|
+
lines.push(` ${t.ref}${title}`)
|
|
1056
|
+
}
|
|
1057
|
+
if (titleNote) lines.push(titleNote)
|
|
1058
|
+
lines.push('')
|
|
1059
|
+
lines.push(` ${report.tickets.length} ticket(s) in ${report.total} commit(s)`)
|
|
1060
|
+
// Said even when zero: a chore commit legitimately carries no ticket and a
|
|
1061
|
+
// MISSED trailer looks identical, so silence here would read as "everything is
|
|
1062
|
+
// accounted for".
|
|
1063
|
+
lines.push(` ${report.unreferenced} commit(s) carry no ref`)
|
|
1064
|
+
out.write(lines.join('\n') + '\n')
|
|
1065
|
+
return 0
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* `spec-sync stage <key> [<range>] [--apply] [--json]` — move a release's
|
|
1070
|
+
* tickets onto a declared deployment rung.
|
|
1071
|
+
*
|
|
1072
|
+
* The counterpart to `released`, which reports and cannot write. This is the
|
|
1073
|
+
* write, and it is deliberately a separate verb: someone typing `released` in a
|
|
1074
|
+
* terminal should never be able to move anything.
|
|
1075
|
+
*
|
|
1076
|
+
* **Dry run is the default.** `--apply` is required to touch Linear, and the
|
|
1077
|
+
* resolved range, the target state and the full plan print either way — a wrong
|
|
1078
|
+
* range is then visible before it is acted on rather than after.
|
|
1079
|
+
*
|
|
1080
|
+
* What it refuses to move is as important as what it moves: see
|
|
1081
|
+
* `partitionStageMoves`. Every excluded ref is named with its reason, because a
|
|
1082
|
+
* silent exclusion and a successful move look identical in a pipeline log.
|
|
1083
|
+
*/
|
|
1084
|
+
async function specSyncStage(dir, config, stageKey, rangeArg, flags, out) {
|
|
1085
|
+
const stages = releaseStages(config)
|
|
1086
|
+
if (!stages.length) {
|
|
1087
|
+
out.write(
|
|
1088
|
+
'spec-sync stage: no deployment ladder is declared.\n' +
|
|
1089
|
+
` Add release.stages to ${CONFIG_FILE} — see its docs for the shape.\n`,
|
|
1090
|
+
)
|
|
1091
|
+
return 1
|
|
1092
|
+
}
|
|
1093
|
+
if (!stageKey) {
|
|
1094
|
+
out.write(`spec-sync stage: no stage given. Declared: ${stages.map((s) => s.key).join(', ')}\n`)
|
|
1095
|
+
return 1
|
|
1096
|
+
}
|
|
1097
|
+
const stage = stageFor(config, stageKey)
|
|
1098
|
+
if (!stage) {
|
|
1099
|
+
out.write(
|
|
1100
|
+
`spec-sync stage: no stage named ${JSON.stringify(stageKey)}.\n` +
|
|
1101
|
+
` Declared: ${stages.map((s) => s.key).join(', ')}\n`,
|
|
1102
|
+
)
|
|
1103
|
+
return 1
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const read = readCommitRange(dir, rangeArg, 'stage')
|
|
1107
|
+
if (read.error) {
|
|
1108
|
+
out.write(read.error.join('\n') + '\n')
|
|
1109
|
+
return 1
|
|
1110
|
+
}
|
|
1111
|
+
const { range, commits } = read
|
|
1112
|
+
|
|
1113
|
+
const report = ticketsInRange(commits)
|
|
1114
|
+
const teamKey = (config.linear && config.linear.teamKey) || ''
|
|
1115
|
+
const parts = partitionStageMoves({
|
|
1116
|
+
tickets: report.tickets,
|
|
1117
|
+
teamKey,
|
|
1118
|
+
specs: listSpecs(dir, config),
|
|
1119
|
+
cededBucket: LADDER_ORIGIN_BUCKET,
|
|
1120
|
+
})
|
|
1121
|
+
|
|
1122
|
+
const key = resolveApiKey(config, flags.env || process.env)
|
|
1123
|
+
const transport = flags.via || (config.apply && config.apply.transport) || (key.ok ? 'api' : 'mcp')
|
|
1124
|
+
const applying = Boolean(flags.apply)
|
|
1125
|
+
|
|
1126
|
+
if (applying && transport !== 'api') {
|
|
1127
|
+
out.write(
|
|
1128
|
+
`spec-sync stage: refusing to apply over ${transport}.\n` +
|
|
1129
|
+
` ${key.ok ? '--via api is required' : key.error}\n` +
|
|
1130
|
+
' Re-run without --apply for the plan.\n',
|
|
1131
|
+
)
|
|
1132
|
+
return 1
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// Read each movable issue BEFORE any write: it yields the id the update needs,
|
|
1136
|
+
// its title for the report, and its current state for the order warning. A ref
|
|
1137
|
+
// that cannot be read is dropped from the move rather than guessed at.
|
|
1138
|
+
const adapter = transport === 'api' && key.ok ? flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch }) : null
|
|
1139
|
+
const moves = []
|
|
1140
|
+
const unreadable = []
|
|
1141
|
+
for (const ticket of parts.movable) {
|
|
1142
|
+
if (!adapter) {
|
|
1143
|
+
moves.push({ ...ticket, title: null, from: null, warning: null })
|
|
1144
|
+
continue
|
|
1145
|
+
}
|
|
1146
|
+
let issue = null
|
|
1147
|
+
try {
|
|
1148
|
+
issue = await adapter.readIssue(ticket.ref)
|
|
1149
|
+
} catch {
|
|
1150
|
+
issue = null
|
|
1151
|
+
}
|
|
1152
|
+
if (!issue || !issue.id) {
|
|
1153
|
+
unreadable.push(ticket)
|
|
1154
|
+
continue
|
|
1155
|
+
}
|
|
1156
|
+
const from = issue.state && issue.state.name ? issue.state.name : null
|
|
1157
|
+
moves.push({
|
|
1158
|
+
...ticket,
|
|
1159
|
+
id: issue.id,
|
|
1160
|
+
title: issue.title || null,
|
|
1161
|
+
from,
|
|
1162
|
+
warning: stageOrderWarning(stages, from, stage.key, Object.values(config.states || {})),
|
|
1163
|
+
})
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
let moved = []
|
|
1167
|
+
let failed = []
|
|
1168
|
+
if (applying && moves.length) {
|
|
1169
|
+
let states
|
|
1170
|
+
try {
|
|
1171
|
+
states = await adapter.listIssueStates((config.linear && config.linear.teamId) || null)
|
|
1172
|
+
} catch (error) {
|
|
1173
|
+
out.write(`spec-sync stage: could not read the workspace states — ${error.message}\n`)
|
|
1174
|
+
return 1
|
|
1175
|
+
}
|
|
1176
|
+
// Resolve the target id BEFORE the first write, mirroring `apply`: a state
|
|
1177
|
+
// name the workspace lacks must fail with nothing moved, not halfway through.
|
|
1178
|
+
const target = states.find((st) => st && st.name && st.name.toLowerCase().trim() === stage.state.toLowerCase().trim())
|
|
1179
|
+
if (!target) {
|
|
1180
|
+
out.write(
|
|
1181
|
+
`spec-sync stage: refusing — no issue state named ${JSON.stringify(stage.state)} in this workspace.\n` +
|
|
1182
|
+
` available: ${states.map((st) => st.name).join(', ') || '(none reported)'}\n` +
|
|
1183
|
+
' Linear silently ignores an unknown state, so this would have moved nothing.\n',
|
|
1184
|
+
)
|
|
1185
|
+
return 1
|
|
1186
|
+
}
|
|
1187
|
+
for (const move of moves) {
|
|
1188
|
+
try {
|
|
1189
|
+
await adapter.updateIssue(move.id, { stateId: target.id })
|
|
1190
|
+
moved.push(move)
|
|
1191
|
+
} catch (error) {
|
|
1192
|
+
failed.push({ ...move, error: error.message })
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
if (flags.json) {
|
|
1198
|
+
out.write(
|
|
1199
|
+
JSON.stringify(
|
|
1200
|
+
{
|
|
1201
|
+
range,
|
|
1202
|
+
stage: { key: stage.key, state: stage.state },
|
|
1203
|
+
applied: applying,
|
|
1204
|
+
moves: moves.map((m) => ({ ref: m.ref, title: m.title, from: m.from, warning: m.warning })),
|
|
1205
|
+
moved: moved.map((m) => m.ref),
|
|
1206
|
+
failed: failed.map((m) => ({ ref: m.ref, error: m.error })),
|
|
1207
|
+
skipped: {
|
|
1208
|
+
foreign: parts.foreign.map((t) => t.ref),
|
|
1209
|
+
unlinked: parts.unlinked.map((t) => t.ref),
|
|
1210
|
+
unfinished: parts.unfinished.map((t) => ({ ref: t.ref, bucket: t.bucket })),
|
|
1211
|
+
unreadable: unreadable.map((t) => t.ref),
|
|
1212
|
+
},
|
|
1213
|
+
unreferencedCommits: report.unreferenced,
|
|
1214
|
+
totalCommits: report.total,
|
|
1215
|
+
},
|
|
1216
|
+
null,
|
|
1217
|
+
2,
|
|
1218
|
+
) + '\n',
|
|
1219
|
+
)
|
|
1220
|
+
return failed.length ? 1 : 0
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
const lines = [`spec-sync stage: ${stage.key} -> "${stage.state}" (${range})`, '']
|
|
1224
|
+
if (!moves.length) {
|
|
1225
|
+
lines.push(applying ? ' moved nothing' : ' would move nothing')
|
|
1226
|
+
} else {
|
|
1227
|
+
lines.push(` ${applying ? 'moved' : 'would move'} ${applying ? moved.length : moves.length} ticket(s)`)
|
|
1228
|
+
for (const m of moves) {
|
|
1229
|
+
const title = m.title ? ` ${m.title}` : ''
|
|
1230
|
+
lines.push(` ${m.ref}${title}`)
|
|
1231
|
+
if (m.warning) lines.push(` warning: ${m.warning}`)
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
for (const { label, items } of [
|
|
1235
|
+
{ label: 'not team ' + (teamKey || '(unset)'), items: parts.foreign.map((t) => t.ref) },
|
|
1236
|
+
{ label: 'no spec in this repo claims it', items: parts.unlinked.map((t) => t.ref) },
|
|
1237
|
+
{
|
|
1238
|
+
label: 'spec not complete — push still owns its state',
|
|
1239
|
+
items: parts.unfinished.map((t) => `${t.ref} (${t.bucket})`),
|
|
1240
|
+
},
|
|
1241
|
+
{ label: 'could not be read from Linear', items: unreadable.map((t) => t.ref) },
|
|
1242
|
+
]) {
|
|
1243
|
+
if (items.length) lines.push(` skipped ${items.length} — ${label}: ${items.join(', ')}`)
|
|
1244
|
+
}
|
|
1245
|
+
for (const f of failed) lines.push(` FAILED ${f.ref}: ${f.error}`)
|
|
1246
|
+
lines.push('')
|
|
1247
|
+
// Said even when zero, for the reason `released` says it: a chore commit
|
|
1248
|
+
// legitimately carries no ref and a MISSED trailer looks identical, so silence
|
|
1249
|
+
// would read as "every commit is accounted for".
|
|
1250
|
+
lines.push(` ${report.unreferenced} commit(s) carry no ref, of ${report.total}`)
|
|
1251
|
+
if (!applying) lines.push(' dry run — pass --apply to move them')
|
|
1252
|
+
out.write(lines.join('\n') + '\n')
|
|
1253
|
+
return failed.length ? 1 : 0
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* `spec-sync ref [<spec>] [--json]` — the ticket a commit's work belongs to.
|
|
1258
|
+
*
|
|
1259
|
+
* Exists so neither a person nor a model has to go spelunking for the id when
|
|
1260
|
+
* writing a commit: `Refs: $(spec-sync ref)`. With a fast-forward-only history
|
|
1261
|
+
* the commit message is the ONLY place a ticket survives into the range a
|
|
1262
|
+
* release scans — branch names never reach it.
|
|
1263
|
+
*
|
|
1264
|
+
* **Bare, it answers from the branch**, which is the same thing as "the ticket
|
|
1265
|
+
* this commit belongs to" only while you are committing that branch's own
|
|
1266
|
+
* implementation work. `/spec` requires no particular branch, so authoring a
|
|
1267
|
+
* backlog spec part-way through another spec makes the two diverge: the commit
|
|
1268
|
+
* is entirely the new spec's and the branch answer is a wrong ref stamped on it.
|
|
1269
|
+
* **Naming the spec resolves it directly** — the branch is then not consulted at
|
|
1270
|
+
* all, so it also works from `main`.
|
|
1271
|
+
*
|
|
1272
|
+
* The default deliberately stays branch-derived rather than inferring the spec
|
|
1273
|
+
* from staged paths. Inference would change what gets stamped based on a lookup
|
|
1274
|
+
* that `git commit -a`, partial staging, or a spec touching shared code each
|
|
1275
|
+
* blind — silently, and in the destructive direction (a plausible wrong ref
|
|
1276
|
+
* beats no ref for looking correct). An explicit override cannot be blinded, and
|
|
1277
|
+
* needs no answer for mixed staging.
|
|
1278
|
+
*
|
|
1279
|
+
* The branch→spec direction is the INVERSE of what `/spec-go` provisions with,
|
|
1280
|
+
* so it is computed by running `branchFor` over each spec and matching, rather
|
|
1281
|
+
* than by re-deriving the pattern here. A second implementation of the naming
|
|
1282
|
+
* rule would drift from the one that created the branch.
|
|
1283
|
+
*
|
|
1284
|
+
* **Every no-ref case prints nothing on stdout** and exits non-zero. A commit on
|
|
1285
|
+
* `main`, or on a spec kept deliberately local, has no ticket — and a command
|
|
1286
|
+
* that wrote an error message to stdout would see a shell splice it straight
|
|
1287
|
+
* into the commit body via `$(…)`. That contract covers the named form too: an
|
|
1288
|
+
* unknown spec name must FAIL rather than quietly falling back to the branch,
|
|
1289
|
+
* which would turn a typo into a confidently wrong ref.
|
|
1290
|
+
*/
|
|
1291
|
+
function specSyncRef(dir, config, specArg, flags, out, err) {
|
|
1292
|
+
const say = (msg) => err.write(`spec-sync ref: ${msg}\n`)
|
|
1293
|
+
|
|
1294
|
+
// Shared tail: an unlinked spec is a no-ref case like any other, so it keeps
|
|
1295
|
+
// stdout empty whichever form asked.
|
|
1296
|
+
const emitRef = (spec, branch) => {
|
|
1297
|
+
const identifier = linkedIdentifier(path.join(spec.path, (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'))
|
|
1298
|
+
if (!identifier) {
|
|
1299
|
+
say(`${spec.folder} is not linked to Linear — /spec-push to mirror it`)
|
|
1300
|
+
return 1
|
|
1301
|
+
}
|
|
1302
|
+
out.write(flags.json ? JSON.stringify({ ref: identifier, spec: spec.folder, branch }, null, 2) + '\n' : `${identifier}\n`)
|
|
1303
|
+
return 0
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
const git = (argv) => {
|
|
1307
|
+
try {
|
|
1308
|
+
return execFileSync('git', ['-C', dir, ...argv], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
|
|
1309
|
+
} catch {
|
|
1310
|
+
return null
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
const branch = currentBranch(git)
|
|
1314
|
+
|
|
1315
|
+
// The named form answers about the SPEC, so a missing branch is irrelevant to
|
|
1316
|
+
// it — resolve before the branch is required at all.
|
|
1317
|
+
if (specArg) {
|
|
1318
|
+
const named = findSpecFolder(specArg, dir)
|
|
1319
|
+
if (!named) {
|
|
1320
|
+
say(`no spec folder named "${specArg}" in specs/ — check the name`)
|
|
1321
|
+
return 1
|
|
1322
|
+
}
|
|
1323
|
+
return emitRef(named, branch || null)
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
if (!branch) {
|
|
1327
|
+
say('not on a git branch (detached HEAD, or not a git repository)')
|
|
1328
|
+
return 1
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
const env = loadEnvConfig(dir).config
|
|
1332
|
+
let match = null
|
|
1333
|
+
for (const bucket of BUCKETS) {
|
|
1334
|
+
let entries
|
|
1335
|
+
try {
|
|
1336
|
+
entries = fs.readdirSync(path.join(dir, 'specs', bucket), { withFileTypes: true })
|
|
1337
|
+
} catch {
|
|
1338
|
+
continue
|
|
1339
|
+
}
|
|
1340
|
+
for (const entry of entries) {
|
|
1341
|
+
if (!entry.isDirectory()) continue
|
|
1342
|
+
const specPath = path.join(dir, 'specs', bucket, entry.name)
|
|
1343
|
+
const { type, slug } = splitPrefix(entry.name)
|
|
1344
|
+
if (branchFor({ type, slug, path: specPath }, env) === branch) {
|
|
1345
|
+
match = { folder: entry.name, bucket, path: specPath }
|
|
1346
|
+
break
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
if (match) break
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
if (!match) {
|
|
1353
|
+
say(`branch "${branch}" is not a spec branch — no ticket to reference`)
|
|
1354
|
+
return 1
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
return emitRef(match, branch)
|
|
1358
|
+
}
|
|
1359
|
+
|
|
863
1360
|
/**
|
|
864
1361
|
* `spec-sync retarget [--yes]` — repoint a mirror after a team-key rename.
|
|
865
1362
|
*
|
|
@@ -1077,13 +1574,41 @@ async function specSyncStates(dir, config, flags, out) {
|
|
|
1077
1574
|
}
|
|
1078
1575
|
if (flags.json) {
|
|
1079
1576
|
// The bare array `--workspace-states` takes, so this can be piped into it.
|
|
1577
|
+
// Deliberately NOT widened to carry the configured vocabulary: every caller
|
|
1578
|
+
// pipes this straight into that flag, and an object here would break them.
|
|
1080
1579
|
out.write(JSON.stringify(names, null, 2) + '\n')
|
|
1081
1580
|
return 0
|
|
1082
1581
|
}
|
|
1083
|
-
|
|
1582
|
+
const lines = ['spec-sync states: transport = api', ` workspace: ${names.join(', ')}`]
|
|
1583
|
+
lines.push(...configuredVocabularyLines(config, names))
|
|
1584
|
+
out.write(lines.join('\n') + '\n')
|
|
1084
1585
|
return 0
|
|
1085
1586
|
}
|
|
1086
1587
|
|
|
1588
|
+
// The state names this project's config points at, shown against what the
|
|
1589
|
+
// workspace actually has — the bucket map, then the deployment ladder in its
|
|
1590
|
+
// declared order. One command then shows the whole vocabulary, instead of the
|
|
1591
|
+
// ladder living only in a file nobody re-reads.
|
|
1592
|
+
//
|
|
1593
|
+
// A name the workspace lacks is flagged here but nothing is refused: `states`
|
|
1594
|
+
// is a report. The push path is where that is fatal.
|
|
1595
|
+
function configuredVocabularyLines(config, names) {
|
|
1596
|
+
const have = new Set((names || []).map((n) => String(n).toLowerCase().trim()))
|
|
1597
|
+
const mark = (name) => (have.has(String(name).toLowerCase().trim()) ? '' : ' <- not in the workspace')
|
|
1598
|
+
const lines = ['', ' configured:']
|
|
1599
|
+
for (const [bucket, name] of Object.entries((config && config.states) || {})) {
|
|
1600
|
+
lines.push(` states.${bucket}: ${name}${mark(name)}`)
|
|
1601
|
+
}
|
|
1602
|
+
const stages = releaseStages(config)
|
|
1603
|
+
if (stages.length) {
|
|
1604
|
+
lines.push(` release.stages: ${stages.length} rung(s), in order`)
|
|
1605
|
+
stages.forEach((stage, i) => {
|
|
1606
|
+
lines.push(` ${i + 1}. ${stage.key} -> ${stage.state}${mark(stage.state)}`)
|
|
1607
|
+
})
|
|
1608
|
+
}
|
|
1609
|
+
return lines
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1087
1612
|
/**
|
|
1088
1613
|
* `spec-sync apply <spec> --plan <file> [--via api|mcp] [--project <id>]`
|
|
1089
1614
|
*
|
|
@@ -1556,6 +2081,10 @@ function specSyncInitConfig(dir, flags, out) {
|
|
|
1556
2081
|
if (flags.hotfixLabels.length) intake.hotfixLabels = flags.hotfixLabels
|
|
1557
2082
|
if (Object.keys(intake).length) draft.intake = intake
|
|
1558
2083
|
if (Object.keys(flags.stateNames).length) draft.states = { ...flags.stateNames }
|
|
2084
|
+
// The ladder, in the order given. `mergeConfig` below is what validates the
|
|
2085
|
+
// shape — a blank key or a duplicate fails there, in one place, rather than
|
|
2086
|
+
// being re-checked here and drifting from the loader.
|
|
2087
|
+
if (flags.stages.length) draft.release = { stages: flags.stages }
|
|
1559
2088
|
|
|
1560
2089
|
for (const bucket of Object.keys(flags.stateNames)) {
|
|
1561
2090
|
if (!LIFECYCLE_BUCKETS.includes(bucket)) {
|
|
@@ -1595,9 +2124,11 @@ function specSyncInitConfig(dir, flags, out) {
|
|
|
1595
2124
|
const missing = validateStates(effective, workspace)
|
|
1596
2125
|
if (missing.length) {
|
|
1597
2126
|
const lines = ['spec-sync init-config: refusing — configured state name(s) not in the workspace', '']
|
|
1598
|
-
for (const { bucket, configured, suggestion } of stateSuggestions(effective, workspace)) {
|
|
1599
|
-
lines.push(`
|
|
1600
|
-
|
|
2127
|
+
for (const { bucket, label, configured, suggestion } of stateSuggestions(effective, workspace)) {
|
|
2128
|
+
lines.push(` ${label}: "${configured}" is not an issue state in this workspace`)
|
|
2129
|
+
// `--state <bucket>=<name>` only addresses the bucket map; a ladder rung
|
|
2130
|
+
// is edited in the config, and never carries a suggestion anyway.
|
|
2131
|
+
if (suggestion && bucket) lines.push(` pass --state ${bucket}="${suggestion}"`)
|
|
1601
2132
|
}
|
|
1602
2133
|
lines.push(
|
|
1603
2134
|
'',
|
|
@@ -1910,10 +2441,13 @@ async function specSync(rest, io = {}) {
|
|
|
1910
2441
|
// after the loop.
|
|
1911
2442
|
const unknownFlags = []
|
|
1912
2443
|
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,
|
|
1913
|
-
mcp: null, force: false, yes: false, remoteCheck: false, teamId: '', teamKey: '', projectId: '', intakeLabel: '', bugLabels: [], hotfixLabels: [], stateNames: {}, statesFile: null }
|
|
2444
|
+
mcp: null, force: false, yes: false, apply: false, remoteCheck: false, teamId: '', teamKey: '', projectId: '', intakeLabel: '', bugLabels: [], hotfixLabels: [], stateNames: {}, statesFile: null, stages: [] }
|
|
1914
2445
|
for (let i = 0; i < args.length; i++) {
|
|
1915
2446
|
if (args[i] === '--dir') dir = path.resolve(args[++i])
|
|
1916
2447
|
else if (args[i] === '--json') flags.json = true
|
|
2448
|
+
// `stage` writes only on --apply: the dry run is the default so a wrong
|
|
2449
|
+
// range is seen before it is acted on, not after.
|
|
2450
|
+
else if (args[i] === '--apply') flags.apply = true
|
|
1917
2451
|
else if (args[i] === '--remote') flags.remote = path.resolve(args[++i])
|
|
1918
2452
|
else if (args[i] === '--stored') flags.stored = path.resolve(args[++i])
|
|
1919
2453
|
else if (args[i] === '--mcp') flags.mcp = path.resolve(args[++i])
|
|
@@ -1950,6 +2484,11 @@ async function specSync(rest, io = {}) {
|
|
|
1950
2484
|
// actually renamed get written; the rest keep the defaults.
|
|
1951
2485
|
const [bucket, ...rest] = String(args[++i] || '').split('=')
|
|
1952
2486
|
flags.stateNames[String(bucket).trim()] = rest.join('=').trim()
|
|
2487
|
+
} else if (args[i] === '--stage') {
|
|
2488
|
+
// `--stage test="On Test"`, repeatable. Order matters and is the order
|
|
2489
|
+
// given, so the ladder is written exactly as the operator listed it.
|
|
2490
|
+
const [key, ...rest] = String(args[++i] || '').split('=')
|
|
2491
|
+
flags.stages.push({ key: String(key).trim(), state: rest.join('=').trim() })
|
|
1953
2492
|
} else if (args[i] === '--states') flags.statesFile = path.resolve(args[++i])
|
|
1954
2493
|
else if (args[i].startsWith('--')) unknownFlags.push(args[i])
|
|
1955
2494
|
else positional.push(args[i])
|
|
@@ -2025,6 +2564,12 @@ async function specSync(rest, io = {}) {
|
|
|
2025
2564
|
return (await specSyncProjects(dir, config, flags, out)) || 0
|
|
2026
2565
|
case 'states':
|
|
2027
2566
|
return (await specSyncStates(dir, config, flags, out)) || 0
|
|
2567
|
+
case 'released':
|
|
2568
|
+
return (await specSyncReleased(dir, config, positional[0], flags, out)) || 0
|
|
2569
|
+
case 'stage':
|
|
2570
|
+
return (await specSyncStage(dir, config, positional[0], positional[1], flags, out)) || 0
|
|
2571
|
+
case 'ref':
|
|
2572
|
+
return specSyncRef(dir, config, positional[0], flags, out, err) || 0
|
|
2028
2573
|
case 'retarget':
|
|
2029
2574
|
return (await specSyncRetarget(dir, config, flags, out)) || 0
|
|
2030
2575
|
case 'apply':
|
|
@@ -2047,6 +2592,9 @@ async function specSync(rest, io = {}) {
|
|
|
2047
2592
|
' skitterspec spec-sync apply --all <bucket> [--via api|mcp] [--json]\n' +
|
|
2048
2593
|
' skitterspec spec-sync verify <spec> --stored <file>\n' +
|
|
2049
2594
|
' skitterspec spec-sync linked [--json]\n' +
|
|
2595
|
+
' skitterspec spec-sync ref [<spec>] [--json]\n' +
|
|
2596
|
+
' skitterspec spec-sync released [<range>] [--json]\n' +
|
|
2597
|
+
' skitterspec spec-sync stage <key> [<range>] [--apply] [--json]\n' +
|
|
2050
2598
|
' skitterspec spec-sync retarget [--yes]\n' +
|
|
2051
2599
|
' skitterspec spec-sync doctor [--check-remote] [--mcp <file>] [--json]\n' +
|
|
2052
2600
|
' skitterspec spec-sync init-config --team-id <id> [--team-key K] [--project-id id]\n' +
|