@skitterbyte/skitterspec-linear 3.1.0 → 4.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/README.md +15 -8
- package/assets/core/SETUP.md +175 -0
- package/assets/core/linear.config.json.example +2 -5
- package/assets/core/linear.config.md +44 -6
- package/assets/skills/spec-bug/SKILL.md +55 -8
- package/assets/skills/spec-go/SKILL.md +6 -0
- package/assets/skills/spec-pull/SKILL.md +4 -1
- package/assets/skills/spec-push/SKILL.md +21 -0
- package/package.json +1 -1
- package/src/vendor/linear/cli-sync.js +32 -1
- package/src/vendor/linear/config.js +28 -4
- package/src/vendor/linear/mcp.js +38 -12
- package/src/vendor/sync-core/src/compare.js +116 -0
- package/src/vendor/sync-core/src/normalize.js +144 -17
- package/src/vendor/sync-core/src/pull.js +47 -16
- package/src/vendor/sync-core/src/push.js +51 -10
- package/src/vendor/sync-core/src/write.js +247 -0
|
@@ -52,6 +52,94 @@ function rawStatus(localH, remoteH, baseH) {
|
|
|
52
52
|
return 'conflict'
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// --- keyed collections (per-item three-way) --------------------------------
|
|
56
|
+
|
|
57
|
+
// Index an array of items by their `idKey` value (stringified). Non-arrays and
|
|
58
|
+
// items missing the id are skipped — they can't participate in a keyed merge.
|
|
59
|
+
function indexById(arr, idKey) {
|
|
60
|
+
const map = new Map()
|
|
61
|
+
if (Array.isArray(arr)) {
|
|
62
|
+
for (const item of arr) {
|
|
63
|
+
if (item && typeof item === 'object' && item[idKey] != null) {
|
|
64
|
+
map.set(String(item[idKey]), item)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return map
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Signature of an item under a given id: its content hash with the id stripped
|
|
72
|
+
// (so an id-only change / reorder is not a content edit), or 'ABSENT' when the
|
|
73
|
+
// id isn't present on that side. 'ABSENT' never collides with a hex hash.
|
|
74
|
+
function itemSignature(map, id, idKey) {
|
|
75
|
+
if (!map.has(id)) return 'ABSENT'
|
|
76
|
+
const { [idKey]: _omit, ...content } = map.get(id)
|
|
77
|
+
return hashField(content)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Raw per-item three-way: which side moved off base, and how (added/edited/
|
|
81
|
+
// removed). Mirrors rawStatus but tracks presence so add vs edit vs remove is
|
|
82
|
+
// distinguishable.
|
|
83
|
+
function itemRaw(localSig, remoteSig, baseSig) {
|
|
84
|
+
const localChanged = localSig !== baseSig
|
|
85
|
+
const remoteChanged = remoteSig !== baseSig
|
|
86
|
+
if (!localChanged && !remoteChanged) return { raw: 'unchanged', side: null }
|
|
87
|
+
const kind = (sig, base) => (base === 'ABSENT' ? 'added' : sig === 'ABSENT' ? 'removed' : 'edited')
|
|
88
|
+
if (localChanged && !remoteChanged) return { raw: kind(localSig, baseSig), side: 'local' }
|
|
89
|
+
if (!localChanged && remoteChanged) return { raw: kind(remoteSig, baseSig), side: 'remote' }
|
|
90
|
+
if (localSig === remoteSig) return { raw: 'unchanged', side: null } // both converged
|
|
91
|
+
return { raw: 'conflict', side: null }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Collapse a raw per-item outcome through ownership into an effective status +
|
|
95
|
+
// push/pull flags. Removals are report-only in v1 (surfaced, never auto-applied).
|
|
96
|
+
function collapseItem({ raw, side }, ownership) {
|
|
97
|
+
const canPush = ownership === 'both' || ownership === 'push'
|
|
98
|
+
const canPull = ownership === 'both' || ownership === 'pull'
|
|
99
|
+
if (raw === 'unchanged') return { status: 'unchanged', side: null, pushable: false, pullable: false, report: false }
|
|
100
|
+
if (raw === 'removed') return { status: 'removed', side, pushable: false, pullable: false, report: true }
|
|
101
|
+
if (raw === 'conflict') {
|
|
102
|
+
if (ownership === 'push') return { status: 'edited', side: 'local', pushable: true, pullable: false, report: false }
|
|
103
|
+
if (ownership === 'pull') return { status: 'edited', side: 'remote', pushable: false, pullable: true, report: false }
|
|
104
|
+
return { status: 'conflict', side: null, pushable: true, pullable: true, report: false }
|
|
105
|
+
}
|
|
106
|
+
// added / edited
|
|
107
|
+
if (side === 'local') return { status: raw, side, pushable: canPush, pullable: false, report: false }
|
|
108
|
+
return { status: raw, side, pushable: false, pullable: canPull, report: false }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Classify a keyed collection field item-by-item. Returns one entry per id seen
|
|
113
|
+
* across local/remote/base, each with its effective status and push/pull flags,
|
|
114
|
+
* plus the local/remote item values so a caller can apply the change.
|
|
115
|
+
*/
|
|
116
|
+
function classifyItems(local, remote, base, ownership, idKey) {
|
|
117
|
+
const lMap = indexById(local, idKey)
|
|
118
|
+
const rMap = indexById(remote, idKey)
|
|
119
|
+
const bMap = indexById(base, idKey)
|
|
120
|
+
const ids = new Set([...lMap.keys(), ...rMap.keys(), ...bMap.keys()])
|
|
121
|
+
const items = []
|
|
122
|
+
for (const id of ids) {
|
|
123
|
+
const raw = itemRaw(
|
|
124
|
+
itemSignature(lMap, id, idKey),
|
|
125
|
+
itemSignature(rMap, id, idKey),
|
|
126
|
+
itemSignature(bMap, id, idKey),
|
|
127
|
+
)
|
|
128
|
+
const c = collapseItem(raw, ownership)
|
|
129
|
+
items.push({
|
|
130
|
+
id,
|
|
131
|
+
status: c.status,
|
|
132
|
+
side: c.side,
|
|
133
|
+
pushable: c.pushable,
|
|
134
|
+
pullable: c.pullable,
|
|
135
|
+
report: c.report,
|
|
136
|
+
local: lMap.get(id) || null,
|
|
137
|
+
remote: rMap.get(id) || null,
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
return items
|
|
141
|
+
}
|
|
142
|
+
|
|
55
143
|
// Collapse the raw status through ownership into an effective status + flags.
|
|
56
144
|
function collapse(raw, ownership) {
|
|
57
145
|
const canPush = ownership === 'both' || ownership === 'push'
|
|
@@ -78,9 +166,36 @@ function collapse(raw, ownership) {
|
|
|
78
166
|
*/
|
|
79
167
|
function classify(local, remote, base, config) {
|
|
80
168
|
const ownership = config.sync.fieldOwnership
|
|
169
|
+
const keyed = (config.sync && config.sync.keyedFields) || {}
|
|
81
170
|
const baseObj = base || {}
|
|
82
171
|
return Object.keys(ownership).map((field) => {
|
|
83
172
|
const own = ownership[field]
|
|
173
|
+
const idKey = keyed[field]
|
|
174
|
+
|
|
175
|
+
// Keyed collection: per-item three-way. Field-level flags aggregate the
|
|
176
|
+
// items so existing summary code still sees "does this field have work?".
|
|
177
|
+
if (idKey) {
|
|
178
|
+
const items = classifyItems(
|
|
179
|
+
local ? local[field] : null,
|
|
180
|
+
remote ? remote[field] : null,
|
|
181
|
+
field in baseObj ? baseObj[field] : null,
|
|
182
|
+
own,
|
|
183
|
+
idKey,
|
|
184
|
+
)
|
|
185
|
+
const active = items.filter((i) => i.status !== 'unchanged')
|
|
186
|
+
return {
|
|
187
|
+
field,
|
|
188
|
+
ownership: own,
|
|
189
|
+
keyed: true,
|
|
190
|
+
idKey,
|
|
191
|
+
items,
|
|
192
|
+
status: active.length ? 'items-changed' : 'unchanged',
|
|
193
|
+
pushable: items.some((i) => i.pushable),
|
|
194
|
+
pullable: items.some((i) => i.pullable),
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Scalar / whole-field (behaviour unchanged).
|
|
84
199
|
const localH = hashField(local ? local[field] : null)
|
|
85
200
|
const remoteH = hashField(remote ? remote[field] : null)
|
|
86
201
|
const baseH = hashField(field in baseObj ? baseObj[field] : null)
|
|
@@ -92,6 +207,7 @@ function classify(local, remote, base, config) {
|
|
|
92
207
|
|
|
93
208
|
module.exports = {
|
|
94
209
|
classify,
|
|
210
|
+
classifyItems,
|
|
95
211
|
hashField,
|
|
96
212
|
stableStringify,
|
|
97
213
|
rawStatus,
|
|
@@ -82,6 +82,25 @@ function parseSections(body) {
|
|
|
82
82
|
return { title, sections }
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
// Canonicalise markdown so semantically-equal content hashes equal across the
|
|
86
|
+
// boundary. Linear reserializes markdown on save (authored `-` bullets come back
|
|
87
|
+
// as `*`, trailing whitespace trimmed, blank runs collapsed), so without this a
|
|
88
|
+
// clean push→pull would report `description` as perpetually changed. Applied to
|
|
89
|
+
// the description on BOTH sides. Conservative: only unifies list markers and
|
|
90
|
+
// whitespace — the transforms actually observed from Linear.
|
|
91
|
+
function canonicalizeMarkdown(text) {
|
|
92
|
+
if (text == null) return text
|
|
93
|
+
return String(text)
|
|
94
|
+
.replace(/\r\n/g, '\n')
|
|
95
|
+
.split('\n')
|
|
96
|
+
// Unordered-list marker at line start (`*`/`+`/`-`) → `-`. Requires a space
|
|
97
|
+
// after the marker so bold/emphasis (`**Goal:**`) is untouched.
|
|
98
|
+
.map((line) => line.replace(/^(\s*)[*+-]( +)/, '$1-$2').replace(/[ \t]+$/, ''))
|
|
99
|
+
.join('\n')
|
|
100
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
101
|
+
.trim()
|
|
102
|
+
}
|
|
103
|
+
|
|
85
104
|
// Canonical milestone status from the phase-index emoji.
|
|
86
105
|
const EMOJI_STATUS = { '⬜': 'not-started', '🔄': 'in-progress', '✅': 'done' }
|
|
87
106
|
|
|
@@ -102,7 +121,37 @@ function parsePhaseIndex(phasesSection) {
|
|
|
102
121
|
return rows
|
|
103
122
|
}
|
|
104
123
|
|
|
105
|
-
//
|
|
124
|
+
// The phase's descriptive title: the h1 with the "Phase N — " prefix and any
|
|
125
|
+
// trailing status emoji stripped (so it matches its Linear Milestone name).
|
|
126
|
+
function phaseTitle(body) {
|
|
127
|
+
const h1 = /^#\s+(.*)$/m.exec(body)
|
|
128
|
+
if (!h1) return null
|
|
129
|
+
const t = h1[1]
|
|
130
|
+
.replace(/\s*[⬜🔄✅]\s*$/u, '')
|
|
131
|
+
.replace(/^Phase\s+\d+\s*[—–-]\s*/i, '')
|
|
132
|
+
.trim()
|
|
133
|
+
return t || null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Parse a task line (already stripped of its leading "- ") into a keyed item:
|
|
137
|
+
// its checkbox state, its text, and the inline Linear issue identifier if present
|
|
138
|
+
// (`… (SKI-123)`). Returns null for a non-task line.
|
|
139
|
+
function parseTaskLine(line) {
|
|
140
|
+
const m = /^\[([ xX])\]\s*(.*)$/.exec(line)
|
|
141
|
+
if (!m) return null
|
|
142
|
+
const done = m[1].toLowerCase() === 'x'
|
|
143
|
+
let text = m[2].trim()
|
|
144
|
+
let id = null
|
|
145
|
+
const idm = /\s*\(([A-Za-z][A-Za-z0-9]*-\d+)\)\s*$/.exec(text)
|
|
146
|
+
if (idm) {
|
|
147
|
+
id = idm[1]
|
|
148
|
+
text = text.slice(0, idm.index).trim()
|
|
149
|
+
}
|
|
150
|
+
return { id, text, done }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Read the phase files (01-*.md, 02-*.md …) in execution order. Each yields its
|
|
154
|
+
// linked milestone id (from optional frontmatter), title, goal and tasks.
|
|
106
155
|
function readPhaseFiles(snapshotDir) {
|
|
107
156
|
let entries
|
|
108
157
|
try {
|
|
@@ -115,11 +164,19 @@ function readPhaseFiles(snapshotDir) {
|
|
|
115
164
|
.sort()
|
|
116
165
|
.map((file) => {
|
|
117
166
|
const raw = fs.readFileSync(path.join(snapshotDir, file), 'utf-8')
|
|
118
|
-
const
|
|
119
|
-
const
|
|
167
|
+
const { data, body } = parseFrontmatter(raw)
|
|
168
|
+
const goal = (/^\*\*Goal:\*\*\s*([\s\S]*?)(?:\n\n|$)/m.exec(body) || [])[1] || ''
|
|
169
|
+
const tasks = (body.match(/^-\s*\[[ x]\]\s*.*$/gm) || []).map((t) =>
|
|
120
170
|
t.replace(/^-\s*/, '').trim(),
|
|
121
171
|
)
|
|
122
|
-
return {
|
|
172
|
+
return {
|
|
173
|
+
phase: file.replace(/\.md$/, ''),
|
|
174
|
+
file,
|
|
175
|
+
id: data.linear_milestone_id != null ? String(data.linear_milestone_id) : null,
|
|
176
|
+
name: phaseTitle(body),
|
|
177
|
+
goal: goal.trim(),
|
|
178
|
+
tasks,
|
|
179
|
+
}
|
|
123
180
|
})
|
|
124
181
|
}
|
|
125
182
|
|
|
@@ -151,16 +208,18 @@ function readSnapshot(snapshotDir, config) {
|
|
|
151
208
|
}
|
|
152
209
|
|
|
153
210
|
// Build the pushed description: the overview prose with local-only sections
|
|
154
|
-
// removed. Keeps the title line for context.
|
|
155
|
-
|
|
156
|
-
|
|
211
|
+
// removed. Keeps the title line for context. `extraSkip` drops additional
|
|
212
|
+
// sections (e.g. "Phases" once milestones sync as first-class Linear objects, so
|
|
213
|
+
// the phase list isn't duplicated in the description — Decision 5).
|
|
214
|
+
function buildDescription(title, sections, localOnlySections, extraSkip = []) {
|
|
215
|
+
const skip = new Set([...(localOnlySections || []), ...extraSkip])
|
|
157
216
|
const parts = []
|
|
158
217
|
if (title) parts.push(`# ${title}`)
|
|
159
218
|
for (const [heading, content] of Object.entries(sections)) {
|
|
160
219
|
if (skip.has(heading)) continue
|
|
161
220
|
parts.push(`## ${heading}\n\n${content}`.trim())
|
|
162
221
|
}
|
|
163
|
-
return parts.join('\n\n')
|
|
222
|
+
return canonicalizeMarkdown(parts.join('\n\n')) || null
|
|
164
223
|
}
|
|
165
224
|
|
|
166
225
|
/**
|
|
@@ -168,9 +227,24 @@ function buildDescription(title, sections, localOnlySections) {
|
|
|
168
227
|
*/
|
|
169
228
|
function normalizeLocal(snapshotDir, config) {
|
|
170
229
|
const { frontmatter, title, sections, phases } = readSnapshot(snapshotDir, config)
|
|
230
|
+
const milestonesKeyed = !!(config.sync.keyedFields && config.sync.keyedFields.milestones)
|
|
171
231
|
const extracted = {
|
|
172
|
-
description: buildDescription(
|
|
173
|
-
|
|
232
|
+
description: buildDescription(
|
|
233
|
+
title,
|
|
234
|
+
sections,
|
|
235
|
+
config.sync.localOnlySections,
|
|
236
|
+
milestonesKeyed ? ['Phases'] : [],
|
|
237
|
+
),
|
|
238
|
+
// Keyed milestone items: a phase's linked milestone id (or null when
|
|
239
|
+
// unlinked), its title and goal. Unlinked phases carry id:null and are
|
|
240
|
+
// skipped by the keyed compare until they're linked.
|
|
241
|
+
milestones: phases
|
|
242
|
+
.filter((p) => p.name)
|
|
243
|
+
.map((p) => ({ id: p.id, name: p.name, goal: p.goal })),
|
|
244
|
+
// Keyed task items across all phases: inline issue id (or null), text, done.
|
|
245
|
+
// Deliberately just {id,text,done} so it hashes equal to a remote issue — the
|
|
246
|
+
// owning phase is recovered at push time by locating the task line.
|
|
247
|
+
tasks: phases.flatMap((p) => p.tasks.map(parseTaskLine).filter(Boolean)),
|
|
174
248
|
phaseBodies: phases.map((p) => ({ phase: p.phase, goal: p.goal })),
|
|
175
249
|
acceptanceCriteria: sections['Acceptance criteria'] || null,
|
|
176
250
|
taskBreakdown: phases.map((p) => ({ phase: p.phase, tasks: p.tasks })),
|
|
@@ -208,31 +282,82 @@ function canonicalRemoteStatus(state) {
|
|
|
208
282
|
return s
|
|
209
283
|
}
|
|
210
284
|
|
|
285
|
+
// The real Linear projection carries the project's workflow state in `status`
|
|
286
|
+
// (an object `{ name, type }`); accept a bare string / legacy `state` too.
|
|
287
|
+
function remoteStateName(project) {
|
|
288
|
+
const st = project.status != null ? project.status : project.state
|
|
289
|
+
if (st == null) return null
|
|
290
|
+
if (typeof st === 'object') return st.name != null ? st.name : st.type != null ? st.type : null
|
|
291
|
+
return st
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Real Linear priority is an object `{ value, name }`; accept a bare number too.
|
|
295
|
+
function remotePriority(priority) {
|
|
296
|
+
if (priority == null) return null
|
|
297
|
+
if (typeof priority === 'object') return priority.value != null ? priority.value : null
|
|
298
|
+
return priority
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Real Linear labels are `[{ id, name }]`; accept bare strings too.
|
|
302
|
+
function remoteLabels(labels) {
|
|
303
|
+
if (!Array.isArray(labels)) return []
|
|
304
|
+
return labels
|
|
305
|
+
.map((l) => (typeof l === 'string' ? l : l && l.name != null ? l.name : null))
|
|
306
|
+
.filter((n) => n != null)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Whether a remote Linear issue is complete. The real MCP shape is a flat
|
|
310
|
+
// `statusType` ("completed") — accept the legacy `state.type` and `completedAt`
|
|
311
|
+
// / bare `done` too (fixtures, older shapes).
|
|
312
|
+
function remoteIssueDone(iss) {
|
|
313
|
+
if (iss.done === true) return true
|
|
314
|
+
const type = iss.statusType != null ? iss.statusType : iss.state && iss.state.type
|
|
315
|
+
if (type != null) return String(type).toLowerCase() === 'completed'
|
|
316
|
+
return iss.completedAt != null
|
|
317
|
+
}
|
|
318
|
+
|
|
211
319
|
/**
|
|
212
|
-
* Normalize a remote Project projection (from the
|
|
213
|
-
*
|
|
320
|
+
* Normalize a remote Project projection (from the MCP adapter, or a fixture)
|
|
321
|
+
* into the same field set as `normalizeLocal`.
|
|
214
322
|
*/
|
|
215
323
|
function normalizeRemote(project, config) {
|
|
216
324
|
const p = project || {}
|
|
217
325
|
const milestones = Array.isArray(p.milestones) ? p.milestones : []
|
|
326
|
+
const stateName = remoteStateName(p)
|
|
218
327
|
const extracted = {
|
|
219
|
-
description: p.description != null ? p.description : null,
|
|
328
|
+
description: p.description != null ? canonicalizeMarkdown(p.description) : null,
|
|
329
|
+
// Keyed milestone items mirroring normalizeLocal: id, title, goal (the Linear
|
|
330
|
+
// milestone's description). Progress is Linear-derived and not synced.
|
|
220
331
|
milestones: milestones.map((m) => ({
|
|
332
|
+
id: m.id != null ? String(m.id) : null,
|
|
221
333
|
name: m.name,
|
|
222
|
-
|
|
334
|
+
// A milestone's description mirrors the phase's `**Goal:**` line; strip the
|
|
335
|
+
// label so it hashes equal to the local goal (which readPhaseFiles already
|
|
336
|
+
// captures without it).
|
|
337
|
+
goal: (m.description != null ? m.description : '')
|
|
338
|
+
.replace(/^\s*\*\*Goal:\*\*\s*/, '')
|
|
339
|
+
.trim(),
|
|
223
340
|
})),
|
|
224
341
|
phaseBodies: milestones.map((m) => ({
|
|
225
342
|
phase: m.name,
|
|
226
343
|
goal: (m.description != null ? m.description : '').trim(),
|
|
227
344
|
})),
|
|
228
345
|
acceptanceCriteria: p.acceptanceCriteria != null ? p.acceptanceCriteria : null,
|
|
346
|
+
// Keyed task items from the project's issues: keyed by the human identifier
|
|
347
|
+
// (SKI-123, what the inline task-line id carries — the Linear MCP returns it as
|
|
348
|
+
// the issue's `id`), text ← title, done ← a completed-type workflow state.
|
|
349
|
+
tasks: (Array.isArray(p.issues) ? p.issues : []).map((iss) => ({
|
|
350
|
+
id: iss.identifier != null ? String(iss.identifier) : iss.id != null ? String(iss.id) : null,
|
|
351
|
+
text: iss.title != null ? iss.title : '',
|
|
352
|
+
done: remoteIssueDone(iss),
|
|
353
|
+
})),
|
|
229
354
|
taskBreakdown: milestones.map((m) => ({
|
|
230
355
|
phase: m.name,
|
|
231
356
|
tasks: Array.isArray(m.tasks) ? m.tasks : [],
|
|
232
357
|
})),
|
|
233
|
-
workflowState:
|
|
234
|
-
priority: p.priority
|
|
235
|
-
labels:
|
|
358
|
+
workflowState: stateName != null ? bucketForState(stateName, config) : null,
|
|
359
|
+
priority: remotePriority(p.priority),
|
|
360
|
+
labels: remoteLabels(p.labels),
|
|
236
361
|
}
|
|
237
362
|
return toFieldSet(extracted, config)
|
|
238
363
|
}
|
|
@@ -244,6 +369,8 @@ module.exports = {
|
|
|
244
369
|
parseFrontmatter,
|
|
245
370
|
parseSections,
|
|
246
371
|
parsePhaseIndex,
|
|
372
|
+
parseTaskLine,
|
|
247
373
|
canonicalRemoteStatus,
|
|
374
|
+
canonicalizeMarkdown,
|
|
248
375
|
bucketForState,
|
|
249
376
|
}
|
|
@@ -18,9 +18,23 @@
|
|
|
18
18
|
const { normalizeLocal, normalizeRemote } = require('./normalize.js')
|
|
19
19
|
const { classify } = require('./compare.js')
|
|
20
20
|
const { readBase, writeBase, backup } = require('./base.js')
|
|
21
|
-
const { writeFrontmatter } = require('./write.js')
|
|
21
|
+
const { writeFrontmatter, applyMilestonesPull, applyTasksPull } = require('./write.js')
|
|
22
22
|
const { frontmatterPatchFor } = require('./apply.js')
|
|
23
23
|
|
|
24
|
+
// Collect the conflicting units across scalar (field-level) and keyed
|
|
25
|
+
// (item-level) fields, as stable labels for the refusal message.
|
|
26
|
+
function collectConflicts(fields) {
|
|
27
|
+
const out = []
|
|
28
|
+
for (const f of fields) {
|
|
29
|
+
if (f.keyed) {
|
|
30
|
+
for (const it of f.items) if (it.status === 'conflict') out.push(`${f.field}#${it.id}`)
|
|
31
|
+
} else if (f.status === 'conflict') {
|
|
32
|
+
out.push(f.field)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
37
|
+
|
|
24
38
|
async function pull({ dir, snapshotDir, identifier, projectId, adapter, config, force = false, timestamp }) {
|
|
25
39
|
const local = normalizeLocal(snapshotDir, config)
|
|
26
40
|
const remoteRaw = await adapter.readProject(projectId)
|
|
@@ -31,40 +45,54 @@ async function pull({ dir, snapshotDir, identifier, projectId, adapter, config,
|
|
|
31
45
|
const base = readBase(dir, identifier, config)
|
|
32
46
|
const fields = classify(local, remote, base, config)
|
|
33
47
|
|
|
34
|
-
const conflicts = fields
|
|
48
|
+
const conflicts = collectConflicts(fields)
|
|
35
49
|
if (conflicts.length && !force) {
|
|
36
50
|
return {
|
|
37
51
|
ok: false,
|
|
38
52
|
blocked: true,
|
|
39
53
|
reason: 'conflict',
|
|
40
54
|
conflicts,
|
|
41
|
-
message: `pull refused — ${conflicts.length}
|
|
55
|
+
message: `pull refused — ${conflicts.length} unit(s) changed on both sides: ` +
|
|
42
56
|
`${conflicts.join(', ')}. Resolve locally or re-run with --force (remote wins).`,
|
|
43
57
|
}
|
|
44
58
|
}
|
|
45
59
|
|
|
46
|
-
// Everything remote wants to write down: remote-only fields, plus (under force)
|
|
47
|
-
// both-conflict fields where remote wins.
|
|
48
|
-
const pullFields = fields.filter((f) => f.pullable)
|
|
49
|
-
const fieldValues = {}
|
|
50
|
-
for (const f of pullFields) fieldValues[f.field] = remote[f.field]
|
|
51
|
-
|
|
52
|
-
const { patch, applied, deferred } = frontmatterPatchFor(fieldValues, config)
|
|
53
|
-
|
|
54
60
|
// --force overwrites local edits — back the local side up first.
|
|
55
61
|
let backupPath = null
|
|
56
62
|
if (force) {
|
|
57
63
|
backupPath = backup('local', dir, identifier, config, { timestamp, data: local })
|
|
58
64
|
}
|
|
59
65
|
|
|
60
|
-
//
|
|
66
|
+
// Keyed body fields (e.g. milestones) — apply per-item via the denormalizer,
|
|
67
|
+
// which writes/creates the matching phase files. Removals are report-only.
|
|
68
|
+
const keyedApplied = []
|
|
69
|
+
const keyedCreated = []
|
|
70
|
+
const keyedReported = []
|
|
71
|
+
for (const f of fields) {
|
|
72
|
+
if (!f.keyed) continue
|
|
73
|
+
const apply = f.field === 'tasks' ? applyTasksPull : applyMilestonesPull
|
|
74
|
+
const res = apply(snapshotDir, f.items)
|
|
75
|
+
if (res.applied.length || res.created.length) keyedApplied.push(f.field)
|
|
76
|
+
keyedCreated.push(...res.created)
|
|
77
|
+
keyedReported.push(...res.reported.map((id) => `${f.field}#${id}`))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Scalar pull-owned fields → frontmatter (keyed fields handled above).
|
|
81
|
+
const scalarPull = fields.filter((f) => f.pullable && !f.keyed)
|
|
82
|
+
const fieldValues = {}
|
|
83
|
+
for (const f of scalarPull) fieldValues[f.field] = remote[f.field]
|
|
84
|
+
const { patch, applied, deferred } = frontmatterPatchFor(fieldValues, config)
|
|
85
|
+
|
|
61
86
|
if (applied.length || timestamp) {
|
|
62
87
|
writeFrontmatter(snapshotDir, config, { ...patch, last_synced_at: timestamp })
|
|
63
88
|
}
|
|
64
89
|
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
90
|
+
// Re-normalize local so the base reflects the phase-file writes we just made,
|
|
91
|
+
// then advance base: scalar-applied fields take the remote value; keyed fields
|
|
92
|
+
// take the (now-updated) local value so applied items read in-sync and any
|
|
93
|
+
// report-only removal stays pending.
|
|
94
|
+
const newLocal = normalizeLocal(snapshotDir, config)
|
|
95
|
+
const newBase = { ...newLocal }
|
|
68
96
|
for (const field of applied) newBase[field] = remote[field]
|
|
69
97
|
newBase.__meta = { updatedAt: remoteRaw.updatedAt || null, syncedAt: timestamp }
|
|
70
98
|
const basePath = writeBase(dir, identifier, config, newBase)
|
|
@@ -74,10 +102,13 @@ async function pull({ dir, snapshotDir, identifier, projectId, adapter, config,
|
|
|
74
102
|
blocked: false,
|
|
75
103
|
applied,
|
|
76
104
|
deferred,
|
|
105
|
+
keyedApplied,
|
|
106
|
+
keyedCreated,
|
|
107
|
+
keyedReported,
|
|
77
108
|
conflictsForced: force ? conflicts : [],
|
|
78
109
|
backupPath,
|
|
79
110
|
basePath,
|
|
80
|
-
pulled:
|
|
111
|
+
pulled: [...scalarPull.map((f) => f.field), ...keyedApplied],
|
|
81
112
|
}
|
|
82
113
|
}
|
|
83
114
|
|
|
@@ -28,16 +28,22 @@ async function push({ dir, snapshotDir, identifier, projectId, adapter, config,
|
|
|
28
28
|
}
|
|
29
29
|
const remote = normalizeRemote(remoteRaw, config)
|
|
30
30
|
const base = readBase(dir, identifier, config)
|
|
31
|
-
const baseStamp = base && base.__meta ? base.__meta.updatedAt : null
|
|
32
31
|
const fields = classify(local, remote, base, config)
|
|
33
32
|
|
|
34
|
-
// Remote moved past base if
|
|
35
|
-
//
|
|
33
|
+
// Remote moved past base only if a *co-authored* (`both`) field diverged on the
|
|
34
|
+
// remote side — that's the case the repo can't safely overwrite without a pull.
|
|
35
|
+
// For a keyed field the equivalent is a same-item conflict (independent edits to
|
|
36
|
+
// different items don't collide, so they don't block). A `pull`-owned change
|
|
37
|
+
// (status/priority/labels) is Linear's to own and must NOT block a content push,
|
|
38
|
+
// and a bare `updatedAt` bump is too coarse to gate on — the pre-write re-read
|
|
39
|
+
// below still catches a racer that lands during the push itself.
|
|
36
40
|
const remoteDivergedFields = fields
|
|
37
|
-
.filter((f) => f.raw === 'remote-only' || f.raw === 'conflict')
|
|
41
|
+
.filter((f) => !f.keyed && f.ownership === 'both' && (f.raw === 'remote-only' || f.raw === 'conflict'))
|
|
38
42
|
.map((f) => f.field)
|
|
39
|
-
const
|
|
40
|
-
|
|
43
|
+
for (const f of fields) {
|
|
44
|
+
if (f.keyed) for (const it of f.items) if (it.status === 'conflict') remoteDivergedFields.push(`${f.field}#${it.id}`)
|
|
45
|
+
}
|
|
46
|
+
const moved = remoteDivergedFields.length > 0
|
|
41
47
|
|
|
42
48
|
if (moved && !force) {
|
|
43
49
|
return {
|
|
@@ -52,8 +58,37 @@ async function push({ dir, snapshotDir, identifier, projectId, adapter, config,
|
|
|
52
58
|
}
|
|
53
59
|
}
|
|
54
60
|
|
|
55
|
-
|
|
56
|
-
|
|
61
|
+
// Scalar push goes through the project adapter here. Keyed body fields
|
|
62
|
+
// (milestones) can't be written by the offline engine — the provider skill does
|
|
63
|
+
// the MCP create/update and stamps new ids — so the engine emits a *plan* the
|
|
64
|
+
// skill applies. The base still advances to local (below): a created milestone's
|
|
65
|
+
// id:null item is skipped by the keyed compare until the skill stamps it, then
|
|
66
|
+
// it converges on the next sync, so no special base handling is needed.
|
|
67
|
+
const pushFields = fields.filter((f) => f.pushable && !f.keyed)
|
|
68
|
+
// A per-field create/update plan for each keyed collection. The item content
|
|
69
|
+
// (minus its id) is exactly what the skill sends to the Linear save tool.
|
|
70
|
+
const keyedPush = {}
|
|
71
|
+
for (const f of fields) {
|
|
72
|
+
if (!f.keyed) continue
|
|
73
|
+
const strip = (obj) => {
|
|
74
|
+
const { [f.idKey]: _omit, ...rest } = obj
|
|
75
|
+
return rest
|
|
76
|
+
}
|
|
77
|
+
const plan = { create: [], update: [] }
|
|
78
|
+
// Edits to already-linked items (matched by id) → update.
|
|
79
|
+
for (const it of f.items) {
|
|
80
|
+
if (!it.pushable || !it.local) continue
|
|
81
|
+
if (it.status !== 'added') plan.update.push({ id: it.id, ...strip(it.local) })
|
|
82
|
+
}
|
|
83
|
+
// Unlinked local items (no id yet) are new content to create; the keyed
|
|
84
|
+
// compare skips them (nothing to key on), so collect them straight from local.
|
|
85
|
+
const localItems = Array.isArray(local[f.field]) ? local[f.field] : []
|
|
86
|
+
for (const li of localItems) if (li && li[f.idKey] == null) plan.create.push(strip(li))
|
|
87
|
+
if (plan.create.length || plan.update.length) keyedPush[f.field] = plan
|
|
88
|
+
}
|
|
89
|
+
const hasKeyedPush = Object.keys(keyedPush).length > 0
|
|
90
|
+
|
|
91
|
+
if (!pushFields.length && !hasKeyedPush && !force) {
|
|
57
92
|
return { ok: true, blocked: false, written: [], skipped: [], note: 'nothing to push' }
|
|
58
93
|
}
|
|
59
94
|
|
|
@@ -76,7 +111,9 @@ async function push({ dir, snapshotDir, identifier, projectId, adapter, config,
|
|
|
76
111
|
|
|
77
112
|
const updates = {}
|
|
78
113
|
for (const f of pushFields) updates[f.field] = local[f.field]
|
|
79
|
-
const updated =
|
|
114
|
+
const updated = Object.keys(updates).length
|
|
115
|
+
? (await adapter.updateProject(projectId, updates)) || remoteRaw2 || remoteRaw
|
|
116
|
+
: remoteRaw2 || remoteRaw
|
|
80
117
|
const updatedRemote = normalizeRemote(updated, config)
|
|
81
118
|
|
|
82
119
|
// Reconciled base: local is the source of truth for the fields we pushed (and
|
|
@@ -95,8 +132,12 @@ async function push({ dir, snapshotDir, identifier, projectId, adapter, config,
|
|
|
95
132
|
ok: true,
|
|
96
133
|
blocked: false,
|
|
97
134
|
written: pushFields.map((f) => f.field),
|
|
135
|
+
// The skill applies these Linear writes (create → stamp the new id back into
|
|
136
|
+
// the phase file / task line; update → save by id). Omitted when empty.
|
|
137
|
+
...(keyedPush.milestones ? { milestonesPush: keyedPush.milestones } : {}),
|
|
138
|
+
...(keyedPush.tasks ? { issuesPush: keyedPush.tasks } : {}),
|
|
98
139
|
skipped: fields
|
|
99
|
-
.filter((f) => !f.pushable && f.status !== 'unchanged')
|
|
140
|
+
.filter((f) => !f.pushable && !f.keyed && f.status !== 'unchanged')
|
|
100
141
|
.map((f) => f.field),
|
|
101
142
|
backupPath,
|
|
102
143
|
basePath,
|