openplanr 1.2.8 → 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 +7 -3
- 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/system-prompts.d.ts +2 -2
- package/dist/ai/prompts/system-prompts.d.ts.map +1 -1
- package/dist/ai/prompts/system-prompts.js +7 -7
- package/dist/ai/schemas/ai-response-schemas.js +1 -1
- package/dist/ai/schemas/ai-response-schemas.js.map +1 -1
- package/dist/ai/types.d.ts.map +1 -1
- package/dist/ai/types.js +2 -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 +9 -8
- package/dist/cli/commands/revise.d.ts.map +1 -1
- package/dist/cli/commands/revise.js +93 -25
- package/dist/cli/commands/revise.js.map +1 -1
- package/dist/cli/index.js +2 -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 +179 -3
- 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 +2 -2
- package/dist/services/atomic-write-service.js +2 -2
- package/dist/services/audit-log-service.d.ts +3 -6
- package/dist/services/audit-log-service.d.ts.map +1 -1
- package/dist/services/audit-log-service.js +4 -7
- package/dist/services/audit-log-service.js.map +1 -1
- package/dist/services/cascade-service.d.ts +2 -2
- package/dist/services/cascade-service.js +3 -3
- package/dist/services/cascade-service.js.map +1 -1
- package/dist/services/credentials-service.js +2 -2
- package/dist/services/credentials-service.js.map +1 -1
- package/dist/services/diff-service.d.ts +1 -1
- package/dist/services/diff-service.js +1 -1
- package/dist/services/evidence-verifier.d.ts +1 -1
- package/dist/services/evidence-verifier.d.ts.map +1 -1
- package/dist/services/evidence-verifier.js +5 -2
- package/dist/services/evidence-verifier.js.map +1 -1
- package/dist/services/git-service.d.ts +4 -4
- package/dist/services/git-service.js +4 -4
- package/dist/services/graph-integrity.d.ts +2 -3
- package/dist/services/graph-integrity.d.ts.map +1 -1
- package/dist/services/graph-integrity.js +2 -3
- package/dist/services/graph-integrity.js.map +1 -1
- 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 +19 -0
- package/dist/services/prompt-service.d.ts.map +1 -1
- package/dist/services/prompt-service.js +64 -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 +1 -1
- package/dist/services/revise-cache-service.js +1 -1
- 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 +18 -11
- package/dist/services/revise-service.d.ts.map +1 -1
- package/dist/services/revise-service.js +57 -12
- package/dist/services/revise-service.js.map +1 -1
- package/dist/services/template-sections.d.ts +1 -1
- package/dist/services/template-sections.js +1 -1
- 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 +22 -1
- package/dist/utils/diff.d.ts.map +1 -1
- package/dist/utils/diff.js +136 -1
- package/dist/utils/diff.js.map +1 -1
- 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,956 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `planr linear push` — map Epic → Linear Project, Feature → top-level
|
|
3
|
+
* project issue, Story and TaskList → sub-issues of the feature issue.
|
|
4
|
+
*/
|
|
5
|
+
import { logger } from '../utils/logger.js';
|
|
6
|
+
import { findGherkinContent } from './artifact-gathering.js';
|
|
7
|
+
import { findArtifactTypeById, listArtifacts, readArtifact, updateArtifactFields, } from './artifact-service.js';
|
|
8
|
+
import { buildBacklogItemBody, buildEpicProjectDescription, buildFeatureIssueBody, buildMergedTaskListBody, buildStandaloneArtifactBody, buildStoryIssueBody, formatTaskCheckboxBody, toOptionalString, } from './linear/body-formatters.js';
|
|
9
|
+
import { resolveEstimateForPush } from './linear/estimate-resolver.js';
|
|
10
|
+
import { buildLinearPushPlan, getLinkedEpicId, } from './linear/plan-builders.js';
|
|
11
|
+
import { loadForBacklogItem, loadForFeature, loadForQuickTask, loadForStory, loadForTaskFile, loadLinearPushScope, } from './linear/scope-loaders.js';
|
|
12
|
+
import { contextFromMappedEpic, createTypeLabelCache, mergeLabelIds, readExistingLabelIds, } from './linear/strategy-context.js';
|
|
13
|
+
import { createLinearIssue, createLinearProject, createProjectMilestone, ensureIssueLabel, fetchTeamIssueEstimationType, fetchTeamWorkflowStates, isLikelyLinearIssueId, isLikelyLinearWorkflowStateId, updateLinearIssue, updateLinearProject, } from './linear-service.js';
|
|
14
|
+
export { buildBacklogItemBody, buildEpicProjectDescription, buildFeatureIssueBody, buildLinearPushPlan, buildMergedTaskListBody, buildStoryIssueBody, formatTaskCheckboxBody, loadForBacklogItem, loadForFeature, loadForQuickTask, loadForStory, loadForTaskFile, loadLinearPushScope, };
|
|
15
|
+
/**
|
|
16
|
+
* Decide whether a stored `linearIssueId` frontmatter value should be
|
|
17
|
+
* trusted for an update call, or treated as stale/corrupted so we fall
|
|
18
|
+
* through to the create path instead. Logs a warning either way so the user
|
|
19
|
+
* can spot the repair.
|
|
20
|
+
*/
|
|
21
|
+
function isUsableLinearIssueId(value, artifactLabel) {
|
|
22
|
+
if (!value)
|
|
23
|
+
return false;
|
|
24
|
+
if (!isLikelyLinearIssueId(value)) {
|
|
25
|
+
logger.warn(`${artifactLabel}: stored linearIssueId "${value}" is not a valid Linear id (expected uuid or \`ENG-42\` identifier). Falling through to the create path — re-push will write a fresh, valid id.`);
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
// StrategyContext is defined in `./linear/strategy-context.ts` and imported above.
|
|
31
|
+
function sortByArtifactId(a, b) {
|
|
32
|
+
return a.id.localeCompare(b.id, undefined, { numeric: true });
|
|
33
|
+
}
|
|
34
|
+
const STATUS_ALIASES = {
|
|
35
|
+
completed: 'done',
|
|
36
|
+
cancelled: 'done',
|
|
37
|
+
canceled: 'done',
|
|
38
|
+
todo: 'pending',
|
|
39
|
+
};
|
|
40
|
+
function asTaskStatus(s) {
|
|
41
|
+
if (s === 'pending' || s === 'in-progress' || s === 'done')
|
|
42
|
+
return s;
|
|
43
|
+
if (typeof s === 'string') {
|
|
44
|
+
const alias = STATUS_ALIASES[s.toLowerCase()];
|
|
45
|
+
if (alias)
|
|
46
|
+
return alias;
|
|
47
|
+
}
|
|
48
|
+
return 'pending';
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Derive a default status→stateId map from a team's workflow states. Used
|
|
52
|
+
* when the user hasn't configured `linear.pushStateIds` — lets `planr linear
|
|
53
|
+
* push` set workflow state out of the box.
|
|
54
|
+
*
|
|
55
|
+
* We pick the first state of each canonical Linear type so a team with
|
|
56
|
+
* multiple "unstarted" lanes (Todo + Backlog) or multiple "completed" lanes
|
|
57
|
+
* (Done + Released) gets a sensible default. Users who need different
|
|
58
|
+
* routing can override via `linear.pushStateIds` (which takes precedence).
|
|
59
|
+
*/
|
|
60
|
+
export function buildAutoPushStateIdMap(states) {
|
|
61
|
+
const firstByType = {};
|
|
62
|
+
for (const s of states) {
|
|
63
|
+
if (!firstByType[s.type])
|
|
64
|
+
firstByType[s.type] = s.id;
|
|
65
|
+
}
|
|
66
|
+
const out = {};
|
|
67
|
+
// Task vocabulary (feature/story/quick/task — via asTaskStatus normalization).
|
|
68
|
+
const pendingStateId = firstByType.unstarted ?? firstByType.backlog;
|
|
69
|
+
if (pendingStateId)
|
|
70
|
+
out.pending = pendingStateId;
|
|
71
|
+
if (firstByType.started)
|
|
72
|
+
out['in-progress'] = firstByType.started;
|
|
73
|
+
const doneStateId = firstByType.completed ?? firstByType.canceled;
|
|
74
|
+
if (doneStateId)
|
|
75
|
+
out.done = doneStateId;
|
|
76
|
+
// Backlog vocabulary — separate so BL push doesn't accidentally inherit
|
|
77
|
+
// task-shape defaults if a user has only `in-progress` mapped etc.
|
|
78
|
+
const openStateId = firstByType.backlog ?? firstByType.unstarted;
|
|
79
|
+
if (openStateId)
|
|
80
|
+
out.open = openStateId;
|
|
81
|
+
const closedStateId = firstByType.completed ?? firstByType.canceled;
|
|
82
|
+
if (closedStateId)
|
|
83
|
+
out.closed = closedStateId;
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* OpenPlanr status → Linear `stateId` for feature/story/quick/task push.
|
|
88
|
+
*
|
|
89
|
+
* Precedence: user config (`linear.pushStateIds` > `linear.statusMap` with
|
|
90
|
+
* uuid values) > auto-derived team map. Common aliases (`completed` →
|
|
91
|
+
* `done`, `todo` → `pending`, …) are normalized before lookup so hand-edited
|
|
92
|
+
* frontmatter using Linear-native vocabulary keeps working.
|
|
93
|
+
*/
|
|
94
|
+
export function resolveTaskStateIdForPush(config, status, autoMap) {
|
|
95
|
+
if (!status)
|
|
96
|
+
return undefined;
|
|
97
|
+
const s = asTaskStatus(status);
|
|
98
|
+
const push = config.linear?.pushStateIds;
|
|
99
|
+
if (push) {
|
|
100
|
+
const v = push[s] ?? push[status];
|
|
101
|
+
if (v)
|
|
102
|
+
return v;
|
|
103
|
+
}
|
|
104
|
+
const m = config.linear?.statusMap;
|
|
105
|
+
if (m) {
|
|
106
|
+
const v = m[s] ?? m[status];
|
|
107
|
+
if (v && isLikelyLinearWorkflowStateId(v))
|
|
108
|
+
return v;
|
|
109
|
+
}
|
|
110
|
+
if (autoMap) {
|
|
111
|
+
const v = autoMap[s] ?? autoMap[status];
|
|
112
|
+
if (v)
|
|
113
|
+
return v;
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* OpenPlanr status → Linear `stateId` for backlog push.
|
|
119
|
+
*
|
|
120
|
+
* BL uses `open | closed | promoted`, which don't map onto Linear's workflow
|
|
121
|
+
* vocabulary. We look up the raw key in `pushStateIds` → `statusMap` →
|
|
122
|
+
* auto-derived team map. No coercion into task vocabulary.
|
|
123
|
+
*/
|
|
124
|
+
export function resolveBacklogStateIdForPush(config, status, autoMap) {
|
|
125
|
+
if (!status)
|
|
126
|
+
return undefined;
|
|
127
|
+
const push = config.linear?.pushStateIds;
|
|
128
|
+
if (push?.[status])
|
|
129
|
+
return push[status];
|
|
130
|
+
const m = config.linear?.statusMap;
|
|
131
|
+
const raw = m?.[status];
|
|
132
|
+
if (raw && isLikelyLinearWorkflowStateId(raw))
|
|
133
|
+
return raw;
|
|
134
|
+
if (autoMap?.[status])
|
|
135
|
+
return autoMap[status];
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Back-compat alias: the original name used by feature/story call sites.
|
|
140
|
+
* New code should prefer `resolveTaskStateIdForPush` for clarity.
|
|
141
|
+
*/
|
|
142
|
+
const resolveStateIdForPush = resolveTaskStateIdForPush;
|
|
143
|
+
/**
|
|
144
|
+
* Per-client cache for the auto-derived status→stateId map. Populated once
|
|
145
|
+
* per push run by `ensureAutoStateIdMap` and read by every resolver call
|
|
146
|
+
* site. Scoped to the LinearClient instance so tests that construct fresh
|
|
147
|
+
* clients get fresh caches; production CLI creates one client per command
|
|
148
|
+
* invocation so the map is effectively per-run.
|
|
149
|
+
*
|
|
150
|
+
* Using a WeakMap (instead of threading the value through every pushOne*
|
|
151
|
+
* signature) keeps the surface area minimal and matches the existing
|
|
152
|
+
* `ensureIssueLabel` cache pattern at the StrategyContext layer.
|
|
153
|
+
*/
|
|
154
|
+
const autoStateIdMapCache = new WeakMap();
|
|
155
|
+
/**
|
|
156
|
+
* Per-client cache for the team's issue estimation type (fibonacci / linear /
|
|
157
|
+
* exponential / tShirt / notUsed). Populated once per push run alongside the
|
|
158
|
+
* state-id map; read by every resolver call site that wants to set an
|
|
159
|
+
* estimate on the Linear issue.
|
|
160
|
+
*/
|
|
161
|
+
const teamEstimationTypeCache = new WeakMap();
|
|
162
|
+
/**
|
|
163
|
+
* Per-client latch so the "t-shirt scale — estimates skipped" warning fires
|
|
164
|
+
* exactly once per push run, no matter how many artifacts are in the scope.
|
|
165
|
+
*/
|
|
166
|
+
const tShirtWarningLatch = new WeakSet();
|
|
167
|
+
/**
|
|
168
|
+
* Populate the per-client auto-map. Called once at the top of
|
|
169
|
+
* `runLinearPush` — a single extra API round-trip buys zero-config status
|
|
170
|
+
* sync. Failures are swallowed to keep push working: if Linear rejects the
|
|
171
|
+
* team-states query, the map stays empty and status updates become opt-in
|
|
172
|
+
* via `linear.pushStateIds` as before.
|
|
173
|
+
*/
|
|
174
|
+
export async function ensureAutoStateIdMap(client, teamId) {
|
|
175
|
+
if (autoStateIdMapCache.has(client))
|
|
176
|
+
return;
|
|
177
|
+
try {
|
|
178
|
+
const states = await fetchTeamWorkflowStates(client, teamId);
|
|
179
|
+
autoStateIdMapCache.set(client, buildAutoPushStateIdMap(states));
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
logger.debug('linear push: could not auto-derive pushStateIds from team workflow states', err);
|
|
183
|
+
autoStateIdMapCache.set(client, {});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Populate the per-client estimation-type cache. Failures degrade to
|
|
188
|
+
* `'notUsed'` so estimate is simply omitted rather than blocking the push.
|
|
189
|
+
*/
|
|
190
|
+
export async function ensureTeamEstimationType(client, teamId) {
|
|
191
|
+
if (teamEstimationTypeCache.has(client))
|
|
192
|
+
return;
|
|
193
|
+
try {
|
|
194
|
+
const scale = await fetchTeamIssueEstimationType(client, teamId);
|
|
195
|
+
teamEstimationTypeCache.set(client, scale);
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
logger.debug('linear push: could not fetch team estimation type', err);
|
|
199
|
+
teamEstimationTypeCache.set(client, 'notUsed');
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
export function getAutoStateIdMap(client) {
|
|
203
|
+
if (!client)
|
|
204
|
+
return undefined;
|
|
205
|
+
return autoStateIdMapCache.get(client);
|
|
206
|
+
}
|
|
207
|
+
function getTeamEstimationType(client) {
|
|
208
|
+
if (!client)
|
|
209
|
+
return undefined;
|
|
210
|
+
return teamEstimationTypeCache.get(client);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Resolve an artifact's estimate for push against the team's scale. Emits
|
|
214
|
+
* debug logs for snap transformations and a single-shot warning for t-shirt
|
|
215
|
+
* teams; centralizes call-site boilerplate so every push path (FEAT / US /
|
|
216
|
+
* QT / BL) can be one `buildEstimateInput(...)` call.
|
|
217
|
+
*/
|
|
218
|
+
function buildEstimateInput(client, frontmatter, artifactId) {
|
|
219
|
+
const scale = getTeamEstimationType(client);
|
|
220
|
+
const resolution = resolveEstimateForPush(frontmatter, scale);
|
|
221
|
+
if (resolution.kind === 'mapped') {
|
|
222
|
+
if (resolution.snapped) {
|
|
223
|
+
logger.debug(`linear push: ${artifactId} estimate snapped ${resolution.originalValue} → ${resolution.estimate} (scale=${scale})`);
|
|
224
|
+
}
|
|
225
|
+
return { resolution, fieldPatch: { estimate: resolution.estimate } };
|
|
226
|
+
}
|
|
227
|
+
if (resolution.kind === 'skip-t-shirt' && !tShirtWarningLatch.has(client)) {
|
|
228
|
+
tShirtWarningLatch.add(client);
|
|
229
|
+
logger.warn('linear push: team uses t-shirt estimation scale — skipping estimate field on all artifacts (no reliable numeric → XS/S/M/L/XL mapping). Configure `linear.pushStateIds` with explicit values to override.');
|
|
230
|
+
}
|
|
231
|
+
if (resolution.kind === 'skip-invalid-value') {
|
|
232
|
+
logger.debug(`linear push: ${artifactId} has unparseable estimate "${String(resolution.rawValue)}" — skipping field`);
|
|
233
|
+
}
|
|
234
|
+
return { resolution, fieldPatch: {} };
|
|
235
|
+
}
|
|
236
|
+
// Plan builders live in `./linear/plan-builders.ts` and are re-exported above.
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Per-feature / per-story / per-tasklist push primitives.
|
|
239
|
+
// Shared between epic-scope pushes (which loop over features) and granular
|
|
240
|
+
// scope pushes (which push a single feature / story / task subtree).
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
/**
|
|
243
|
+
* Push one feature issue and its descendants (stories + merged tasklist) under
|
|
244
|
+
* an already-resolved Linear project. Returns the feature's Linear issue id,
|
|
245
|
+
* or `null` when `updateOnly` is set and the feature has no prior linear link
|
|
246
|
+
* (caller decides whether to propagate the skip).
|
|
247
|
+
*
|
|
248
|
+
* Strategy propagation: when `strategyCtx.strategy === 'milestone-of'` the
|
|
249
|
+
* feature issue gets `projectMilestoneId` set; when `'label-on'` the epic's
|
|
250
|
+
* label is merged into the issue's labelIds (existing labels preserved).
|
|
251
|
+
*/
|
|
252
|
+
async function pushOneFeatureAndDescendants(projectDir, config, client, sf, strategyCtx, typeLabelCache, teamId, updateOnly) {
|
|
253
|
+
const f = sf.data;
|
|
254
|
+
const featureTitle = f.title.trim();
|
|
255
|
+
const featureBody = buildFeatureIssueBody(f);
|
|
256
|
+
const stateF = resolveStateIdForPush(config, f.status, getAutoStateIdMap(client));
|
|
257
|
+
const estimatePatch = buildEstimateInput(client, sf.frontmatter, f.id).fieldPatch;
|
|
258
|
+
const { projectId } = strategyCtx;
|
|
259
|
+
const typeLabelId = await typeLabelCache('feature');
|
|
260
|
+
let featureIssueId;
|
|
261
|
+
if (isUsableLinearIssueId(f.linearIssueId, `Feature ${f.id}`)) {
|
|
262
|
+
const existingLabels = await readExistingLabelIds(client, f.linearIssueId);
|
|
263
|
+
const labelIds = mergeLabelIds(mergeLabelIds(existingLabels, typeLabelId), strategyCtx.strategy === 'label-on' ? strategyCtx.labelId : undefined);
|
|
264
|
+
const u = await updateLinearIssue(client, f.linearIssueId, {
|
|
265
|
+
title: featureTitle,
|
|
266
|
+
description: featureBody,
|
|
267
|
+
projectId,
|
|
268
|
+
teamId,
|
|
269
|
+
// Linear rejects `stateId: null` on update (InvalidInput). Omit when
|
|
270
|
+
// unmapped so the issue keeps its current state.
|
|
271
|
+
...(stateF ? { stateId: stateF } : {}),
|
|
272
|
+
...estimatePatch,
|
|
273
|
+
projectMilestoneId: strategyCtx.milestoneId ?? null,
|
|
274
|
+
labelIds,
|
|
275
|
+
});
|
|
276
|
+
featureIssueId = u.id;
|
|
277
|
+
const fmUpdate = {
|
|
278
|
+
linearIssueId: u.id,
|
|
279
|
+
linearIssueIdentifier: u.identifier,
|
|
280
|
+
linearIssueUrl: u.url,
|
|
281
|
+
linearLabelIds: labelIds,
|
|
282
|
+
};
|
|
283
|
+
if (strategyCtx.milestoneId)
|
|
284
|
+
fmUpdate.linearProjectMilestoneId = strategyCtx.milestoneId;
|
|
285
|
+
await updateArtifactFields(projectDir, config, 'feature', f.id, fmUpdate);
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
if (updateOnly) {
|
|
289
|
+
logger.warn(`Update-only: skipping feature ${f.id} (no linearIssueId) — not creating it; stories and tasks under this feature are skipped.`);
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
const initialLabelIds = mergeLabelIds([typeLabelId], strategyCtx.strategy === 'label-on' ? strategyCtx.labelId : undefined);
|
|
293
|
+
const c = await createLinearIssue(client, {
|
|
294
|
+
teamId,
|
|
295
|
+
projectId,
|
|
296
|
+
title: featureTitle,
|
|
297
|
+
description: featureBody,
|
|
298
|
+
...(stateF ? { stateId: stateF } : {}),
|
|
299
|
+
...estimatePatch,
|
|
300
|
+
...(strategyCtx.milestoneId ? { projectMilestoneId: strategyCtx.milestoneId } : {}),
|
|
301
|
+
labelIds: initialLabelIds,
|
|
302
|
+
});
|
|
303
|
+
featureIssueId = c.id;
|
|
304
|
+
const fmUpdate = {
|
|
305
|
+
linearIssueId: c.id,
|
|
306
|
+
linearIssueIdentifier: c.identifier,
|
|
307
|
+
linearIssueUrl: c.url,
|
|
308
|
+
linearLabelIds: initialLabelIds,
|
|
309
|
+
};
|
|
310
|
+
if (strategyCtx.milestoneId)
|
|
311
|
+
fmUpdate.linearProjectMilestoneId = strategyCtx.milestoneId;
|
|
312
|
+
await updateArtifactFields(projectDir, config, 'feature', f.id, fmUpdate);
|
|
313
|
+
}
|
|
314
|
+
for (const st of sf.stories) {
|
|
315
|
+
await pushOneStoryUnderFeature(projectDir, config, client, st, featureIssueId, strategyCtx, typeLabelCache, teamId, updateOnly);
|
|
316
|
+
}
|
|
317
|
+
await pushOneTaskListForFeature(projectDir, config, client, sf, featureIssueId, strategyCtx, typeLabelCache, teamId, updateOnly);
|
|
318
|
+
return featureIssueId;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Create or update one story sub-issue under a resolved feature Linear parent.
|
|
322
|
+
* Inherits milestone/label attributes from the containing epic via `strategyCtx`.
|
|
323
|
+
*/
|
|
324
|
+
async function pushOneStoryUnderFeature(projectDir, config, client, scopedStory, featureIssueId, strategyCtx, typeLabelCache, teamId, updateOnly) {
|
|
325
|
+
const s = scopedStory.data;
|
|
326
|
+
const storyTitle = s.title.trim();
|
|
327
|
+
// Load the story's Gherkin scenarios from `<storyId>-gherkin.feature` if
|
|
328
|
+
// it exists. OpenPlanr's convention stores real acceptance criteria as
|
|
329
|
+
// Gherkin in a sibling file; without this, stories followed-by-convention
|
|
330
|
+
// pushed empty bodies to Linear.
|
|
331
|
+
const gherkinContent = await findGherkinContent(projectDir, config, s.id);
|
|
332
|
+
const storyBody = buildStoryIssueBody(s, gherkinContent);
|
|
333
|
+
const stateS = resolveStateIdForPush(config, s.status, getAutoStateIdMap(client));
|
|
334
|
+
const estimatePatch = buildEstimateInput(client, scopedStory.frontmatter, s.id).fieldPatch;
|
|
335
|
+
const { projectId } = strategyCtx;
|
|
336
|
+
const typeLabelId = await typeLabelCache('story');
|
|
337
|
+
if (isUsableLinearIssueId(s.linearIssueId, `Story ${s.id}`)) {
|
|
338
|
+
const existingLabels = await readExistingLabelIds(client, s.linearIssueId);
|
|
339
|
+
const labelIds = mergeLabelIds(mergeLabelIds(existingLabels, typeLabelId), strategyCtx.strategy === 'label-on' ? strategyCtx.labelId : undefined);
|
|
340
|
+
const u = await updateLinearIssue(client, s.linearIssueId, {
|
|
341
|
+
title: storyTitle,
|
|
342
|
+
description: storyBody,
|
|
343
|
+
projectId,
|
|
344
|
+
teamId,
|
|
345
|
+
parentId: featureIssueId,
|
|
346
|
+
// Linear rejects `stateId: null` on update (InvalidInput). Omit when
|
|
347
|
+
// unmapped so the issue keeps its current state.
|
|
348
|
+
...(stateS ? { stateId: stateS } : {}),
|
|
349
|
+
...estimatePatch,
|
|
350
|
+
projectMilestoneId: strategyCtx.milestoneId ?? null,
|
|
351
|
+
labelIds,
|
|
352
|
+
});
|
|
353
|
+
const fmUpdate = {
|
|
354
|
+
linearIssueId: u.id,
|
|
355
|
+
linearIssueIdentifier: u.identifier,
|
|
356
|
+
linearIssueUrl: u.url,
|
|
357
|
+
linearParentIssueId: featureIssueId,
|
|
358
|
+
linearLabelIds: labelIds,
|
|
359
|
+
};
|
|
360
|
+
if (strategyCtx.milestoneId)
|
|
361
|
+
fmUpdate.linearProjectMilestoneId = strategyCtx.milestoneId;
|
|
362
|
+
await updateArtifactFields(projectDir, config, 'story', s.id, fmUpdate);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (updateOnly) {
|
|
366
|
+
logger.warn(`Update-only: skipping story ${s.id} (no linearIssueId).`);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const initialLabelIds = mergeLabelIds([typeLabelId], strategyCtx.strategy === 'label-on' ? strategyCtx.labelId : undefined);
|
|
370
|
+
const c = await createLinearIssue(client, {
|
|
371
|
+
teamId,
|
|
372
|
+
projectId,
|
|
373
|
+
parentId: featureIssueId,
|
|
374
|
+
title: storyTitle,
|
|
375
|
+
description: storyBody,
|
|
376
|
+
...(stateS ? { stateId: stateS } : {}),
|
|
377
|
+
...estimatePatch,
|
|
378
|
+
...(strategyCtx.milestoneId ? { projectMilestoneId: strategyCtx.milestoneId } : {}),
|
|
379
|
+
labelIds: initialLabelIds,
|
|
380
|
+
});
|
|
381
|
+
const fmUpdate = {
|
|
382
|
+
linearIssueId: c.id,
|
|
383
|
+
linearIssueIdentifier: c.identifier,
|
|
384
|
+
linearIssueUrl: c.url,
|
|
385
|
+
linearParentIssueId: featureIssueId,
|
|
386
|
+
linearLabelIds: initialLabelIds,
|
|
387
|
+
};
|
|
388
|
+
if (strategyCtx.milestoneId)
|
|
389
|
+
fmUpdate.linearProjectMilestoneId = strategyCtx.milestoneId;
|
|
390
|
+
await updateArtifactFields(projectDir, config, 'story', s.id, fmUpdate);
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Create or update the single "Tasks for <feature>" sub-issue that aggregates
|
|
394
|
+
* all task-file checkboxes for a feature. Merges all task files sharing this
|
|
395
|
+
* feature into one body. Returns without writing when there is nothing to
|
|
396
|
+
* push and no existing linear issue to keep in sync.
|
|
397
|
+
*/
|
|
398
|
+
async function pushOneTaskListForFeature(projectDir, config, client, sf, featureIssueId, strategyCtx, typeLabelCache, teamId, updateOnly) {
|
|
399
|
+
// Note: estimate sync is intentionally not applied here. A single Linear
|
|
400
|
+
// TaskList issue aggregates `sf.taskFiles` — multiple TASK-*.md files —
|
|
401
|
+
// so a 1:1 numeric estimate mapping doesn't apply. Aggregation rules
|
|
402
|
+
// (sum? max? per-file-section?) are their own concern.
|
|
403
|
+
const f = sf.data;
|
|
404
|
+
const { projectId } = strategyCtx;
|
|
405
|
+
const mergedBody = await buildMergedTaskListBody(projectDir, config, f.id, sf.taskFiles);
|
|
406
|
+
const issueFromFiles = await Promise.all(sf.taskFiles.map(async (tf) => {
|
|
407
|
+
const a = await readArtifact(projectDir, config, 'task', tf.id);
|
|
408
|
+
return toOptionalString(a?.data.linearIssueId);
|
|
409
|
+
}));
|
|
410
|
+
const rawExistingTaskIssueId = issueFromFiles.find(Boolean);
|
|
411
|
+
const existingTaskIssueId = isUsableLinearIssueId(rawExistingTaskIssueId, `TaskList under ${f.id}`)
|
|
412
|
+
? rawExistingTaskIssueId
|
|
413
|
+
: undefined;
|
|
414
|
+
if (!mergedBody.trim() && !existingTaskIssueId) {
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const title = sf.taskFiles.length > 1
|
|
418
|
+
? `Tasks: ${f.title} (${sf.taskFiles.length} files)`
|
|
419
|
+
: `Tasks: ${f.title}`;
|
|
420
|
+
const typeLabelId = await typeLabelCache('task');
|
|
421
|
+
if (existingTaskIssueId) {
|
|
422
|
+
const existingLabels = await readExistingLabelIds(client, existingTaskIssueId);
|
|
423
|
+
const labelIds = mergeLabelIds(mergeLabelIds(existingLabels, typeLabelId), strategyCtx.strategy === 'label-on' ? strategyCtx.labelId : undefined);
|
|
424
|
+
const u = await updateLinearIssue(client, existingTaskIssueId, {
|
|
425
|
+
title,
|
|
426
|
+
description: mergedBody || '_No open tasks in OpenPlanr task file(s)._',
|
|
427
|
+
projectId,
|
|
428
|
+
teamId,
|
|
429
|
+
parentId: featureIssueId,
|
|
430
|
+
projectMilestoneId: strategyCtx.milestoneId ?? null,
|
|
431
|
+
labelIds,
|
|
432
|
+
});
|
|
433
|
+
const synced = new Date().toISOString();
|
|
434
|
+
for (const tf of sf.taskFiles) {
|
|
435
|
+
const fmUpdate = {
|
|
436
|
+
linearIssueId: u.id,
|
|
437
|
+
linearIssueIdentifier: u.identifier,
|
|
438
|
+
linearIssueUrl: u.url,
|
|
439
|
+
linearParentIssueId: featureIssueId,
|
|
440
|
+
linearTaskChecklistSyncedAt: synced,
|
|
441
|
+
linearLabelIds: labelIds,
|
|
442
|
+
};
|
|
443
|
+
if (strategyCtx.milestoneId)
|
|
444
|
+
fmUpdate.linearProjectMilestoneId = strategyCtx.milestoneId;
|
|
445
|
+
await updateArtifactFields(projectDir, config, 'task', tf.id, fmUpdate);
|
|
446
|
+
}
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (updateOnly) {
|
|
450
|
+
logger.warn(`Update-only: skipping task list issue for feature ${f.id} (no existing linearIssueId on task files).`);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const initialLabelIds = mergeLabelIds([typeLabelId], strategyCtx.strategy === 'label-on' ? strategyCtx.labelId : undefined);
|
|
454
|
+
const c = await createLinearIssue(client, {
|
|
455
|
+
teamId,
|
|
456
|
+
projectId,
|
|
457
|
+
parentId: featureIssueId,
|
|
458
|
+
title,
|
|
459
|
+
description: mergedBody,
|
|
460
|
+
...(strategyCtx.milestoneId ? { projectMilestoneId: strategyCtx.milestoneId } : {}),
|
|
461
|
+
labelIds: initialLabelIds,
|
|
462
|
+
});
|
|
463
|
+
const synced = new Date().toISOString();
|
|
464
|
+
for (const tf of sf.taskFiles) {
|
|
465
|
+
const fmUpdate = {
|
|
466
|
+
linearIssueId: c.id,
|
|
467
|
+
linearIssueIdentifier: c.identifier,
|
|
468
|
+
linearIssueUrl: c.url,
|
|
469
|
+
linearParentIssueId: featureIssueId,
|
|
470
|
+
linearTaskChecklistSyncedAt: synced,
|
|
471
|
+
linearLabelIds: initialLabelIds,
|
|
472
|
+
};
|
|
473
|
+
if (strategyCtx.milestoneId)
|
|
474
|
+
fmUpdate.linearProjectMilestoneId = strategyCtx.milestoneId;
|
|
475
|
+
await updateArtifactFields(projectDir, config, 'task', tf.id, fmUpdate);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Epic-scope push: resolves the mapping strategy (first-time choice persisted,
|
|
480
|
+
* subsequent runs read from frontmatter), creates/updates the Linear container
|
|
481
|
+
* (project + optional milestone or label), and cascades through every feature.
|
|
482
|
+
*/
|
|
483
|
+
async function pushEpicScope(projectDir, config, client, epicId, updateOnly, teamId, leadId, override) {
|
|
484
|
+
const scope = await loadLinearPushScope(projectDir, config, epicId);
|
|
485
|
+
if (!scope) {
|
|
486
|
+
throw new Error(`Epic not found: ${epicId}`);
|
|
487
|
+
}
|
|
488
|
+
const { epic, features } = scope;
|
|
489
|
+
const plan = await buildLinearPushPlan(projectDir, config, epicId, { updateOnly });
|
|
490
|
+
if (!plan)
|
|
491
|
+
return null;
|
|
492
|
+
// Resolve strategy: stored on epic > override > config default > 'project'.
|
|
493
|
+
const stored = epic.linearMappingStrategy;
|
|
494
|
+
const chosen = stored ?? override?.strategy ?? config.linear?.defaultEpicStrategy ?? 'project';
|
|
495
|
+
// Re-strategizing is out of scope for this release — refuse to silently
|
|
496
|
+
// migrate an epic to a different mapping.
|
|
497
|
+
if (stored && override?.strategy && override.strategy !== stored) {
|
|
498
|
+
throw new Error(`Epic ${epic.id} is already mapped as '${stored}'. Re-strategizing to '${override.strategy}' is not supported in this release. Use \`planr linear unlink ${epic.id}\` + re-push once that command arrives.`);
|
|
499
|
+
}
|
|
500
|
+
if (updateOnly && !epic.linearProjectId) {
|
|
501
|
+
throw new Error('Cannot use --update-only: this epic has no `linearProjectId` in frontmatter. Run `planr linear push` without --update-only once to create the Linear project.');
|
|
502
|
+
}
|
|
503
|
+
const epicName = epic.title.trim() || epic.id;
|
|
504
|
+
const projectDescription = buildEpicProjectDescription(epic);
|
|
505
|
+
const typeLabelCache = createTypeLabelCache(client, teamId, config);
|
|
506
|
+
let strategyCtx;
|
|
507
|
+
if (chosen === 'project') {
|
|
508
|
+
let projectId;
|
|
509
|
+
if (epic.linearProjectId) {
|
|
510
|
+
const updated = await updateLinearProject(client, epic.linearProjectId, {
|
|
511
|
+
name: epicName,
|
|
512
|
+
description: projectDescription,
|
|
513
|
+
leadId: leadId ?? null,
|
|
514
|
+
});
|
|
515
|
+
projectId = updated.id;
|
|
516
|
+
await updateArtifactFields(projectDir, config, 'epic', epic.id, {
|
|
517
|
+
linearProjectId: updated.id,
|
|
518
|
+
linearProjectIdentifier: updated.identifier,
|
|
519
|
+
linearProjectUrl: updated.url,
|
|
520
|
+
linearMappingStrategy: chosen,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
else {
|
|
524
|
+
const created = await createLinearProject(client, {
|
|
525
|
+
name: epicName,
|
|
526
|
+
teamIds: [teamId],
|
|
527
|
+
description: projectDescription,
|
|
528
|
+
leadId: leadId ?? null,
|
|
529
|
+
});
|
|
530
|
+
projectId = created.id;
|
|
531
|
+
await updateArtifactFields(projectDir, config, 'epic', epic.id, {
|
|
532
|
+
linearProjectId: created.id,
|
|
533
|
+
linearProjectIdentifier: created.identifier,
|
|
534
|
+
linearProjectUrl: created.url,
|
|
535
|
+
linearMappingStrategy: chosen,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
strategyCtx = { strategy: 'project', projectId };
|
|
539
|
+
}
|
|
540
|
+
else if (chosen === 'milestone-of') {
|
|
541
|
+
const targetProjectId = epic.linearProjectId ?? override?.targetProjectId;
|
|
542
|
+
if (!targetProjectId) {
|
|
543
|
+
throw new Error(`milestone-of strategy requires a Linear project to attach into. Re-run with \`--as milestone-of:<projectId>\`.`);
|
|
544
|
+
}
|
|
545
|
+
let milestoneId = epic.linearMilestoneId;
|
|
546
|
+
if (!milestoneId) {
|
|
547
|
+
const m = await createProjectMilestone(client, {
|
|
548
|
+
projectId: targetProjectId,
|
|
549
|
+
name: epicName,
|
|
550
|
+
description: projectDescription,
|
|
551
|
+
});
|
|
552
|
+
milestoneId = m.id;
|
|
553
|
+
}
|
|
554
|
+
await updateArtifactFields(projectDir, config, 'epic', epic.id, {
|
|
555
|
+
linearProjectId: targetProjectId,
|
|
556
|
+
linearMilestoneId: milestoneId,
|
|
557
|
+
linearMappingStrategy: chosen,
|
|
558
|
+
});
|
|
559
|
+
strategyCtx = { strategy: 'milestone-of', projectId: targetProjectId, milestoneId };
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
// label-on
|
|
563
|
+
const targetProjectId = epic.linearProjectId ?? override?.targetProjectId;
|
|
564
|
+
if (!targetProjectId) {
|
|
565
|
+
throw new Error(`label-on strategy requires a Linear project to attach into. Re-run with \`--as label-on:<projectId>\`.`);
|
|
566
|
+
}
|
|
567
|
+
const label = await ensureIssueLabel(client, {
|
|
568
|
+
teamId,
|
|
569
|
+
name: epicName,
|
|
570
|
+
description: `OpenPlanr epic ${epic.id} (auto-created by \`planr linear push\`).`,
|
|
571
|
+
});
|
|
572
|
+
await updateArtifactFields(projectDir, config, 'epic', epic.id, {
|
|
573
|
+
linearProjectId: targetProjectId,
|
|
574
|
+
linearLabelId: label.id,
|
|
575
|
+
linearMappingStrategy: chosen,
|
|
576
|
+
});
|
|
577
|
+
strategyCtx = { strategy: 'label-on', projectId: targetProjectId, labelId: label.id };
|
|
578
|
+
}
|
|
579
|
+
for (const sf of features) {
|
|
580
|
+
await pushOneFeatureAndDescendants(projectDir, config, client, sf, strategyCtx, typeLabelCache, teamId, updateOnly);
|
|
581
|
+
}
|
|
582
|
+
// Cascade to any QT / BL artifacts explicitly linked via `epicId: <this epic>`.
|
|
583
|
+
// Unlinked QT/BL stay in their standalone project; only opt-in children are
|
|
584
|
+
// pulled into the epic's Linear container.
|
|
585
|
+
const quicks = await listArtifacts(projectDir, config, 'quick');
|
|
586
|
+
for (const q of quicks.sort(sortByArtifactId)) {
|
|
587
|
+
const art = await readArtifact(projectDir, config, 'quick', q.id);
|
|
588
|
+
if (!art || getLinkedEpicId(art.data) !== epic.id)
|
|
589
|
+
continue;
|
|
590
|
+
try {
|
|
591
|
+
const qt = await loadForQuickTask(projectDir, config, q.id);
|
|
592
|
+
if (!qt)
|
|
593
|
+
continue;
|
|
594
|
+
await pushOneQuickTaskWithContext(projectDir, config, client, qt, strategyCtx, typeLabelCache, teamId, updateOnly);
|
|
595
|
+
}
|
|
596
|
+
catch (err) {
|
|
597
|
+
// Keep the cascade going — a single malformed QT shouldn't abort the
|
|
598
|
+
// whole epic push. Surface the reason so the operator can fix it.
|
|
599
|
+
logger.warn(`Skipping quick task ${q.id} in epic cascade: ${err instanceof Error ? err.message : String(err)}`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
const backlogs = await listArtifacts(projectDir, config, 'backlog');
|
|
603
|
+
for (const b of backlogs.sort(sortByArtifactId)) {
|
|
604
|
+
const art = await readArtifact(projectDir, config, 'backlog', b.id);
|
|
605
|
+
if (!art || getLinkedEpicId(art.data) !== epic.id)
|
|
606
|
+
continue;
|
|
607
|
+
try {
|
|
608
|
+
const bl = await loadForBacklogItem(projectDir, config, b.id);
|
|
609
|
+
if (!bl)
|
|
610
|
+
continue;
|
|
611
|
+
await pushOneBacklogItemWithContext(projectDir, config, client, bl, strategyCtx, typeLabelCache, teamId, updateOnly);
|
|
612
|
+
}
|
|
613
|
+
catch (err) {
|
|
614
|
+
logger.warn(`Skipping backlog item ${b.id} in epic cascade: ${err instanceof Error ? err.message : String(err)}`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return plan;
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Feature-scope push: exactly one feature issue + its stories + its tasklist.
|
|
621
|
+
* Requires the parent epic's Linear project to already exist (or `pushParents`).
|
|
622
|
+
*/
|
|
623
|
+
async function pushFeatureScope(projectDir, config, client, featureId, options, teamId, leadId) {
|
|
624
|
+
const ctx = await loadForFeature(projectDir, config, featureId);
|
|
625
|
+
if (!ctx) {
|
|
626
|
+
throw new Error(`Feature not found or has no \`epicId\`: ${featureId}`);
|
|
627
|
+
}
|
|
628
|
+
const updateOnly = options.updateOnly === true;
|
|
629
|
+
if (!ctx.epic.linearProjectId) {
|
|
630
|
+
if (options.pushParents) {
|
|
631
|
+
logger.info(`Parent epic ${ctx.epic.id} is not in Linear yet — pushing the full epic first (--push-parents).`);
|
|
632
|
+
return pushEpicScope(projectDir, config, client, ctx.epic.id, updateOnly, teamId, leadId, options.strategyOverride);
|
|
633
|
+
}
|
|
634
|
+
throw new Error(`Parent epic ${ctx.epic.id} has not been pushed to Linear yet. Run \`planr linear push ${ctx.epic.id}\` first, or re-run with \`--push-parents\`.`);
|
|
635
|
+
}
|
|
636
|
+
const strategyCtx = contextFromMappedEpic(ctx.epic, config);
|
|
637
|
+
const typeLabelCache = createTypeLabelCache(client, teamId, config);
|
|
638
|
+
await pushOneFeatureAndDescendants(projectDir, config, client, ctx.sf, strategyCtx, typeLabelCache, teamId, updateOnly);
|
|
639
|
+
return buildLinearPushPlan(projectDir, config, featureId, { updateOnly });
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Story-scope push: one story sub-issue under an already-mapped feature.
|
|
643
|
+
*/
|
|
644
|
+
async function pushStoryScope(projectDir, config, client, storyId, options, teamId, leadId) {
|
|
645
|
+
const ctx = await loadForStory(projectDir, config, storyId);
|
|
646
|
+
if (!ctx) {
|
|
647
|
+
throw new Error(`Story not found or has no \`featureId\`: ${storyId}`);
|
|
648
|
+
}
|
|
649
|
+
const updateOnly = options.updateOnly === true;
|
|
650
|
+
if (!isUsableLinearIssueId(ctx.sf.data.linearIssueId, `Feature ${ctx.sf.data.id}`)) {
|
|
651
|
+
if (options.pushParents) {
|
|
652
|
+
logger.info(`Parent feature ${ctx.sf.data.id} is not in Linear yet — pushing the feature subtree first (--push-parents).`);
|
|
653
|
+
return pushFeatureScope(projectDir, config, client, ctx.sf.data.id, { ...options, pushParents: true }, teamId, leadId);
|
|
654
|
+
}
|
|
655
|
+
throw new Error(`Parent feature ${ctx.sf.data.id} has not been pushed to Linear yet. Run \`planr linear push ${ctx.sf.data.id}\` first, or re-run with \`--push-parents\`.`);
|
|
656
|
+
}
|
|
657
|
+
// Ensure parent epic also has a Linear project — required for the story's `projectId`.
|
|
658
|
+
if (!ctx.epic.linearProjectId) {
|
|
659
|
+
throw new Error(`Parent epic ${ctx.epic.id} has no \`linearProjectId\`. Run \`planr linear push ${ctx.epic.id}\` first.`);
|
|
660
|
+
}
|
|
661
|
+
const strategyCtx = contextFromMappedEpic(ctx.epic, config);
|
|
662
|
+
const typeLabelCache = createTypeLabelCache(client, teamId, config);
|
|
663
|
+
const featureIssueId = ctx.sf.data.linearIssueId;
|
|
664
|
+
await pushOneStoryUnderFeature(projectDir, config, client, ctx.story, featureIssueId, strategyCtx, typeLabelCache, teamId, updateOnly);
|
|
665
|
+
return buildLinearPushPlan(projectDir, config, storyId, { updateOnly });
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Task-file-scope push: update the single "Tasks for <feature>" sub-issue, merging
|
|
669
|
+
* checkbox bodies across all task files under the same feature (matches epic-scope
|
|
670
|
+
* behavior — one Linear sub-issue per feature regardless of how many task files exist).
|
|
671
|
+
*/
|
|
672
|
+
async function pushTaskFileScope(projectDir, config, client, taskId, options, teamId, leadId) {
|
|
673
|
+
const ctx = await loadForTaskFile(projectDir, config, taskId);
|
|
674
|
+
if (!ctx) {
|
|
675
|
+
throw new Error(`Task file not found or has no \`featureId\`: ${taskId}`);
|
|
676
|
+
}
|
|
677
|
+
const updateOnly = options.updateOnly === true;
|
|
678
|
+
if (!isUsableLinearIssueId(ctx.sf.data.linearIssueId, `Feature ${ctx.sf.data.id}`)) {
|
|
679
|
+
if (options.pushParents) {
|
|
680
|
+
logger.info(`Parent feature ${ctx.sf.data.id} is not in Linear yet — pushing the feature subtree first (--push-parents).`);
|
|
681
|
+
return pushFeatureScope(projectDir, config, client, ctx.sf.data.id, { ...options, pushParents: true }, teamId, leadId);
|
|
682
|
+
}
|
|
683
|
+
throw new Error(`Parent feature ${ctx.sf.data.id} has not been pushed to Linear yet. Run \`planr linear push ${ctx.sf.data.id}\` first, or re-run with \`--push-parents\`.`);
|
|
684
|
+
}
|
|
685
|
+
if (!ctx.epic.linearProjectId) {
|
|
686
|
+
throw new Error(`Parent epic ${ctx.epic.id} has no \`linearProjectId\`. Run \`planr linear push ${ctx.epic.id}\` first.`);
|
|
687
|
+
}
|
|
688
|
+
const strategyCtx = contextFromMappedEpic(ctx.epic, config);
|
|
689
|
+
const typeLabelCache = createTypeLabelCache(client, teamId, config);
|
|
690
|
+
const featureIssueId = ctx.sf.data.linearIssueId;
|
|
691
|
+
await pushOneTaskListForFeature(projectDir, config, client, ctx.sf, featureIssueId, strategyCtx, typeLabelCache, teamId, updateOnly);
|
|
692
|
+
return buildLinearPushPlan(projectDir, config, taskId, { updateOnly });
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Resolve the Linear container for a QT / BL push:
|
|
696
|
+
* 1. If the artifact has a linked epic (`epicId` / `parentEpic`) and that epic
|
|
697
|
+
* is already mapped in Linear → reuse the epic's StrategyContext so the
|
|
698
|
+
* issue inherits project + milestone / label propagation.
|
|
699
|
+
* 2. If the epic is linked but not yet mapped — cascade to `pushEpicScope`
|
|
700
|
+
* when `--push-parents` is set, otherwise error with a clear pointer.
|
|
701
|
+
* 3. No linked epic → fall back to `config.linear.standaloneProjectId`.
|
|
702
|
+
* 4. Still missing — actionable error (interactive setup or manual config edit).
|
|
703
|
+
*
|
|
704
|
+
* Returns `null` when the caller already pushed a cascaded ancestor (so the
|
|
705
|
+
* caller should short-circuit its own push).
|
|
706
|
+
*/
|
|
707
|
+
async function resolveQuickOrBacklogContext(projectDir, config, client, artifactKind, artifactId, frontmatter, options, teamId, leadId) {
|
|
708
|
+
const linkedEpicId = getLinkedEpicId(frontmatter);
|
|
709
|
+
if (linkedEpicId) {
|
|
710
|
+
const epicArt = await readArtifact(projectDir, config, 'epic', linkedEpicId);
|
|
711
|
+
if (!epicArt) {
|
|
712
|
+
throw new Error(`${artifactKind === 'quick' ? 'Quick task' : 'Backlog item'} ${artifactId} declares epicId "${linkedEpicId}" but no such epic exists locally. Fix the frontmatter or create the epic first.`);
|
|
713
|
+
}
|
|
714
|
+
const epicScope = await loadLinearPushScope(projectDir, config, linkedEpicId);
|
|
715
|
+
if (!epicScope) {
|
|
716
|
+
throw new Error(`Failed to load epic ${linkedEpicId} for push context.`);
|
|
717
|
+
}
|
|
718
|
+
if (!epicScope.epic.linearProjectId) {
|
|
719
|
+
if (options.pushParents) {
|
|
720
|
+
logger.info(`Parent epic ${linkedEpicId} is not in Linear yet — pushing the full epic first (--push-parents).`);
|
|
721
|
+
await pushEpicScope(projectDir, config, client, linkedEpicId, options.updateOnly === true, teamId, leadId, options.strategyOverride);
|
|
722
|
+
// The cascade re-pushes every linked QT/BL, including this one.
|
|
723
|
+
return { kind: 'cascaded' };
|
|
724
|
+
}
|
|
725
|
+
throw new Error(`${artifactId} is linked to epic ${linkedEpicId}, which has not been pushed to Linear yet. Run \`planr linear push ${linkedEpicId}\` first, or re-run with \`--push-parents\`.`);
|
|
726
|
+
}
|
|
727
|
+
return { kind: 'resolved', ctx: contextFromMappedEpic(epicScope.epic, config) };
|
|
728
|
+
}
|
|
729
|
+
// No linked epic — fall back to the standalone project.
|
|
730
|
+
const standaloneId = config.linear?.standaloneProjectId;
|
|
731
|
+
if (!standaloneId) {
|
|
732
|
+
throw new Error(`No Linear container resolved for ${artifactId}: no \`epicId\` on the artifact and no \`linear.standaloneProjectId\` configured. Either add \`epicId: "EPIC-XXX"\` to the frontmatter (and push that epic first), or run \`planr linear push ${artifactId}\` interactively once to pick a standalone project, or set \`linear.standaloneProjectId\` in \`.planr/config.json\`.`);
|
|
733
|
+
}
|
|
734
|
+
return { kind: 'resolved', ctx: { strategy: 'project', projectId: standaloneId } };
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Internal worker: create-or-update one quick-task Linear issue using a
|
|
738
|
+
* pre-resolved StrategyContext. Used by both the QT-scope entry point and
|
|
739
|
+
* the epic-cascade path (which already has the context in hand).
|
|
740
|
+
*/
|
|
741
|
+
async function pushOneQuickTaskWithContext(projectDir, config, client, qt, ctx, typeLabelCache, teamId, updateOnly) {
|
|
742
|
+
// Push the full markdown body (minus frontmatter + top-level title heading)
|
|
743
|
+
// so prose content and checkbox lists both land in Linear verbatim.
|
|
744
|
+
const body = buildStandaloneArtifactBody(qt.raw, qt.id);
|
|
745
|
+
const title = qt.title.trim();
|
|
746
|
+
const typeLabelId = await typeLabelCache('quick');
|
|
747
|
+
const stateId = resolveTaskStateIdForPush(config, toOptionalString(qt.frontmatter.status), getAutoStateIdMap(client));
|
|
748
|
+
const estimatePatch = buildEstimateInput(client, qt.frontmatter, qt.id).fieldPatch;
|
|
749
|
+
const rawExistingId = toOptionalString(qt.frontmatter.linearIssueId);
|
|
750
|
+
const existingId = isUsableLinearIssueId(rawExistingId, `QuickTask ${qt.id}`)
|
|
751
|
+
? rawExistingId
|
|
752
|
+
: undefined;
|
|
753
|
+
if (existingId) {
|
|
754
|
+
const existingLabels = await readExistingLabelIds(client, existingId);
|
|
755
|
+
const labelIds = mergeLabelIds(mergeLabelIds(existingLabels, typeLabelId), ctx.strategy === 'label-on' ? ctx.labelId : undefined);
|
|
756
|
+
const u = await updateLinearIssue(client, existingId, {
|
|
757
|
+
title,
|
|
758
|
+
description: body,
|
|
759
|
+
projectId: ctx.projectId,
|
|
760
|
+
teamId,
|
|
761
|
+
// Linear rejects `stateId: null` on update (InvalidInput). Omit the
|
|
762
|
+
// field entirely when unmapped so the issue keeps its current state.
|
|
763
|
+
...(stateId ? { stateId } : {}),
|
|
764
|
+
...estimatePatch,
|
|
765
|
+
projectMilestoneId: ctx.milestoneId ?? null,
|
|
766
|
+
labelIds,
|
|
767
|
+
});
|
|
768
|
+
const fmUpdate = {
|
|
769
|
+
linearIssueId: u.id,
|
|
770
|
+
linearIssueIdentifier: u.identifier,
|
|
771
|
+
linearIssueUrl: u.url,
|
|
772
|
+
linearLabelIds: labelIds,
|
|
773
|
+
};
|
|
774
|
+
if (ctx.milestoneId)
|
|
775
|
+
fmUpdate.linearProjectMilestoneId = ctx.milestoneId;
|
|
776
|
+
await updateArtifactFields(projectDir, config, 'quick', qt.id, fmUpdate);
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
if (updateOnly) {
|
|
780
|
+
logger.warn(`Update-only: skipping quick task ${qt.id} (no linearIssueId).`);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
const initialLabelIds = mergeLabelIds([typeLabelId], ctx.strategy === 'label-on' ? ctx.labelId : undefined);
|
|
784
|
+
const c = await createLinearIssue(client, {
|
|
785
|
+
teamId,
|
|
786
|
+
projectId: ctx.projectId,
|
|
787
|
+
title,
|
|
788
|
+
description: body,
|
|
789
|
+
...(stateId ? { stateId } : {}),
|
|
790
|
+
...estimatePatch,
|
|
791
|
+
...(ctx.milestoneId ? { projectMilestoneId: ctx.milestoneId } : {}),
|
|
792
|
+
labelIds: initialLabelIds,
|
|
793
|
+
});
|
|
794
|
+
const fmUpdate = {
|
|
795
|
+
linearIssueId: c.id,
|
|
796
|
+
linearIssueIdentifier: c.identifier,
|
|
797
|
+
linearIssueUrl: c.url,
|
|
798
|
+
linearLabelIds: initialLabelIds,
|
|
799
|
+
};
|
|
800
|
+
if (ctx.milestoneId)
|
|
801
|
+
fmUpdate.linearProjectMilestoneId = ctx.milestoneId;
|
|
802
|
+
await updateArtifactFields(projectDir, config, 'quick', qt.id, fmUpdate);
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Internal worker: create-or-update one backlog-item Linear issue. Always
|
|
806
|
+
* carries the team-scoped `backlog` label; merges the epic's `label-on`
|
|
807
|
+
* label when applicable (and preserves any user-added labels on update).
|
|
808
|
+
*/
|
|
809
|
+
async function pushOneBacklogItemWithContext(projectDir, config, client, bl, ctx, typeLabelCache, teamId, updateOnly) {
|
|
810
|
+
const title = bl.title.trim();
|
|
811
|
+
const body = buildBacklogItemBody(bl);
|
|
812
|
+
const typeLabelId = await typeLabelCache('backlog');
|
|
813
|
+
const stateId = resolveBacklogStateIdForPush(config, toOptionalString(bl.frontmatter.status), getAutoStateIdMap(client));
|
|
814
|
+
const estimatePatch = buildEstimateInput(client, bl.frontmatter, bl.id).fieldPatch;
|
|
815
|
+
const rawExistingId = toOptionalString(bl.frontmatter.linearIssueId);
|
|
816
|
+
const existingId = isUsableLinearIssueId(rawExistingId, `Backlog ${bl.id}`)
|
|
817
|
+
? rawExistingId
|
|
818
|
+
: undefined;
|
|
819
|
+
if (existingId) {
|
|
820
|
+
const existingLabels = await readExistingLabelIds(client, existingId);
|
|
821
|
+
const labelIds = mergeLabelIds(mergeLabelIds(existingLabels, typeLabelId), ctx.strategy === 'label-on' ? ctx.labelId : undefined);
|
|
822
|
+
const u = await updateLinearIssue(client, existingId, {
|
|
823
|
+
title,
|
|
824
|
+
description: body,
|
|
825
|
+
projectId: ctx.projectId,
|
|
826
|
+
teamId,
|
|
827
|
+
// Linear rejects `stateId: null` on update (InvalidInput). Omit the
|
|
828
|
+
// field entirely when unmapped so the issue keeps its current state.
|
|
829
|
+
...(stateId ? { stateId } : {}),
|
|
830
|
+
...estimatePatch,
|
|
831
|
+
labelIds,
|
|
832
|
+
projectMilestoneId: ctx.milestoneId ?? null,
|
|
833
|
+
});
|
|
834
|
+
const fmUpdate = {
|
|
835
|
+
linearIssueId: u.id,
|
|
836
|
+
linearIssueIdentifier: u.identifier,
|
|
837
|
+
linearIssueUrl: u.url,
|
|
838
|
+
linearLabelIds: labelIds,
|
|
839
|
+
};
|
|
840
|
+
if (ctx.milestoneId)
|
|
841
|
+
fmUpdate.linearProjectMilestoneId = ctx.milestoneId;
|
|
842
|
+
await updateArtifactFields(projectDir, config, 'backlog', bl.id, fmUpdate);
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
if (updateOnly) {
|
|
846
|
+
logger.warn(`Update-only: skipping backlog item ${bl.id} (no linearIssueId).`);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
const initialLabelIds = mergeLabelIds([typeLabelId], ctx.strategy === 'label-on' ? ctx.labelId : undefined);
|
|
850
|
+
const c = await createLinearIssue(client, {
|
|
851
|
+
teamId,
|
|
852
|
+
projectId: ctx.projectId,
|
|
853
|
+
title,
|
|
854
|
+
description: body,
|
|
855
|
+
labelIds: initialLabelIds,
|
|
856
|
+
...(stateId ? { stateId } : {}),
|
|
857
|
+
...estimatePatch,
|
|
858
|
+
...(ctx.milestoneId ? { projectMilestoneId: ctx.milestoneId } : {}),
|
|
859
|
+
});
|
|
860
|
+
const fmUpdate = {
|
|
861
|
+
linearIssueId: c.id,
|
|
862
|
+
linearIssueIdentifier: c.identifier,
|
|
863
|
+
linearIssueUrl: c.url,
|
|
864
|
+
linearLabelIds: initialLabelIds,
|
|
865
|
+
};
|
|
866
|
+
if (ctx.milestoneId)
|
|
867
|
+
fmUpdate.linearProjectMilestoneId = ctx.milestoneId;
|
|
868
|
+
await updateArtifactFields(projectDir, config, 'backlog', bl.id, fmUpdate);
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* QT-scope entry point: resolve the Linear container via linked epic or the
|
|
872
|
+
* standalone fallback, then push the single issue.
|
|
873
|
+
*/
|
|
874
|
+
async function pushQuickTaskScope(projectDir, config, client, qtId, options, teamId, leadId) {
|
|
875
|
+
const updateOnly = options.updateOnly === true;
|
|
876
|
+
const qt = await loadForQuickTask(projectDir, config, qtId);
|
|
877
|
+
if (!qt) {
|
|
878
|
+
throw new Error(`Quick task not found: ${qtId}`);
|
|
879
|
+
}
|
|
880
|
+
const resolved = await resolveQuickOrBacklogContext(projectDir, config, client, 'quick', qtId, qt.frontmatter, options, teamId, leadId);
|
|
881
|
+
if (resolved.kind === 'cascaded') {
|
|
882
|
+
// Epic cascade already pushed this QT — just return the scope-1 plan.
|
|
883
|
+
return buildLinearPushPlan(projectDir, config, qtId, { updateOnly });
|
|
884
|
+
}
|
|
885
|
+
const typeLabelCache = createTypeLabelCache(client, teamId, config);
|
|
886
|
+
await pushOneQuickTaskWithContext(projectDir, config, client, qt, resolved.ctx, typeLabelCache, teamId, updateOnly);
|
|
887
|
+
return buildLinearPushPlan(projectDir, config, qtId, { updateOnly });
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* BL-scope entry point: same resolution order as QT, plus the mandatory
|
|
891
|
+
* `backlog` label.
|
|
892
|
+
*/
|
|
893
|
+
async function pushBacklogItemScope(projectDir, config, client, blId, options, teamId, leadId) {
|
|
894
|
+
const updateOnly = options.updateOnly === true;
|
|
895
|
+
const bl = await loadForBacklogItem(projectDir, config, blId);
|
|
896
|
+
if (!bl) {
|
|
897
|
+
throw new Error(`Backlog item not found: ${blId}`);
|
|
898
|
+
}
|
|
899
|
+
const resolved = await resolveQuickOrBacklogContext(projectDir, config, client, 'backlog', blId, bl.frontmatter, options, teamId, leadId);
|
|
900
|
+
if (resolved.kind === 'cascaded') {
|
|
901
|
+
return buildLinearPushPlan(projectDir, config, blId, { updateOnly });
|
|
902
|
+
}
|
|
903
|
+
const typeLabelCache = createTypeLabelCache(client, teamId, config);
|
|
904
|
+
await pushOneBacklogItemWithContext(projectDir, config, client, bl, resolved.ctx, typeLabelCache, teamId, updateOnly);
|
|
905
|
+
return buildLinearPushPlan(projectDir, config, blId, { updateOnly });
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Granular push entry point: dispatches on the artifact-id prefix. Accepts any
|
|
909
|
+
* supported artifact type (EPIC/FEAT/US/TASK); errors with an actionable
|
|
910
|
+
* message for types that are not pushable (ADR/SPRINT/checklist) or not yet
|
|
911
|
+
* supported (QT/BL go through the same router too).
|
|
912
|
+
*/
|
|
913
|
+
export async function runLinearPush(projectDir, config, client, artifactId, options) {
|
|
914
|
+
const teamId = config.linear?.teamId;
|
|
915
|
+
if (!teamId) {
|
|
916
|
+
throw new Error('`linear.teamId` is not set. Run `planr linear init` first.');
|
|
917
|
+
}
|
|
918
|
+
const leadId = config.linear?.defaultProjectLead;
|
|
919
|
+
const opts = options ?? {};
|
|
920
|
+
const updateOnly = opts.updateOnly === true;
|
|
921
|
+
// One API round-trip per push to cache the team's workflow states. Lets
|
|
922
|
+
// every resolver (feature/story/QT/BL) map local status → Linear stateId
|
|
923
|
+
// even when the user hasn't configured `linear.pushStateIds` explicitly.
|
|
924
|
+
await ensureAutoStateIdMap(client, teamId);
|
|
925
|
+
// Second round-trip for the team's issue estimation type — used by every
|
|
926
|
+
// resolver to snap local `storyPoints` to Linear's native estimate field.
|
|
927
|
+
// Cheap, non-blocking on failure (falls back to `'notUsed'`).
|
|
928
|
+
await ensureTeamEstimationType(client, teamId);
|
|
929
|
+
const type = findArtifactTypeById(artifactId);
|
|
930
|
+
if (!type) {
|
|
931
|
+
throw new Error(`Unknown artifact id: ${artifactId}. Expected an EPIC-/FEAT-/US-/TASK- prefix.`);
|
|
932
|
+
}
|
|
933
|
+
if (type === 'sprint' || type === 'adr' || type === 'checklist') {
|
|
934
|
+
throw new Error(`planr linear push does not support ${type}s in this release. Push its parent epic instead: planr linear push <EPIC-ID>.`);
|
|
935
|
+
}
|
|
936
|
+
if (type === 'epic') {
|
|
937
|
+
return pushEpicScope(projectDir, config, client, artifactId, updateOnly, teamId, leadId, opts.strategyOverride);
|
|
938
|
+
}
|
|
939
|
+
if (type === 'feature') {
|
|
940
|
+
return pushFeatureScope(projectDir, config, client, artifactId, opts, teamId, leadId);
|
|
941
|
+
}
|
|
942
|
+
if (type === 'story') {
|
|
943
|
+
return pushStoryScope(projectDir, config, client, artifactId, opts, teamId, leadId);
|
|
944
|
+
}
|
|
945
|
+
if (type === 'task') {
|
|
946
|
+
return pushTaskFileScope(projectDir, config, client, artifactId, opts, teamId, leadId);
|
|
947
|
+
}
|
|
948
|
+
if (type === 'quick') {
|
|
949
|
+
return pushQuickTaskScope(projectDir, config, client, artifactId, opts, teamId, leadId);
|
|
950
|
+
}
|
|
951
|
+
if (type === 'backlog') {
|
|
952
|
+
return pushBacklogItemScope(projectDir, config, client, artifactId, opts, teamId, leadId);
|
|
953
|
+
}
|
|
954
|
+
return null;
|
|
955
|
+
}
|
|
956
|
+
//# sourceMappingURL=linear-push-service.js.map
|