openplanr 1.2.7 → 1.3.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 +41 -4
- package/dist/agents/task-parser.d.ts.map +1 -1
- package/dist/agents/task-parser.js +8 -34
- package/dist/agents/task-parser.js.map +1 -1
- package/dist/ai/prompts/prompt-builder.d.ts +48 -0
- package/dist/ai/prompts/prompt-builder.d.ts.map +1 -1
- package/dist/ai/prompts/prompt-builder.js +57 -1
- package/dist/ai/prompts/prompt-builder.js.map +1 -1
- package/dist/ai/prompts/system-prompts.d.ts +24 -1
- package/dist/ai/prompts/system-prompts.d.ts.map +1 -1
- package/dist/ai/prompts/system-prompts.js +104 -6
- package/dist/ai/prompts/system-prompts.js.map +1 -1
- package/dist/ai/schemas/ai-response-schemas.d.ts +68 -0
- package/dist/ai/schemas/ai-response-schemas.d.ts.map +1 -1
- package/dist/ai/schemas/ai-response-schemas.js +81 -0
- package/dist/ai/schemas/ai-response-schemas.js.map +1 -1
- package/dist/ai/types.d.ts +2 -0
- package/dist/ai/types.d.ts.map +1 -1
- package/dist/ai/types.js +4 -0
- package/dist/ai/types.js.map +1 -1
- package/dist/cli/commands/backlog.d.ts +12 -0
- package/dist/cli/commands/backlog.d.ts.map +1 -1
- package/dist/cli/commands/backlog.js +88 -2
- package/dist/cli/commands/backlog.js.map +1 -1
- package/dist/cli/commands/config.d.ts.map +1 -1
- package/dist/cli/commands/config.js +8 -2
- package/dist/cli/commands/config.js.map +1 -1
- package/dist/cli/commands/linear.d.ts +8 -0
- package/dist/cli/commands/linear.d.ts.map +1 -0
- package/dist/cli/commands/linear.js +550 -0
- package/dist/cli/commands/linear.js.map +1 -0
- package/dist/cli/commands/quick.d.ts +17 -0
- package/dist/cli/commands/quick.d.ts.map +1 -1
- package/dist/cli/commands/quick.js +31 -15
- package/dist/cli/commands/quick.js.map +1 -1
- package/dist/cli/commands/revise.d.ts +24 -0
- package/dist/cli/commands/revise.d.ts.map +1 -0
- package/dist/cli/commands/revise.js +570 -0
- package/dist/cli/commands/revise.js.map +1 -0
- package/dist/cli/index.js +4 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/models/schema.d.ts +43 -0
- package/dist/models/schema.d.ts.map +1 -1
- package/dist/models/schema.js +49 -0
- package/dist/models/schema.js.map +1 -1
- package/dist/models/types.d.ts +296 -0
- package/dist/models/types.d.ts.map +1 -1
- package/dist/services/artifact-gathering.d.ts +4 -0
- package/dist/services/artifact-gathering.d.ts.map +1 -1
- package/dist/services/artifact-gathering.js +1 -1
- package/dist/services/artifact-gathering.js.map +1 -1
- package/dist/services/artifact-service.d.ts +12 -1
- package/dist/services/artifact-service.d.ts.map +1 -1
- package/dist/services/artifact-service.js +49 -6
- package/dist/services/artifact-service.js.map +1 -1
- package/dist/services/atomic-write-service.d.ts +41 -0
- package/dist/services/atomic-write-service.d.ts.map +1 -0
- package/dist/services/atomic-write-service.js +87 -0
- package/dist/services/atomic-write-service.js.map +1 -0
- package/dist/services/audit-log-service.d.ts +47 -0
- package/dist/services/audit-log-service.d.ts.map +1 -0
- package/dist/services/audit-log-service.js +210 -0
- package/dist/services/audit-log-service.js.map +1 -0
- package/dist/services/cascade-service.d.ts +62 -0
- package/dist/services/cascade-service.d.ts.map +1 -0
- package/dist/services/cascade-service.js +189 -0
- package/dist/services/cascade-service.js.map +1 -0
- package/dist/services/credentials-service.js +2 -2
- package/dist/services/credentials-service.js.map +1 -1
- package/dist/services/diff-service.d.ts +18 -0
- package/dist/services/diff-service.d.ts.map +1 -0
- package/dist/services/diff-service.js +35 -0
- package/dist/services/diff-service.js.map +1 -0
- package/dist/services/evidence-verifier.d.ts +71 -0
- package/dist/services/evidence-verifier.d.ts.map +1 -0
- package/dist/services/evidence-verifier.js +174 -0
- package/dist/services/evidence-verifier.js.map +1 -0
- package/dist/services/git-service.d.ts +60 -0
- package/dist/services/git-service.d.ts.map +1 -0
- package/dist/services/git-service.js +137 -0
- package/dist/services/git-service.js.map +1 -0
- package/dist/services/graph-integrity.d.ts +35 -0
- package/dist/services/graph-integrity.d.ts.map +1 -0
- package/dist/services/graph-integrity.js +53 -0
- package/dist/services/graph-integrity.js.map +1 -0
- package/dist/services/linear/body-formatters.d.ts +69 -0
- package/dist/services/linear/body-formatters.d.ts.map +1 -0
- package/dist/services/linear/body-formatters.js +183 -0
- package/dist/services/linear/body-formatters.js.map +1 -0
- package/dist/services/linear/constants.d.ts +61 -0
- package/dist/services/linear/constants.d.ts.map +1 -0
- package/dist/services/linear/constants.js +84 -0
- package/dist/services/linear/constants.js.map +1 -0
- package/dist/services/linear/errors.d.ts +14 -0
- package/dist/services/linear/errors.d.ts.map +1 -0
- package/dist/services/linear/errors.js +106 -0
- package/dist/services/linear/errors.js.map +1 -0
- package/dist/services/linear/estimate-resolver.d.ts +50 -0
- package/dist/services/linear/estimate-resolver.d.ts.map +1 -0
- package/dist/services/linear/estimate-resolver.js +82 -0
- package/dist/services/linear/estimate-resolver.js.map +1 -0
- package/dist/services/linear/plan-builders.d.ts +64 -0
- package/dist/services/linear/plan-builders.d.ts.map +1 -0
- package/dist/services/linear/plan-builders.js +237 -0
- package/dist/services/linear/plan-builders.js.map +1 -0
- package/dist/services/linear/scope-loaders.d.ts +79 -0
- package/dist/services/linear/scope-loaders.d.ts.map +1 -0
- package/dist/services/linear/scope-loaders.js +227 -0
- package/dist/services/linear/scope-loaders.js.map +1 -0
- package/dist/services/linear/strategy-context.d.ts +66 -0
- package/dist/services/linear/strategy-context.d.ts.map +1 -0
- package/dist/services/linear/strategy-context.js +121 -0
- package/dist/services/linear/strategy-context.js.map +1 -0
- package/dist/services/linear-mapping-service.d.ts +11 -0
- package/dist/services/linear-mapping-service.d.ts.map +1 -0
- package/dist/services/linear-mapping-service.js +220 -0
- package/dist/services/linear-mapping-service.js.map +1 -0
- package/dist/services/linear-pull-service.d.ts +137 -0
- package/dist/services/linear-pull-service.d.ts.map +1 -0
- package/dist/services/linear-pull-service.js +720 -0
- package/dist/services/linear-pull-service.js.map +1 -0
- package/dist/services/linear-push-service.d.ts +86 -0
- package/dist/services/linear-push-service.d.ts.map +1 -0
- package/dist/services/linear-push-service.js +956 -0
- package/dist/services/linear-push-service.js.map +1 -0
- package/dist/services/linear-service.d.ts +122 -0
- package/dist/services/linear-service.d.ts.map +1 -0
- package/dist/services/linear-service.js +361 -0
- package/dist/services/linear-service.js.map +1 -0
- package/dist/services/prompt-service.d.ts +37 -0
- package/dist/services/prompt-service.d.ts.map +1 -1
- package/dist/services/prompt-service.js +111 -0
- package/dist/services/prompt-service.js.map +1 -1
- package/dist/services/revise-apply-service.d.ts +55 -0
- package/dist/services/revise-apply-service.d.ts.map +1 -0
- package/dist/services/revise-apply-service.js +255 -0
- package/dist/services/revise-apply-service.js.map +1 -0
- package/dist/services/revise-cache-service.d.ts +46 -0
- package/dist/services/revise-cache-service.d.ts.map +1 -0
- package/dist/services/revise-cache-service.js +88 -0
- package/dist/services/revise-cache-service.js.map +1 -0
- package/dist/services/revise-plan-service.d.ts +38 -0
- package/dist/services/revise-plan-service.d.ts.map +1 -0
- package/dist/services/revise-plan-service.js +151 -0
- package/dist/services/revise-plan-service.js.map +1 -0
- package/dist/services/revise-service.d.ts +115 -0
- package/dist/services/revise-service.d.ts.map +1 -0
- package/dist/services/revise-service.js +294 -0
- package/dist/services/revise-service.js.map +1 -0
- package/dist/services/template-sections.d.ts +28 -0
- package/dist/services/template-sections.d.ts.map +1 -0
- package/dist/services/template-sections.js +55 -0
- package/dist/services/template-sections.js.map +1 -0
- package/dist/templates/backlog/backlog-item.md.hbs +3 -0
- package/dist/templates/quick/quick-task.md.hbs +6 -0
- package/dist/utils/diff.d.ts +47 -0
- package/dist/utils/diff.d.ts.map +1 -0
- package/dist/utils/diff.js +278 -0
- package/dist/utils/diff.js.map +1 -0
- package/dist/utils/markdown.d.ts +23 -0
- package/dist/utils/markdown.d.ts.map +1 -1
- package/dist/utils/markdown.js +79 -0
- package/dist/utils/markdown.js.map +1 -1
- package/package.json +3 -2
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linear → OpenPlanr pull-direction sync.
|
|
3
|
+
*
|
|
4
|
+
* Two concerns consolidated here because both are strictly pull and share
|
|
5
|
+
* the same client lifecycle and auth surface:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Workflow-status sync**: for Features and Stories with a stored
|
|
8
|
+
* `linearIssueId`, fetch the current Linear workflow state name and
|
|
9
|
+
* write OpenPlanr `status` frontmatter when mapped.
|
|
10
|
+
*
|
|
11
|
+
* 2. **Task checkbox sync**: bidirectional 3-way merge between local
|
|
12
|
+
* TASK markdown and Linear TaskList issue description bodies.
|
|
13
|
+
* Pull-side lives here; push-side lives in `linear-push-service.ts`.
|
|
14
|
+
*
|
|
15
|
+
* Keeping them in one module reduces call-site noise for
|
|
16
|
+
* `planr linear sync` and gives the next reader one file to understand
|
|
17
|
+
* everything that pulls state from Linear.
|
|
18
|
+
*/
|
|
19
|
+
import { parseTaskMarkdown } from '../agents/task-parser.js';
|
|
20
|
+
import { isVerbose, logger } from '../utils/logger.js';
|
|
21
|
+
import { applyTaskCheckboxStateMap, parseTaskCheckboxLines, parseTaskCheckboxReconciled, serializeTaskCheckboxReconciled, } from '../utils/markdown.js';
|
|
22
|
+
import { listArtifacts, readArtifact, readArtifactRaw, updateArtifact, updateArtifactFields, } from './artifact-service.js';
|
|
23
|
+
import { isNonInteractive } from './interactive-state.js';
|
|
24
|
+
import { ensureAutoStateIdMap, ensureTeamEstimationType, formatTaskCheckboxBody, getAutoStateIdMap, resolveBacklogStateIdForPush, resolveTaskStateIdForPush, } from './linear-push-service.js';
|
|
25
|
+
import { fetchLinearIssueStateNames, getLinearIssueDescription, isLikelyLinearIssueId, isLikelyLinearWorkflowStateId, updateLinearIssue, } from './linear-service.js';
|
|
26
|
+
import { promptSelect } from './prompt-service.js';
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Workflow-status sync
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
function asTaskStatus(s) {
|
|
31
|
+
if (s === 'pending' || s === 'in-progress' || s === 'done')
|
|
32
|
+
return s;
|
|
33
|
+
return 'pending';
|
|
34
|
+
}
|
|
35
|
+
function normalizeStateKey(name) {
|
|
36
|
+
return name.trim().toLowerCase();
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Default Linear workflow state name → OpenPlanr `TaskStatus` (case-insensitive keys).
|
|
40
|
+
* User `linear.statusMap` overrides/extends these.
|
|
41
|
+
*/
|
|
42
|
+
const DEFAULT_LINEAR_STATE_TO_OP = [
|
|
43
|
+
['backlog', 'pending'],
|
|
44
|
+
['triage', 'pending'],
|
|
45
|
+
['unstarted', 'pending'],
|
|
46
|
+
['todo', 'pending'],
|
|
47
|
+
['canceled', 'done'],
|
|
48
|
+
['cancelled', 'done'],
|
|
49
|
+
['done', 'done'],
|
|
50
|
+
['completed', 'done'],
|
|
51
|
+
['in progress', 'in-progress'],
|
|
52
|
+
['in development', 'in-progress'],
|
|
53
|
+
['in review', 'in-progress'],
|
|
54
|
+
];
|
|
55
|
+
export function buildNameToStatusMap(user) {
|
|
56
|
+
const m = new Map();
|
|
57
|
+
for (const [k, v] of DEFAULT_LINEAR_STATE_TO_OP) {
|
|
58
|
+
m.set(normalizeStateKey(k), v);
|
|
59
|
+
}
|
|
60
|
+
if (user) {
|
|
61
|
+
for (const [linearName, raw] of Object.entries(user)) {
|
|
62
|
+
if (isLikelyLinearWorkflowStateId(raw))
|
|
63
|
+
continue;
|
|
64
|
+
if (raw === 'pending' || raw === 'in-progress' || raw === 'done') {
|
|
65
|
+
m.set(normalizeStateKey(linearName), raw);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return m;
|
|
70
|
+
}
|
|
71
|
+
export function mapLinearNameToTaskStatus(stateName, byName) {
|
|
72
|
+
return byName.get(normalizeStateKey(stateName));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Linear state name → BacklogStatus. Backlog items use a different vocabulary
|
|
76
|
+
* from tasks/stories/features: any "in flight" Linear state (Todo, In Progress,
|
|
77
|
+
* In Review) maps to `open`, while Linear "done" states (Done, Completed,
|
|
78
|
+
* Canceled) map to `closed`. We deliberately never emit `promoted` from the
|
|
79
|
+
* pull path — that state implies a local target pointer we don't have from
|
|
80
|
+
* Linear. User `linear.statusMap` can override defaults.
|
|
81
|
+
*/
|
|
82
|
+
const DEFAULT_LINEAR_STATE_TO_BACKLOG = [
|
|
83
|
+
['backlog', 'open'],
|
|
84
|
+
['triage', 'open'],
|
|
85
|
+
['unstarted', 'open'],
|
|
86
|
+
['todo', 'open'],
|
|
87
|
+
['in progress', 'open'],
|
|
88
|
+
['in development', 'open'],
|
|
89
|
+
['in review', 'open'],
|
|
90
|
+
['canceled', 'closed'],
|
|
91
|
+
['cancelled', 'closed'],
|
|
92
|
+
['done', 'closed'],
|
|
93
|
+
['completed', 'closed'],
|
|
94
|
+
];
|
|
95
|
+
export function buildNameToBacklogStatusMap(user) {
|
|
96
|
+
const m = new Map();
|
|
97
|
+
for (const [k, v] of DEFAULT_LINEAR_STATE_TO_BACKLOG) {
|
|
98
|
+
m.set(normalizeStateKey(k), v);
|
|
99
|
+
}
|
|
100
|
+
if (user) {
|
|
101
|
+
for (const [linearName, raw] of Object.entries(user)) {
|
|
102
|
+
if (isLikelyLinearWorkflowStateId(raw))
|
|
103
|
+
continue;
|
|
104
|
+
if (raw === 'open' || raw === 'closed' || raw === 'promoted') {
|
|
105
|
+
m.set(normalizeStateKey(linearName), raw);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return m;
|
|
110
|
+
}
|
|
111
|
+
export function mapLinearNameToBacklogStatus(stateName, byName) {
|
|
112
|
+
return byName.get(normalizeStateKey(stateName));
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Pure three-way merge decision for a single artifact's workflow status.
|
|
116
|
+
*
|
|
117
|
+
* Mirrors `resolveTaskCheckboxFinalStates` but on a scalar. Returns
|
|
118
|
+
* `side='unchanged'` when local and remote already agree (counter path),
|
|
119
|
+
* `side='linear'` when the remote changed and should be pulled, `side='local'`
|
|
120
|
+
* when the local changed and should be pushed back to Linear. True
|
|
121
|
+
* conflicts (both diverged from base, or no base and they disagree) are
|
|
122
|
+
* resolved per `strategy`.
|
|
123
|
+
*/
|
|
124
|
+
export function resolveStatusFinalState(c, strategy) {
|
|
125
|
+
const { base, local, remote } = c;
|
|
126
|
+
// Fast path: nothing to do.
|
|
127
|
+
if (local === remote) {
|
|
128
|
+
return { final: local, side: 'unchanged', conflictDecisions: 0, isTrueConflict: false };
|
|
129
|
+
}
|
|
130
|
+
// Only one side diverged from base — propagate its change.
|
|
131
|
+
if (base !== undefined) {
|
|
132
|
+
if (base === local && local !== remote) {
|
|
133
|
+
// Linear changed since last sync → pull.
|
|
134
|
+
return { final: remote, side: 'linear', conflictDecisions: 0, isTrueConflict: false };
|
|
135
|
+
}
|
|
136
|
+
if (base === remote && local !== remote) {
|
|
137
|
+
// Local changed since last sync → push.
|
|
138
|
+
return { final: local, side: 'local', conflictDecisions: 0, isTrueConflict: false };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// True conflict: both changed, or no base and they disagree.
|
|
142
|
+
// Strategy dictates which wins; we always record a conflict decision so
|
|
143
|
+
// the summary + audit log reflect reality.
|
|
144
|
+
if (strategy === 'local') {
|
|
145
|
+
return { final: local, side: 'local', conflictDecisions: 1, isTrueConflict: true };
|
|
146
|
+
}
|
|
147
|
+
if (strategy === 'linear') {
|
|
148
|
+
return { final: remote, side: 'linear', conflictDecisions: 1, isTrueConflict: true };
|
|
149
|
+
}
|
|
150
|
+
// strategy === 'prompt': caller resolves interactively after seeing the
|
|
151
|
+
// conflict. Report the true-conflict marker so the sync loop can dispatch
|
|
152
|
+
// to promptSelect and increment the counter.
|
|
153
|
+
return { final: local, side: 'local', conflictDecisions: 1, isTrueConflict: true };
|
|
154
|
+
}
|
|
155
|
+
export async function syncLinearStatusIntoArtifacts(projectDir, config, client, options) {
|
|
156
|
+
const dryRun = options?.dryRun === true;
|
|
157
|
+
const strategy = options?.onConflict ?? 'prompt';
|
|
158
|
+
const summary = {
|
|
159
|
+
updated: 0,
|
|
160
|
+
pushedToLinear: 0,
|
|
161
|
+
unchanged: 0,
|
|
162
|
+
conflictDecisions: 0,
|
|
163
|
+
unmapped: 0,
|
|
164
|
+
skippedNoId: 0,
|
|
165
|
+
missingFromApi: 0,
|
|
166
|
+
pushFailures: 0,
|
|
167
|
+
};
|
|
168
|
+
const teamId = config.linear?.teamId;
|
|
169
|
+
if (!teamId) {
|
|
170
|
+
// Without a team id we can't push back. Fall back to pull-only behavior
|
|
171
|
+
// — the rest of the function handles `side: 'local'` as best-effort
|
|
172
|
+
// (skipping the API call when teamId is missing).
|
|
173
|
+
logger.debug('linear sync: no linear.teamId configured; skipping push-back path');
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
// Prime the auto-derived state-id map + estimation-type cache the same
|
|
177
|
+
// way `runLinearPush` does, so conflict resolutions that flip to `local`
|
|
178
|
+
// can call `updateLinearIssue(stateId)` without a separate fetch.
|
|
179
|
+
await ensureAutoStateIdMap(client, teamId);
|
|
180
|
+
await ensureTeamEstimationType(client, teamId);
|
|
181
|
+
}
|
|
182
|
+
const byNameTask = buildNameToStatusMap(config.linear?.statusMap);
|
|
183
|
+
const byNameBacklog = buildNameToBacklogStatusMap(config.linear?.statusMap);
|
|
184
|
+
const tracked = [];
|
|
185
|
+
for (const type of ['feature', 'story', 'quick', 'backlog']) {
|
|
186
|
+
const list = await listArtifacts(projectDir, config, type);
|
|
187
|
+
for (const row of list) {
|
|
188
|
+
const art = await readArtifact(projectDir, config, type, row.id);
|
|
189
|
+
if (!art)
|
|
190
|
+
continue;
|
|
191
|
+
const linearId = art.data.linearIssueId;
|
|
192
|
+
if (!linearId) {
|
|
193
|
+
logger.debug(`linear sync: skip ${row.id} (no linearIssueId in frontmatter)`);
|
|
194
|
+
summary.skippedNoId++;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
// Same validation as checkbox sync: don't feed malformed ids to the API.
|
|
198
|
+
if (!isLikelyLinearIssueId(linearId)) {
|
|
199
|
+
logger.warn(`${type} ${row.id}: linearIssueId "${linearId}" is not a valid Linear id (expected uuid or \`ENG-42\` identifier). Skipping status sync — re-run \`planr linear push\` to repair.`);
|
|
200
|
+
summary.skippedNoId++;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
// For backlog items we keep the raw status so the `promoted` guard and
|
|
204
|
+
// `open/closed/promoted` comparisons stay honest. For the other types,
|
|
205
|
+
// the task-status coercion preserves existing behavior.
|
|
206
|
+
const localStatus = type === 'backlog' ? String(art.data.status ?? 'open') : asTaskStatus(art.data.status);
|
|
207
|
+
const rawBase = art.data.linearStatusReconciled;
|
|
208
|
+
const baseStatus = typeof rawBase === 'string' && rawBase.trim().length > 0 ? rawBase.trim() : undefined;
|
|
209
|
+
tracked.push({ type, id: row.id, linearIssueId: linearId, localStatus, baseStatus });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (tracked.length === 0) {
|
|
213
|
+
return summary;
|
|
214
|
+
}
|
|
215
|
+
const ids = tracked.map((t) => t.linearIssueId);
|
|
216
|
+
const fromLinear = await fetchLinearIssueStateNames(client, ids);
|
|
217
|
+
const autoResolvedConflicts = [];
|
|
218
|
+
for (const t of tracked) {
|
|
219
|
+
const stateName = fromLinear.get(t.linearIssueId);
|
|
220
|
+
if (stateName === undefined) {
|
|
221
|
+
logger.warn(`linear sync: issue ${t.linearIssueId} (${t.type} ${t.id}) not returned by Linear (deleted or no access) — left unchanged.`);
|
|
222
|
+
summary.missingFromApi++;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const mapped = t.type === 'backlog'
|
|
226
|
+
? mapLinearNameToBacklogStatus(stateName, byNameBacklog)
|
|
227
|
+
: mapLinearNameToTaskStatus(stateName, byNameTask);
|
|
228
|
+
if (mapped === undefined) {
|
|
229
|
+
logger.warn(`linear sync: unmapped Linear state "${stateName}" for ${t.type} ${t.id} — left unchanged.`);
|
|
230
|
+
summary.unmapped++;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
// `promoted` is a local-only BL state (the BL was promoted to a QT/story
|
|
234
|
+
// with a target pointer recorded in the BL body). Linear has no equivalent
|
|
235
|
+
// signal, so pulling `Done` back would destroy the `promoted → target`
|
|
236
|
+
// linkage. Treat as unchanged.
|
|
237
|
+
if (t.type === 'backlog' && t.localStatus === 'promoted') {
|
|
238
|
+
if (isVerbose()) {
|
|
239
|
+
logger.debug(`linear sync: backlog ${t.id} kept as "promoted" (local-only state)`);
|
|
240
|
+
}
|
|
241
|
+
summary.unchanged++;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
// Three-way merge: local vs remote vs base (`linearStatusReconciled`).
|
|
245
|
+
const decision = resolveStatusFinalState({ base: t.baseStatus, local: t.localStatus, remote: mapped }, strategy);
|
|
246
|
+
if (decision.side === 'unchanged') {
|
|
247
|
+
if (isVerbose()) {
|
|
248
|
+
logger.debug(`linear sync: ${t.type} ${t.id} unchanged (${mapped})`);
|
|
249
|
+
}
|
|
250
|
+
summary.unchanged++;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
// For true conflicts under `prompt`, ask the user. The pure helper
|
|
254
|
+
// returned a placeholder; `promptSelect` picks the real winner here.
|
|
255
|
+
let resolvedSide = decision.side;
|
|
256
|
+
let resolvedFinal = decision.final;
|
|
257
|
+
if (decision.isTrueConflict) {
|
|
258
|
+
summary.conflictDecisions++;
|
|
259
|
+
const label = `${t.type} ${t.id}`;
|
|
260
|
+
if (strategy === 'prompt' && !isNonInteractive()) {
|
|
261
|
+
const picked = await promptSelect(`${label}: status conflict (base=${t.baseStatus ?? '—'} local=${t.localStatus} remote=${mapped}). Use which side?`, [
|
|
262
|
+
{ name: 'Local file', value: 'local' },
|
|
263
|
+
{ name: 'Linear', value: 'linear' },
|
|
264
|
+
], 'linear');
|
|
265
|
+
resolvedSide = picked;
|
|
266
|
+
resolvedFinal = picked === 'local' ? t.localStatus : mapped;
|
|
267
|
+
}
|
|
268
|
+
else if (strategy === 'prompt' && isNonInteractive()) {
|
|
269
|
+
logger.dim(` [auto] ${label} status conflict (base=${t.baseStatus ?? '—'} local=${t.localStatus} remote=${mapped}): using Linear (set --on-conflict local|linear to override)`);
|
|
270
|
+
resolvedSide = 'linear';
|
|
271
|
+
resolvedFinal = mapped;
|
|
272
|
+
autoResolvedConflicts.push({
|
|
273
|
+
kind: t.type,
|
|
274
|
+
id: t.id,
|
|
275
|
+
base: t.baseStatus,
|
|
276
|
+
local: t.localStatus,
|
|
277
|
+
remote: mapped,
|
|
278
|
+
chosen: 'linear',
|
|
279
|
+
timestamp: new Date().toISOString(),
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// Apply the resolution.
|
|
284
|
+
if (resolvedSide === 'linear') {
|
|
285
|
+
// Pull: write local frontmatter to match Linear.
|
|
286
|
+
if (!dryRun) {
|
|
287
|
+
await updateArtifactFields(projectDir, config, t.type, t.id, {
|
|
288
|
+
status: resolvedFinal,
|
|
289
|
+
linearStatusReconciled: resolvedFinal,
|
|
290
|
+
linearStatusSyncedAt: new Date().toISOString(),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
if (isVerbose()) {
|
|
294
|
+
logger.debug(`linear sync: ${t.type} ${t.id} pulled Linear → local: ${t.localStatus} → ${resolvedFinal} (Linear: "${stateName}")`);
|
|
295
|
+
}
|
|
296
|
+
summary.updated++;
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
// Push: write Linear's state to match local.
|
|
300
|
+
if (!teamId) {
|
|
301
|
+
logger.warn(`linear sync: ${t.type} ${t.id} has local change (${t.localStatus}) but no linear.teamId configured — skipping push-back.`);
|
|
302
|
+
summary.pushFailures++;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const stateId = t.type === 'backlog'
|
|
306
|
+
? resolveBacklogStateIdForPush(config, resolvedFinal, getAutoStateIdMap(client))
|
|
307
|
+
: resolveTaskStateIdForPush(config, resolvedFinal, getAutoStateIdMap(client));
|
|
308
|
+
if (!stateId) {
|
|
309
|
+
logger.warn(`linear sync: ${t.type} ${t.id} local status "${resolvedFinal}" has no matching Linear state (check linear.pushStateIds or team workflow states). Skipping push-back.`);
|
|
310
|
+
summary.pushFailures++;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (!dryRun) {
|
|
314
|
+
try {
|
|
315
|
+
await updateLinearIssue(client, t.linearIssueId, { stateId });
|
|
316
|
+
await updateArtifactFields(projectDir, config, t.type, t.id, {
|
|
317
|
+
linearStatusReconciled: resolvedFinal,
|
|
318
|
+
linearStatusSyncedAt: new Date().toISOString(),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
catch (err) {
|
|
322
|
+
logger.warn(`linear sync: push-back failed for ${t.type} ${t.id} (${err.message}) — local frontmatter unchanged, will retry on next sync.`);
|
|
323
|
+
summary.pushFailures++;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (isVerbose()) {
|
|
328
|
+
logger.debug(`linear sync: ${t.type} ${t.id} pushed local → Linear: ${resolvedFinal} (was Linear: "${stateName}")`);
|
|
329
|
+
}
|
|
330
|
+
summary.pushedToLinear++;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (!dryRun && autoResolvedConflicts.length > 0) {
|
|
334
|
+
await appendStatusSyncConflictAudit(projectDir, autoResolvedConflicts);
|
|
335
|
+
}
|
|
336
|
+
return summary;
|
|
337
|
+
}
|
|
338
|
+
async function appendStatusSyncConflictAudit(projectDir, entries) {
|
|
339
|
+
const { appendFile, mkdir } = await import('node:fs/promises');
|
|
340
|
+
const path = await import('node:path');
|
|
341
|
+
const { existsSync } = await import('node:fs');
|
|
342
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
343
|
+
const dir = path.join(projectDir, '.planr', 'reports');
|
|
344
|
+
await mkdir(dir, { recursive: true });
|
|
345
|
+
const file = path.join(dir, `linear-sync-conflicts-${today}.md`);
|
|
346
|
+
const isNew = !existsSync(file);
|
|
347
|
+
const fmt = (v) => (v === undefined ? '—' : v);
|
|
348
|
+
const rows = entries
|
|
349
|
+
.map((e) => `| ${e.timestamp} | status | ${e.kind} ${e.id} | ${fmt(e.base)} | ${e.local} | ${e.remote} | ${e.chosen} |`)
|
|
350
|
+
.join('\n');
|
|
351
|
+
const header = isNew
|
|
352
|
+
? `# Linear sync conflict audit — ${today}\n\n> Auto-resolved conflicts from non-interactive \`planr linear sync\` runs. Each row is one artifact where local and Linear disagreed and the default resolution was picked without human confirmation.\n\n| timestamp | kind | artifact | base | local | remote | chosen |\n| --- | --- | --- | --- | --- | --- | --- |\n`
|
|
353
|
+
: '';
|
|
354
|
+
await appendFile(file, `${header}${rows}\n`, 'utf-8');
|
|
355
|
+
logger.dim(`Recorded ${entries.length} auto-resolved status conflict(s) to ${path.relative(projectDir, file)}`);
|
|
356
|
+
}
|
|
357
|
+
export function formatLinearStatusSyncLine(s) {
|
|
358
|
+
return `${s.updated} pulled from Linear, ${s.pushedToLinear} pushed to Linear, ${s.unchanged} unchanged, ${s.conflictDecisions} conflict(s), ${s.unmapped} unmapped, ${s.skippedNoId} skipped (no linearIssueId), ${s.missingFromApi} not returned by API${s.pushFailures > 0 ? `, ${s.pushFailures} push failure(s)` : ''}`;
|
|
359
|
+
}
|
|
360
|
+
function toDoneMap(parsed) {
|
|
361
|
+
return new Map(parsed.map((t) => [t.id, t.done]));
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Rebuild a `ParsedSubtask` list in document order: local file order, then any ids only in remote, then apply `final` done flags.
|
|
365
|
+
*/
|
|
366
|
+
export function mergeByIdForFormat(local, remote, final) {
|
|
367
|
+
const fromLocal = new Map(local.map((t) => [t.id, t]));
|
|
368
|
+
const out = [];
|
|
369
|
+
const used = new Set();
|
|
370
|
+
for (const t of local) {
|
|
371
|
+
if (!final.has(t.id) || used.has(t.id))
|
|
372
|
+
continue;
|
|
373
|
+
const d = final.get(t.id);
|
|
374
|
+
if (d === undefined)
|
|
375
|
+
continue;
|
|
376
|
+
out.push({ ...t, done: d });
|
|
377
|
+
used.add(t.id);
|
|
378
|
+
}
|
|
379
|
+
for (const t of remote) {
|
|
380
|
+
if (used.has(t.id) || !final.has(t.id))
|
|
381
|
+
continue;
|
|
382
|
+
const d = final.get(t.id);
|
|
383
|
+
if (d === undefined)
|
|
384
|
+
continue;
|
|
385
|
+
out.push({ ...(fromLocal.get(t.id) ?? t), done: d });
|
|
386
|
+
used.add(t.id);
|
|
387
|
+
}
|
|
388
|
+
return out;
|
|
389
|
+
}
|
|
390
|
+
/** Merged issue body: return text for an artifact’s section, or the whole body when a single file owns the issue. */
|
|
391
|
+
export function extractTaskSectionFromMergedDescription(merged, taskFileId, siblingFileCount) {
|
|
392
|
+
const token = `## ${taskFileId}`;
|
|
393
|
+
if (siblingFileCount === 1) {
|
|
394
|
+
if (!merged.includes('## ')) {
|
|
395
|
+
return merged.trim();
|
|
396
|
+
}
|
|
397
|
+
if (merged.includes(token)) {
|
|
398
|
+
return extractBlockAfterH2(merged, taskFileId);
|
|
399
|
+
}
|
|
400
|
+
return merged.trim();
|
|
401
|
+
}
|
|
402
|
+
if (merged.includes(token)) {
|
|
403
|
+
return extractBlockAfterH2(merged, taskFileId);
|
|
404
|
+
}
|
|
405
|
+
if (merged.includes('## ')) {
|
|
406
|
+
return '';
|
|
407
|
+
}
|
|
408
|
+
return merged.trim();
|
|
409
|
+
}
|
|
410
|
+
function extractBlockAfterH2(merged, taskFileId) {
|
|
411
|
+
const token = `## ${taskFileId}`;
|
|
412
|
+
const idx = merged.indexOf(token);
|
|
413
|
+
if (idx === -1) {
|
|
414
|
+
return merged.trim();
|
|
415
|
+
}
|
|
416
|
+
const after = merged.slice(idx + token.length).replace(/^\n+/, '');
|
|
417
|
+
const nextH2 = after.search(/^## /m);
|
|
418
|
+
return (nextH2 === -1 ? after : after.slice(0, nextH2)).trim();
|
|
419
|
+
}
|
|
420
|
+
export function replaceTaskSectionInMergedDescription(merged, taskFileId, newSectionBody) {
|
|
421
|
+
if (!merged.includes('## ')) {
|
|
422
|
+
return newSectionBody.trim();
|
|
423
|
+
}
|
|
424
|
+
const token = `## ${taskFileId}`;
|
|
425
|
+
const idx = merged.indexOf(token);
|
|
426
|
+
if (idx === -1) {
|
|
427
|
+
return newSectionBody.trim();
|
|
428
|
+
}
|
|
429
|
+
const before = merged.slice(0, idx);
|
|
430
|
+
const afterHeader = merged.slice(idx + token.length);
|
|
431
|
+
const nextH2 = afterHeader.search(/^## /m);
|
|
432
|
+
const tail = nextH2 === -1 ? '' : afterHeader.slice(nextH2);
|
|
433
|
+
return `${before}${token}\n\n${newSectionBody.trim()}\n\n${tail}`.replace(/\n\n\n+/g, '\n\n');
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Three-way merge for checkbox `id -> done` and presence. A key is **absent** in a version when the task line is not in that side’s parse.
|
|
437
|
+
* Exported for unit tests.
|
|
438
|
+
*/
|
|
439
|
+
export async function resolveTaskCheckboxFinalStates(local, remote, base, strategy, label, onAutoResolve) {
|
|
440
|
+
const ids = new Set([...local.keys(), ...remote.keys(), ...base.keys()]);
|
|
441
|
+
const final = new Map();
|
|
442
|
+
let conflictDecisions = 0;
|
|
443
|
+
for (const id of ids) {
|
|
444
|
+
const lh = local.has(id);
|
|
445
|
+
const rh = remote.has(id);
|
|
446
|
+
const bh = base.has(id);
|
|
447
|
+
const l = lh ? local.get(id) : undefined;
|
|
448
|
+
const r = rh ? remote.get(id) : undefined;
|
|
449
|
+
const b = bh ? base.get(id) : undefined;
|
|
450
|
+
if (lh && rh && l === r) {
|
|
451
|
+
if (l !== undefined) {
|
|
452
|
+
final.set(id, l);
|
|
453
|
+
}
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (!lh && !rh) {
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
if (b === l && l !== r) {
|
|
460
|
+
if (r !== undefined) {
|
|
461
|
+
final.set(id, r);
|
|
462
|
+
}
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
if (b === r && l !== r) {
|
|
466
|
+
if (l !== undefined) {
|
|
467
|
+
final.set(id, l);
|
|
468
|
+
}
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
if (bh && lh && !rh && l !== b && l !== undefined) {
|
|
472
|
+
final.set(id, l);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
if (bh && rh && !lh && r !== b && r !== undefined) {
|
|
476
|
+
final.set(id, r);
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
if (!bh && l !== undefined && r === undefined) {
|
|
480
|
+
final.set(id, l);
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
if (!bh && r !== undefined && l === undefined) {
|
|
484
|
+
final.set(id, r);
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
const choice = await pickConflict(strategy, { id, base: b, local: l, remote: r }, label);
|
|
488
|
+
if (l !== undefined && r !== undefined) {
|
|
489
|
+
final.set(id, choice === 'local' ? l : r);
|
|
490
|
+
}
|
|
491
|
+
else if (l !== undefined) {
|
|
492
|
+
final.set(id, l);
|
|
493
|
+
}
|
|
494
|
+
else if (r !== undefined) {
|
|
495
|
+
final.set(id, r);
|
|
496
|
+
}
|
|
497
|
+
conflictDecisions++;
|
|
498
|
+
// Record non-interactive auto-resolutions for the audit log. We
|
|
499
|
+
// only record when strategy was 'prompt' AND we're non-interactive (i.e.
|
|
500
|
+
// the default was picked without human input); explicit `--on-conflict
|
|
501
|
+
// local|linear` choices are user intent, not silent defaults.
|
|
502
|
+
if (onAutoResolve && strategy === 'prompt' && isNonInteractive()) {
|
|
503
|
+
onAutoResolve({
|
|
504
|
+
label,
|
|
505
|
+
id,
|
|
506
|
+
base: b,
|
|
507
|
+
local: l,
|
|
508
|
+
remote: r,
|
|
509
|
+
chosen: choice,
|
|
510
|
+
timestamp: new Date().toISOString(),
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return { final, conflictDecisions };
|
|
515
|
+
}
|
|
516
|
+
async function pickConflict(strategy, c, label) {
|
|
517
|
+
if (strategy === 'local') {
|
|
518
|
+
if (c.local === undefined)
|
|
519
|
+
return 'linear';
|
|
520
|
+
return 'local';
|
|
521
|
+
}
|
|
522
|
+
if (strategy === 'linear') {
|
|
523
|
+
if (c.remote === undefined)
|
|
524
|
+
return 'local';
|
|
525
|
+
return 'linear';
|
|
526
|
+
}
|
|
527
|
+
if (isNonInteractive()) {
|
|
528
|
+
logger.dim(` [auto] ${label} task ${c.id} conflict: using Linear (set --on-conflict local|linear)`);
|
|
529
|
+
return c.remote !== undefined ? 'linear' : 'local';
|
|
530
|
+
}
|
|
531
|
+
const def = c.remote !== undefined ? 'linear' : 'local';
|
|
532
|
+
return promptSelect(`${label}: checkbox conflict on ${c.id} (base=${String(c.base)} local=${String(c.local)} remote=${String(c.remote)}). Use which side?`, [
|
|
533
|
+
{ name: 'Local file', value: 'local' },
|
|
534
|
+
{ name: 'Linear', value: 'linear' },
|
|
535
|
+
], def);
|
|
536
|
+
}
|
|
537
|
+
const TASK_CHECKBOX = /^(\s*)- \[(x| )]\s+\*{0,2}(\d+\.\d+)\*{0,2}\s+(.+)$/;
|
|
538
|
+
/** Drop checkbox lines for ids that should be absent, apply done states, append new lines for ids in `rebuilt` that are still missing. */
|
|
539
|
+
export function applyCheckboxMergeToLocalBody(body, final, rebuilt) {
|
|
540
|
+
const lines = [];
|
|
541
|
+
for (const line of body.split('\n')) {
|
|
542
|
+
const m = line.match(TASK_CHECKBOX);
|
|
543
|
+
if (m) {
|
|
544
|
+
const id = m[3];
|
|
545
|
+
if (!final.has(id)) {
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
lines.push(line);
|
|
550
|
+
}
|
|
551
|
+
let out = lines.join('\n');
|
|
552
|
+
out = applyTaskCheckboxStateMap(out, final);
|
|
553
|
+
const present = new Set(parseTaskCheckboxLines(out).map((t) => t.id));
|
|
554
|
+
const toAdd = rebuilt.filter((t) => final.has(t.id) && !present.has(t.id));
|
|
555
|
+
if (toAdd.length === 0) {
|
|
556
|
+
return out;
|
|
557
|
+
}
|
|
558
|
+
const block = formatTaskCheckboxBody(toAdd);
|
|
559
|
+
if (!block) {
|
|
560
|
+
return out;
|
|
561
|
+
}
|
|
562
|
+
const trimmed = out.trimEnd();
|
|
563
|
+
return `${trimmed ? `${trimmed}\n\n` : ''}${block}\n`;
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* For each `task` artifact with `linearIssueId`, load the shared Linear description (once per issue id),
|
|
567
|
+
* reconcile checkboxes with the local file using three-way merge, then write back local and/or Linear.
|
|
568
|
+
*/
|
|
569
|
+
export async function runLinearTaskCheckboxSync(projectDir, config, client, opts = {}) {
|
|
570
|
+
const onConflict = opts.onConflict ?? 'prompt';
|
|
571
|
+
const dryRun = opts.dryRun === true;
|
|
572
|
+
const summary = {
|
|
573
|
+
filesProcessed: 0,
|
|
574
|
+
filesUpdatedLocal: 0,
|
|
575
|
+
linearIssuesUpdated: 0,
|
|
576
|
+
conflictDecisions: 0,
|
|
577
|
+
skippedNoIssue: 0,
|
|
578
|
+
skippedStaleId: 0,
|
|
579
|
+
};
|
|
580
|
+
// Collect non-interactive auto-resolutions so we can audit them to
|
|
581
|
+
// disk after the run (CI-friendly — logger.dim lines don't survive long).
|
|
582
|
+
const autoResolvedConflicts = [];
|
|
583
|
+
const allTasks = await listArtifacts(projectDir, config, 'task');
|
|
584
|
+
const byIssue = new Map();
|
|
585
|
+
for (const t of allTasks) {
|
|
586
|
+
const a = await readArtifact(projectDir, config, 'task', t.id);
|
|
587
|
+
const issueId = a?.data.linearIssueId;
|
|
588
|
+
if (!issueId) {
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
// Catch both known corruption modes before calling the Linear API:
|
|
592
|
+
// (a) a workflow state UUID accidentally stored in the issue-id slot, and
|
|
593
|
+
// (b) any value that doesn't match a valid Linear issue form (UUID or `ENG-42`).
|
|
594
|
+
if (isLikelyLinearWorkflowStateId(issueId)) {
|
|
595
|
+
summary.skippedStaleId++;
|
|
596
|
+
logger.warn(`Task ${t.id}: linearIssueId "${issueId}" looks like a workflow state uuid, not an issue id. Re-run \`planr linear push\` to repair.`);
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
if (!isLikelyLinearIssueId(issueId)) {
|
|
600
|
+
summary.skippedStaleId++;
|
|
601
|
+
logger.warn(`Task ${t.id}: linearIssueId "${issueId}" is not a valid Linear issue id (expected uuid or \`ENG-42\` identifier). Re-run \`planr linear push\` to repair.`);
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
if (!byIssue.has(issueId)) {
|
|
605
|
+
byIssue.set(issueId, []);
|
|
606
|
+
}
|
|
607
|
+
const group = byIssue.get(issueId);
|
|
608
|
+
if (group) {
|
|
609
|
+
group.push(t.id);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
for (const [issueId, taskIds] of byIssue) {
|
|
613
|
+
let merged = await getLinearIssueDescription(client, issueId);
|
|
614
|
+
const sortedFiles = [...taskIds].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
615
|
+
const siblingFileCount = sortedFiles.length;
|
|
616
|
+
let issueDirty = false;
|
|
617
|
+
for (const taskFileId of sortedFiles) {
|
|
618
|
+
summary.filesProcessed++;
|
|
619
|
+
const raw = await readArtifactRaw(projectDir, config, 'task', taskFileId);
|
|
620
|
+
if (!raw) {
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
const data = (await readArtifact(projectDir, config, 'task', taskFileId))?.data;
|
|
624
|
+
const li = data?.linearIssueId ?? issueId;
|
|
625
|
+
if (!li) {
|
|
626
|
+
summary.skippedNoIssue++;
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
const open = raw.indexOf('---');
|
|
630
|
+
const close = raw.indexOf('\n---', open + 3);
|
|
631
|
+
if (open === -1 || close === -1) {
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
const body = raw.slice(close + 4);
|
|
635
|
+
const localParsed = parseTaskMarkdown(body);
|
|
636
|
+
const localMap = toDoneMap(localParsed);
|
|
637
|
+
const section = extractTaskSectionFromMergedDescription(merged, taskFileId, siblingFileCount);
|
|
638
|
+
const remoteParsed = parseTaskMarkdown(section);
|
|
639
|
+
const remoteMap = toDoneMap(remoteParsed);
|
|
640
|
+
const baseStr = data?.linearChecklistReconciled ?? undefined;
|
|
641
|
+
const baseMap = parseTaskCheckboxReconciled(baseStr);
|
|
642
|
+
// A baseline was persisted but parses to ~nothing: it's likely
|
|
643
|
+
// corrupted (hand-edited frontmatter, truncated write, format drift).
|
|
644
|
+
// Warn because without a reliable base the 3-way merge degrades
|
|
645
|
+
// silently to a 2-way merge, losing the "last agreed" reference.
|
|
646
|
+
if (typeof baseStr === 'string' && baseStr.trim().length > 0) {
|
|
647
|
+
const expected = Math.max(localMap.size, remoteMap.size);
|
|
648
|
+
if (expected > 0 && baseMap.size * 2 < expected) {
|
|
649
|
+
logger.warn(`Task ${taskFileId}: linearChecklistReconciled looks corrupted (${baseMap.size} parsed vs ${expected} expected). Re-run \`planr linear push\` to restore the reconciliation baseline.`);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
const { final, conflictDecisions: cd } = await resolveTaskCheckboxFinalStates(localMap, remoteMap, baseMap, onConflict, taskFileId, (entry) => autoResolvedConflicts.push(entry));
|
|
653
|
+
summary.conflictDecisions += cd;
|
|
654
|
+
const rebuilt = mergeByIdForFormat(localParsed, remoteParsed, final);
|
|
655
|
+
const newSection = formatTaskCheckboxBody(rebuilt);
|
|
656
|
+
const newBody = applyCheckboxMergeToLocalBody(body, final, rebuilt);
|
|
657
|
+
const merged2 = replaceTaskSectionInMergedDescription(merged, taskFileId, newSection);
|
|
658
|
+
if (merged2 !== merged) {
|
|
659
|
+
merged = merged2;
|
|
660
|
+
issueDirty = true;
|
|
661
|
+
}
|
|
662
|
+
if (newBody !== body) {
|
|
663
|
+
if (!dryRun) {
|
|
664
|
+
const newRaw = raw.slice(0, close + 4) + newBody;
|
|
665
|
+
await updateArtifact(projectDir, config, 'task', taskFileId, newRaw);
|
|
666
|
+
}
|
|
667
|
+
summary.filesUpdatedLocal++;
|
|
668
|
+
}
|
|
669
|
+
const newRecon = serializeTaskCheckboxReconciled(final);
|
|
670
|
+
if (newRecon !== (baseStr ?? '')) {
|
|
671
|
+
if (!dryRun) {
|
|
672
|
+
await updateArtifactFields(projectDir, config, 'task', taskFileId, {
|
|
673
|
+
linearChecklistReconciled: newRecon,
|
|
674
|
+
linearTaskChecklistSyncedAt: new Date().toISOString(),
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
if (issueDirty) {
|
|
680
|
+
if (!dryRun) {
|
|
681
|
+
await updateLinearIssue(client, issueId, { description: merged });
|
|
682
|
+
}
|
|
683
|
+
summary.linearIssuesUpdated++;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
// Persist any non-interactive auto-resolutions to a Markdown audit log.
|
|
687
|
+
// Matches the filename convention used by revise's audit-log-service so users
|
|
688
|
+
// find it in the same `.planr/reports/` directory they already look at.
|
|
689
|
+
// Never mutates anything in dry-run mode.
|
|
690
|
+
if (!dryRun && autoResolvedConflicts.length > 0) {
|
|
691
|
+
await appendSyncConflictAudit(projectDir, autoResolvedConflicts);
|
|
692
|
+
}
|
|
693
|
+
return summary;
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Append a Markdown audit entry for non-interactive conflict auto-resolutions
|
|
697
|
+
* (M4). File is created on first write per day at
|
|
698
|
+
* `.planr/reports/linear-sync-conflicts-<YYYY-MM-DD>.md`. Appends preserve
|
|
699
|
+
* prior entries across multiple `planr linear sync` runs on the same day.
|
|
700
|
+
*/
|
|
701
|
+
async function appendSyncConflictAudit(projectDir, entries) {
|
|
702
|
+
const { appendFile, mkdir } = await import('node:fs/promises');
|
|
703
|
+
const path = await import('node:path');
|
|
704
|
+
const { existsSync } = await import('node:fs');
|
|
705
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
706
|
+
const dir = path.join(projectDir, '.planr', 'reports');
|
|
707
|
+
await mkdir(dir, { recursive: true });
|
|
708
|
+
const file = path.join(dir, `linear-sync-conflicts-${today}.md`);
|
|
709
|
+
const isNew = !existsSync(file);
|
|
710
|
+
const fmtBool = (v) => (v === undefined ? '—' : String(v));
|
|
711
|
+
const rows = entries
|
|
712
|
+
.map((e) => `| ${e.timestamp} | ${e.label} | ${e.id} | ${fmtBool(e.base)} | ${fmtBool(e.local)} | ${fmtBool(e.remote)} | ${e.chosen} |`)
|
|
713
|
+
.join('\n');
|
|
714
|
+
const header = isNew
|
|
715
|
+
? `# Linear sync conflict audit — ${today}\n\n> Auto-resolved conflicts from non-interactive \`planr linear sync\` runs. Each row is one checkbox where local and Linear disagreed and the default resolution was picked without human confirmation.\n\n| timestamp | task file | task id | base | local | remote | chosen |\n| --- | --- | --- | --- | --- | --- | --- |\n`
|
|
716
|
+
: '';
|
|
717
|
+
await appendFile(file, `${header}${rows}\n`, 'utf-8');
|
|
718
|
+
logger.dim(`Recorded ${entries.length} auto-resolved conflict(s) to ${path.relative(projectDir, file)}`);
|
|
719
|
+
}
|
|
720
|
+
//# sourceMappingURL=linear-pull-service.js.map
|