@ikunin/sprintpilot 2.1.2 → 2.1.4
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/_Sprintpilot/bin/autopilot.js +353 -47
- package/_Sprintpilot/lib/orchestrator/adapt.js +21 -2
- package/_Sprintpilot/lib/orchestrator/git-plan.js +522 -36
- package/_Sprintpilot/lib/orchestrator/land.js +11 -1
- package/_Sprintpilot/lib/orchestrator/profile-rules.js +65 -1
- package/_Sprintpilot/lib/orchestrator/state-machine.js +183 -4
- package/_Sprintpilot/manifest.yaml +1 -1
- package/_Sprintpilot/modules/git/config.yaml +8 -0
- package/_Sprintpilot/scripts/create-pr.js +178 -7
- package/_Sprintpilot/scripts/run-step.js +221 -0
- package/_Sprintpilot/skills/sprint-autopilot-on/workflow.orchestrator.md +35 -1
- package/package.json +1 -1
|
@@ -1,14 +1,39 @@
|
|
|
1
1
|
// git-plan.js — produce an argv sequence for a git_op action.
|
|
2
2
|
//
|
|
3
|
-
// Given (state, profile, action), return:
|
|
4
|
-
// { steps: [{ args, description, retry
|
|
3
|
+
// Given (state, profile, action), return one of:
|
|
4
|
+
// { branch, steps: [{ args, description, retry?, env?, ... }] }
|
|
5
|
+
// { branch, steps: [], halt_action: { type: 'user_prompt', ... } }
|
|
5
6
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
7
|
+
// Step metadata fields honored by _Sprintpilot/scripts/run-step.js (and
|
|
8
|
+
// the LLM contract in workflow.orchestrator.md):
|
|
9
|
+
// - args: string[] argv (no shell interpolation)
|
|
10
|
+
// - description: string human-readable log line
|
|
11
|
+
// - retry: { attempts, ... } transient-failure retry policy
|
|
12
|
+
// - env: { KEY: value } step-scoped env overrides (used for
|
|
13
|
+
// GH_HOST / GITLAB_URI on self-hosted)
|
|
14
|
+
// - tolerate_exit_codes: [N] non-zero codes treated as success
|
|
15
|
+
// - optional: true non-zero exit logged as warning, not
|
|
16
|
+
// halting (used for best-effort fetches)
|
|
17
|
+
//
|
|
18
|
+
// `halt_action`: alternative plan shape used when the orchestrator can't
|
|
19
|
+
// emit executable steps for a given (platform, op) combination — e.g.
|
|
20
|
+
// MERGE_EPIC under bitbucket/gitea. decorateGitOp surfaces it as a
|
|
21
|
+
// top-level user_prompt action so the autopilot pauses for manual work.
|
|
22
|
+
//
|
|
23
|
+
// Argv-only — no shell strings. Mostly pure; the exceptions are:
|
|
24
|
+
// - buildPrBody() reads profile.pr_template_path from disk (file
|
|
25
|
+
// content is data, not control flow, so determinism holds when the
|
|
26
|
+
// file content is fixed).
|
|
27
|
+
// - planCreateBranch() is invoked after decorateGitOp probes git for
|
|
28
|
+
// branch existence (the impurity lives in decorateGitOp, not here).
|
|
29
|
+
// Steps are deterministic given the same inputs.
|
|
9
30
|
|
|
10
31
|
'use strict';
|
|
11
32
|
|
|
33
|
+
const crypto = require('node:crypto');
|
|
34
|
+
const fs = require('node:fs');
|
|
35
|
+
const path = require('node:path');
|
|
36
|
+
|
|
12
37
|
const { STATES } = require('./state-machine');
|
|
13
38
|
|
|
14
39
|
const STORY_BRANCH_RE = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
@@ -21,6 +46,28 @@ function sanitizeStoryKey(key) {
|
|
|
21
46
|
return s;
|
|
22
47
|
}
|
|
23
48
|
|
|
49
|
+
// Truncate a branch name to `max` chars, appending `-<8-char hash>` so
|
|
50
|
+
// long story keys don't collide after truncation. 8 hex chars = 32 bits
|
|
51
|
+
// of entropy → ~65,000 truncated-stem variants before 50% collision
|
|
52
|
+
// odds per the birthday bound. Honors git's safe branch-name charset
|
|
53
|
+
// (no `/`, no shell metachars in the hash). Returns the input unchanged
|
|
54
|
+
// when under the limit, or when `max` is falsy.
|
|
55
|
+
const BRANCH_HASH_LEN = 8;
|
|
56
|
+
function truncateBranchName(branch, max) {
|
|
57
|
+
if (!max || typeof max !== 'number' || branch.length <= max) return branch;
|
|
58
|
+
const HASH_SUFFIX_LEN = BRANCH_HASH_LEN + 1; // `-` + hash chars
|
|
59
|
+
if (max <= HASH_SUFFIX_LEN) {
|
|
60
|
+
// Pathological config — fall back to the hash alone so we don't
|
|
61
|
+
// produce an empty branch name.
|
|
62
|
+
return crypto.createHash('sha1').update(branch).digest('hex').slice(0, max);
|
|
63
|
+
}
|
|
64
|
+
const hash = crypto.createHash('sha1').update(branch).digest('hex').slice(0, BRANCH_HASH_LEN);
|
|
65
|
+
const keep = max - HASH_SUFFIX_LEN;
|
|
66
|
+
// Trim any trailing separator so the joined name doesn't look like `foo--abc123`.
|
|
67
|
+
const stem = branch.slice(0, keep).replace(/[-._]+$/, '');
|
|
68
|
+
return `${stem}-${hash}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
24
71
|
// branchName(profile, storyKey, epicKey, state?)
|
|
25
72
|
// When `state?.user_branch` is set (because git.reuse_user_branch=true and
|
|
26
73
|
// the user pre-created a working branch), every story commits to that
|
|
@@ -33,14 +80,24 @@ function sanitizeStoryKey(key) {
|
|
|
33
80
|
// is "story/", so under nano you get `story/epic-1` (not `epic/1`) — that's
|
|
34
81
|
// what the existing tooling and e2e tests expect.
|
|
35
82
|
function branchName(profile, storyKey, epicKey, state) {
|
|
83
|
+
// User-supplied branch: return verbatim. The user named that branch
|
|
84
|
+
// deliberately and silently truncating it would break their mental
|
|
85
|
+
// model + their `git push -u origin <name>` invocations.
|
|
36
86
|
if (state && state.user_branch) return state.user_branch;
|
|
37
87
|
const prefix = profile.branch_prefix || 'story/';
|
|
38
88
|
// git.granularity: 'story' (default) or 'epic'. Nano + large can be epic.
|
|
89
|
+
let name;
|
|
39
90
|
if (profile.granularity === 'epic' && epicKey) {
|
|
40
|
-
|
|
91
|
+
name = `${prefix}epic-${sanitizeStoryKey(epicKey) || 'unknown'}`;
|
|
92
|
+
} else {
|
|
93
|
+
const safe = sanitizeStoryKey(storyKey) || 'unknown';
|
|
94
|
+
name = `${prefix}${safe}`;
|
|
41
95
|
}
|
|
42
|
-
|
|
43
|
-
|
|
96
|
+
// Honor git.max_branch_length (default 60). When the full branch name
|
|
97
|
+
// exceeds the limit, truncate and append an 8-char hash so collisions
|
|
98
|
+
// between similar story keys remain unique (32 bits of entropy).
|
|
99
|
+
const max = profile.max_branch_length || 60;
|
|
100
|
+
return truncateBranchName(name, max);
|
|
44
101
|
}
|
|
45
102
|
|
|
46
103
|
// commit_and_push_story — full sequence for STORY_DONE.
|
|
@@ -98,30 +155,93 @@ function planCreateBranch(state, profile, branch) {
|
|
|
98
155
|
};
|
|
99
156
|
}
|
|
100
157
|
const baseBranch = profile.base_branch || 'main';
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
|
|
158
|
+
const hasOrigin = profile.has_origin !== false;
|
|
159
|
+
const steps = [];
|
|
160
|
+
|
|
161
|
+
// Best-effort fetch so the new branch forks from the freshest base.
|
|
162
|
+
// Non-blocking — `retry.on: 'network'` lets the runner ignore transient
|
|
163
|
+
// failures. Skipped when there's no origin (local-only repo / tests).
|
|
164
|
+
if (hasOrigin) {
|
|
165
|
+
steps.push({
|
|
166
|
+
args: ['git', 'fetch', 'origin', baseBranch],
|
|
167
|
+
description: `fetch origin/${baseBranch} before branching`,
|
|
168
|
+
retry: { attempts: 2, backoff_ms: [2000, 4000], on: 'network' },
|
|
169
|
+
optional: true,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Branch already on disk (resume after partial failure, or second story
|
|
174
|
+
// on an epic branch). Idempotent switch — never reset the branch from
|
|
175
|
+
// base, since that would discard prior story work.
|
|
176
|
+
if (state && state.branch_exists) {
|
|
177
|
+
steps.push({
|
|
178
|
+
args: ['git', 'switch', branch],
|
|
179
|
+
description: `switch to existing branch ${branch} (already on disk)`,
|
|
180
|
+
});
|
|
181
|
+
return { branch, steps };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Fresh story branch. `switch -c` fails if the branch exists; that is
|
|
185
|
+
// intentional — the edge layer is responsible for probing and setting
|
|
186
|
+
// `state.branch_exists` before we get here, so a collision here means
|
|
187
|
+
// an unexpected race / stale state.
|
|
188
|
+
steps.push({
|
|
189
|
+
args: ['git', 'switch', '-c', branch, baseBranch],
|
|
190
|
+
description: `create story branch ${branch} from ${baseBranch}`,
|
|
191
|
+
});
|
|
192
|
+
return { branch, steps };
|
|
113
193
|
}
|
|
114
194
|
|
|
115
195
|
function planCommitAndPush(state, profile, action, branch) {
|
|
116
196
|
const files = Array.isArray(action.files) && action.files.length > 0 ? action.files : null;
|
|
117
|
-
const message =
|
|
118
|
-
action.message ||
|
|
119
|
-
(state.story_key
|
|
120
|
-
? `feat(${state.story_key}): ${state.ac_summary || 'story done'}`
|
|
121
|
-
: 'feat: story done');
|
|
197
|
+
const message = action.message || buildStoryCommitMessage(state, profile);
|
|
122
198
|
const baseBranch = profile.base_branch || 'main';
|
|
123
199
|
const storyKey = state.story_key || 'sprint';
|
|
124
200
|
const hasOrigin = profile.has_origin !== false;
|
|
201
|
+
// Collected by buildPrBody when pr_template_path is configured but
|
|
202
|
+
// unreadable. The edge layer (decorateGitOp) surfaces these via the
|
|
203
|
+
// ledger so the failure is visible without writing to stderr from
|
|
204
|
+
// inside a pure-ish plan function.
|
|
205
|
+
const warnings = [];
|
|
206
|
+
// push.auto: false → branches stay local. Both the story-branch push
|
|
207
|
+
// and the base-branch push are suppressed; commits to base still happen
|
|
208
|
+
// so `_bmad-output/` stays in sync locally, the user opts back in by
|
|
209
|
+
// pushing manually.
|
|
210
|
+
const pushAuto = profile.push_auto !== false;
|
|
211
|
+
// push.create_pr: true + merge_strategy=stacked → open one PR per push.
|
|
212
|
+
// Under granularity=story each push targets a unique branch → one PR per
|
|
213
|
+
// story. Under granularity=epic every story pushes to the same epic
|
|
214
|
+
// branch, so this step is called repeatedly but `create-pr.js` is
|
|
215
|
+
// idempotent (it pre-checks `gh pr list --head` and short-circuits when
|
|
216
|
+
// a PR already exists), giving us the documented "one PR per epic"
|
|
217
|
+
// contract for free.
|
|
218
|
+
//
|
|
219
|
+
// The epic PR is later closed by MERGE_EPIC at the epic boundary; the
|
|
220
|
+
// per-story PR (granularity=story) is closed by the user / external
|
|
221
|
+
// automation. `land_as_you_go` opens its own PRs via land.js, so this
|
|
222
|
+
// step is suppressed there.
|
|
223
|
+
const granularity = profile.granularity || 'story';
|
|
224
|
+
const createPr =
|
|
225
|
+
profile.push_create_pr !== false &&
|
|
226
|
+
(profile.merge_strategy || 'stacked') === 'stacked' &&
|
|
227
|
+
hasOrigin &&
|
|
228
|
+
pushAuto &&
|
|
229
|
+
!profile.reuse_user_branch;
|
|
230
|
+
|
|
231
|
+
// push.create_pr: false + merge_strategy=stacked + granularity=story →
|
|
232
|
+
// direct-merge the story branch into base after pushing. Honors the
|
|
233
|
+
// documented behavior in config.yaml#push.create_pr ("merge directly
|
|
234
|
+
// to base after push"). Under granularity=epic the merge happens at
|
|
235
|
+
// MERGE_EPIC instead — direct merge per story would merge incomplete
|
|
236
|
+
// epic work to base on every story, which contradicts the epic
|
|
237
|
+
// granularity contract.
|
|
238
|
+
const directMerge =
|
|
239
|
+
profile.push_create_pr === false &&
|
|
240
|
+
(profile.merge_strategy || 'stacked') === 'stacked' &&
|
|
241
|
+
granularity === 'story' &&
|
|
242
|
+
hasOrigin &&
|
|
243
|
+
pushAuto &&
|
|
244
|
+
!profile.reuse_user_branch;
|
|
125
245
|
|
|
126
246
|
const steps = [];
|
|
127
247
|
|
|
@@ -142,10 +262,12 @@ function planCommitAndPush(state, profile, action, branch) {
|
|
|
142
262
|
});
|
|
143
263
|
}
|
|
144
264
|
steps.push({
|
|
145
|
-
|
|
265
|
+
// `--message=<msg>` form so a user-customized template producing a
|
|
266
|
+
// leading `-` doesn't get parsed as a flag.
|
|
267
|
+
args: ['git', 'commit', `--message=${message}`],
|
|
146
268
|
description: `commit on ${branch}`,
|
|
147
269
|
});
|
|
148
|
-
if (hasOrigin) {
|
|
270
|
+
if (hasOrigin && pushAuto) {
|
|
149
271
|
steps.push({
|
|
150
272
|
args: ['git', 'push', '-u', 'origin', branch],
|
|
151
273
|
description: `push ${branch} (retry 4× exponential backoff on network)`,
|
|
@@ -153,11 +275,114 @@ function planCommitAndPush(state, profile, action, branch) {
|
|
|
153
275
|
});
|
|
154
276
|
}
|
|
155
277
|
|
|
278
|
+
// Open a PR for this story branch (one per branch under granularity=
|
|
279
|
+
// story; idempotent under granularity=epic — see create-pr.js). Skipped
|
|
280
|
+
// when push_create_pr=false, when push.auto=false (no remote ref to PR
|
|
281
|
+
// from), when running land_as_you_go (land.js handles its own PR), or
|
|
282
|
+
// when reuse_user_branch=true (one sprint-end PR, opened externally).
|
|
283
|
+
if (createPr) {
|
|
284
|
+
const prTitle = action.pr_title || message;
|
|
285
|
+
const prBody = action.pr_body || buildPrBody(state, profile, state.project_root, warnings);
|
|
286
|
+
const platform = profile.platform_provider || 'auto';
|
|
287
|
+
const baseUrl = profile.platform_base_url || null;
|
|
288
|
+
const prArgs = [
|
|
289
|
+
'node',
|
|
290
|
+
'_Sprintpilot/scripts/create-pr.js',
|
|
291
|
+
'--platform',
|
|
292
|
+
platform,
|
|
293
|
+
'--branch',
|
|
294
|
+
branch,
|
|
295
|
+
'--base',
|
|
296
|
+
baseBranch,
|
|
297
|
+
'--title',
|
|
298
|
+
prTitle,
|
|
299
|
+
'--body',
|
|
300
|
+
prBody,
|
|
301
|
+
];
|
|
302
|
+
if (baseUrl) prArgs.push('--base-url', baseUrl);
|
|
303
|
+
steps.push({
|
|
304
|
+
args: prArgs,
|
|
305
|
+
description: `open PR for ${branch} → ${baseBranch} (idempotent — exits 0 if PR already exists)`,
|
|
306
|
+
// create-pr.js may return exit 2 (SKIPPED — no CLI / git_only). Treat
|
|
307
|
+
// that as success: the user opted into auto-PR but the platform CLI
|
|
308
|
+
// isn't available, which is recoverable manually.
|
|
309
|
+
tolerate_exit_codes: [0, 2],
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Direct-merge mode (push.create_pr: false): merge the story branch
|
|
314
|
+
// straight into base instead of opening a PR. The merge brings the
|
|
315
|
+
// full story contents (code + _bmad-output) along, so Phase 2's
|
|
316
|
+
// bmad-output-only sync is skipped. Returns to the story branch at
|
|
317
|
+
// the end so subsequent stories under stacked can push again — even
|
|
318
|
+
// though under granularity=story each story has its own branch and
|
|
319
|
+
// won't be revisited.
|
|
320
|
+
//
|
|
321
|
+
// Conflict recovery: we use `git fetch` + `git merge --ff-only` rather
|
|
322
|
+
// than `git pull --ff-only` so the failure mode is two distinct steps
|
|
323
|
+
// (network vs. divergence) — the runner can retry the fetch but
|
|
324
|
+
// surface the divergence as a user_prompt for manual rebase. There is
|
|
325
|
+
// no automatic rebase recovery in direct-merge mode today; the user
|
|
326
|
+
// owns reconciling a diverged base.
|
|
327
|
+
if (directMerge) {
|
|
328
|
+
const squash = !!profile.squash_on_merge;
|
|
329
|
+
steps.push({
|
|
330
|
+
args: ['git', 'switch', baseBranch],
|
|
331
|
+
description: `switch to ${baseBranch} for direct merge`,
|
|
332
|
+
});
|
|
333
|
+
if (hasOrigin) {
|
|
334
|
+
steps.push({
|
|
335
|
+
args: ['git', 'fetch', 'origin', baseBranch],
|
|
336
|
+
description: `fetch origin/${baseBranch} before merging`,
|
|
337
|
+
retry: { attempts: 2, backoff_ms: [2000, 4000], on: 'network' },
|
|
338
|
+
optional: true,
|
|
339
|
+
});
|
|
340
|
+
steps.push({
|
|
341
|
+
args: ['git', 'merge', '--ff-only', `origin/${baseBranch}`],
|
|
342
|
+
description: `fast-forward ${baseBranch} to origin (halts if base has diverged — manual rebase required)`,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
if (squash) {
|
|
346
|
+
steps.push({
|
|
347
|
+
args: ['git', 'merge', '--squash', branch],
|
|
348
|
+
description: `squash-merge ${branch} into ${baseBranch}`,
|
|
349
|
+
});
|
|
350
|
+
// `--message=<msg>` form so a user-customized commit_template_story
|
|
351
|
+
// that produces a leading `-` doesn't get interpreted as a flag.
|
|
352
|
+
steps.push({
|
|
353
|
+
args: ['git', 'commit', `--message=${message}`],
|
|
354
|
+
description: `commit squash merge on ${baseBranch}`,
|
|
355
|
+
});
|
|
356
|
+
} else {
|
|
357
|
+
// Same `--message=` form for the merge commit.
|
|
358
|
+
steps.push({
|
|
359
|
+
args: ['git', 'merge', '--no-ff', `--message=Merge ${branch}`, branch],
|
|
360
|
+
description: `merge ${branch} into ${baseBranch} (no-ff)`,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
steps.push({
|
|
364
|
+
args: ['git', 'push', 'origin', baseBranch],
|
|
365
|
+
description: `push ${baseBranch}`,
|
|
366
|
+
retry: { attempts: 4, backoff_ms: [2000, 4000, 8000, 16000], on: 'network' },
|
|
367
|
+
});
|
|
368
|
+
steps.push({
|
|
369
|
+
args: ['git', 'switch', branch],
|
|
370
|
+
description: `return to ${branch}`,
|
|
371
|
+
});
|
|
372
|
+
return { branch, steps, warnings: warnings.length ? warnings : undefined };
|
|
373
|
+
}
|
|
374
|
+
|
|
156
375
|
// Phase 2 — sync `_bmad-output/` to `<base_branch>`. BMad planning +
|
|
157
376
|
// bookkeeping artifacts must land on main per story so `git log main`
|
|
158
377
|
// is the canonical sprint audit trail. Without this, planning
|
|
159
378
|
// artifacts, sprint-status, story files, and reviews exist only on
|
|
160
379
|
// the story branch.
|
|
380
|
+
//
|
|
381
|
+
// Skipped entirely under direct-merge mode (push_create_pr=false): the
|
|
382
|
+
// full merge into base already carries _bmad-output along, so a
|
|
383
|
+
// dedicated docs-only commit would be redundant noise. Likewise
|
|
384
|
+
// skipped under merge_strategy=land_as_you_go where land.js handles
|
|
385
|
+
// the full merge.
|
|
161
386
|
steps.push({
|
|
162
387
|
args: ['git', 'switch', baseBranch],
|
|
163
388
|
description: `switch to ${baseBranch} to sync BMad artifacts`,
|
|
@@ -171,10 +396,10 @@ function planCommitAndPush(state, profile, action, branch) {
|
|
|
171
396
|
description: 'stage BMad artifacts',
|
|
172
397
|
});
|
|
173
398
|
steps.push({
|
|
174
|
-
args: ['git', 'commit', '--allow-empty',
|
|
399
|
+
args: ['git', 'commit', '--allow-empty', `--message=docs(${storyKey}): BMad artifacts`],
|
|
175
400
|
description: `commit BMad artifacts to ${baseBranch} (--allow-empty for no-diff stories)`,
|
|
176
401
|
});
|
|
177
|
-
if (hasOrigin) {
|
|
402
|
+
if (hasOrigin && pushAuto) {
|
|
178
403
|
steps.push({
|
|
179
404
|
args: ['git', 'push', 'origin', baseBranch],
|
|
180
405
|
description: `push ${baseBranch}`,
|
|
@@ -186,19 +411,275 @@ function planCommitAndPush(state, profile, action, branch) {
|
|
|
186
411
|
description: `return to ${branch} for the next phase`,
|
|
187
412
|
});
|
|
188
413
|
|
|
189
|
-
return { branch, steps };
|
|
414
|
+
return { branch, steps, warnings: warnings.length ? warnings : undefined };
|
|
190
415
|
}
|
|
191
416
|
|
|
417
|
+
// Build the story commit message from profile.commit_template_story with
|
|
418
|
+
// placeholders expanded. Placeholders:
|
|
419
|
+
// {story-key} — state.story_key
|
|
420
|
+
// {epic} — state.current_epic (derived from story_key when absent)
|
|
421
|
+
// {story-title} — state.ac_summary (BMad's acceptance-criteria summary)
|
|
422
|
+
// fallback to story_key
|
|
423
|
+
// Honors the config.yaml#commit_templates.story contract.
|
|
424
|
+
function buildStoryCommitMessage(state, profile) {
|
|
425
|
+
const tmpl = (profile && profile.commit_template_story) || 'feat({epic}): {story-title} ({story-key})';
|
|
426
|
+
const storyKey = state.story_key || 'sprint';
|
|
427
|
+
const epic = state.current_epic || deriveEpicFromStoryKey(storyKey) || 'sprint';
|
|
428
|
+
const title = state.ac_summary || state.story_title || storyKey;
|
|
429
|
+
return expandTemplate(tmpl, { 'story-key': storyKey, epic, 'story-title': title });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Expand `{placeholder}` tokens in `template` from `vars`. Function-form
|
|
433
|
+
// replacement so `$1`/`$&`/`$$` etc. in substituted values are treated
|
|
434
|
+
// as literal characters (string-form `.replace` would interpret them
|
|
435
|
+
// as regex backreferences and corrupt LLM-authored text like
|
|
436
|
+
// "Add $1 button"). Unknown placeholders are left as-is.
|
|
437
|
+
//
|
|
438
|
+
// Case-sensitive: `{story-key}` matches but `{Story-Key}` does not.
|
|
439
|
+
// vars is case-sensitive too, so mixed-case in templates would silently
|
|
440
|
+
// produce no expansion — make it explicit at the regex level instead.
|
|
441
|
+
function expandTemplate(template, vars) {
|
|
442
|
+
if (typeof template !== 'string') return '';
|
|
443
|
+
return template.replace(/\{([a-z][a-z0-9-]*)\}/g, (match, key) => {
|
|
444
|
+
if (Object.prototype.hasOwnProperty.call(vars, key)) {
|
|
445
|
+
return String(vars[key]);
|
|
446
|
+
}
|
|
447
|
+
return match;
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function deriveEpicFromStoryKey(storyKey) {
|
|
452
|
+
if (typeof storyKey !== 'string') return null;
|
|
453
|
+
const m = storyKey.match(/^(epic-[A-Za-z0-9_]+|[A-Za-z0-9_]+)-/);
|
|
454
|
+
return m ? m[1] : null;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Build the PR body. Precedence:
|
|
458
|
+
// 1. action.pr_body (caller override) [handled by caller, not here]
|
|
459
|
+
// 2. profile.pr_template_path file contents (with placeholders expanded)
|
|
460
|
+
// 3. Minimal one-line default derived from state.
|
|
461
|
+
//
|
|
462
|
+
// The template file path is resolved relative to the project root (the
|
|
463
|
+
// directory git operates in). pr_template_path is configured in
|
|
464
|
+
// modules/git/config.yaml#push.pr_template (defaults to
|
|
465
|
+
// `modules/git/templates/pr-body.md`).
|
|
466
|
+
//
|
|
467
|
+
// Plain-text fallback only — argv is shell-safe today but downstream
|
|
468
|
+
// wrappers might pipe; defense in depth.
|
|
469
|
+
function buildPrBody(state, profile, projectRoot, warnings) {
|
|
470
|
+
const storyKey = state.story_key || 'sprint';
|
|
471
|
+
const title = state.ac_summary || storyKey;
|
|
472
|
+
const profileName = (profile && profile.name) || 'medium';
|
|
473
|
+
|
|
474
|
+
const tmplPath = profile && profile.pr_template_path;
|
|
475
|
+
if (tmplPath && projectRoot) {
|
|
476
|
+
// Resolve relative paths against the _Sprintpilot/ subtree per the
|
|
477
|
+
// config convention. Absolute paths are used verbatim.
|
|
478
|
+
const candidates = [];
|
|
479
|
+
if (path.isAbsolute(tmplPath)) {
|
|
480
|
+
candidates.push(tmplPath);
|
|
481
|
+
} else {
|
|
482
|
+
candidates.push(path.join(projectRoot, '_Sprintpilot', tmplPath));
|
|
483
|
+
candidates.push(path.join(projectRoot, tmplPath));
|
|
484
|
+
}
|
|
485
|
+
for (const p of candidates) {
|
|
486
|
+
try {
|
|
487
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
488
|
+
return expandTemplate(raw, {
|
|
489
|
+
'story-key': storyKey,
|
|
490
|
+
epic: state.current_epic || 'sprint',
|
|
491
|
+
'story-title': title,
|
|
492
|
+
});
|
|
493
|
+
} catch (_e) {
|
|
494
|
+
/* try next candidate */
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
// Template configured but unreadable — record on the plan so the
|
|
498
|
+
// edge layer can surface it (e.g. via ledger or stderr) without
|
|
499
|
+
// git-plan.js itself touching stderr from a pure-ish function.
|
|
500
|
+
// Tests can opt into asserting on these warnings; production
|
|
501
|
+
// callers see them in the plan return value.
|
|
502
|
+
if (warnings && Array.isArray(warnings)) {
|
|
503
|
+
warnings.push(`pr_template_path "${tmplPath}" not found; using default body`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return `Story ${storyKey} — ${title}\n\nGenerated by Sprintpilot autopilot (profile: ${profileName}).`;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// planMergeEpic — closes out an epic branch at MERGE_EPIC.
|
|
511
|
+
//
|
|
512
|
+
// Two modes, chosen by profile.push_create_pr:
|
|
513
|
+
// • push_create_pr=true (default): merge via the platform PR using
|
|
514
|
+
// `gh pr merge <branch> --squash|--merge --delete-branch`. The PR
|
|
515
|
+
// must already exist (planCommitAndPush opens one per push under
|
|
516
|
+
// stacked + granularity=epic and the idempotency in create-pr.js
|
|
517
|
+
// means subsequent pushes re-use it). Squash governed by
|
|
518
|
+
// profile.squash_on_merge — true under nano + epic per config.yaml.
|
|
519
|
+
// • push_create_pr=false: local merge sequence (existing behavior),
|
|
520
|
+
// fast-forward base from remote first, then merge the epic branch
|
|
521
|
+
// and push base.
|
|
522
|
+
//
|
|
523
|
+
// Both paths honor profile.squash_on_merge. The PR-merge path also
|
|
524
|
+
// requires profile.platform_provider; non-github platforms fall through
|
|
525
|
+
// to the local-merge sequence with a description note.
|
|
192
526
|
function planMergeEpic(state, profile, _action, branch) {
|
|
193
527
|
const baseBranch = profile.base_branch || 'main';
|
|
194
528
|
const squash = !!profile.squash_on_merge;
|
|
195
|
-
const
|
|
529
|
+
const hasOrigin = profile.has_origin !== false;
|
|
530
|
+
const usePr = profile.push_create_pr !== false;
|
|
531
|
+
const platform = profile.platform_provider || 'auto';
|
|
532
|
+
// Prefer the epic-specific knob; fall back to land_wait_minutes for
|
|
533
|
+
// legacy configs that only set the land-as-you-go value.
|
|
534
|
+
const waitMinutes = Number.isFinite(profile.epic_merge_wait_minutes)
|
|
535
|
+
? profile.epic_merge_wait_minutes
|
|
536
|
+
: Number.isFinite(profile.land_wait_minutes)
|
|
537
|
+
? profile.land_wait_minutes
|
|
538
|
+
: 30;
|
|
539
|
+
|
|
540
|
+
// PR-merge path. Two sub-paths by platform.
|
|
541
|
+
if (usePr && hasOrigin) {
|
|
542
|
+
// Self-hosted instances: thread platform_base_url onto the merge
|
|
543
|
+
// step as an `env` map so the platform CLI targets the right host.
|
|
544
|
+
// gh reads `GH_HOST` and uses a per-host token from `~/.config/gh/`.
|
|
545
|
+
// glab reads `GITLAB_URI` (also `GITLAB_HOST` for older versions).
|
|
546
|
+
// The step runner (run-step.js + the LLM contract in
|
|
547
|
+
// workflow.orchestrator.md) merges `env` into `process.env` for the
|
|
548
|
+
// step's lifetime. Empty `env` is a no-op.
|
|
549
|
+
const env = {};
|
|
550
|
+
if (profile.platform_base_url) {
|
|
551
|
+
try {
|
|
552
|
+
const url = new URL(profile.platform_base_url);
|
|
553
|
+
env.GH_HOST = url.host;
|
|
554
|
+
env.GITLAB_URI = profile.platform_base_url;
|
|
555
|
+
} catch (_e) {
|
|
556
|
+
// Malformed base_url — leave env empty; the merge will use the
|
|
557
|
+
// default host and the user will see a clear "wrong host" error.
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// github / auto: gh pr merge. The autopilot can't safely merge
|
|
562
|
+
// before CI checks complete (branch protection would reject the
|
|
563
|
+
// merge), so we prepend a `create-pr.js --mode checks` wait step
|
|
564
|
+
// when CI gating is plausible. epic_merge_wait_minutes (default 30)
|
|
565
|
+
// is the wait budget; falls back to land_wait_minutes for older
|
|
566
|
+
// configs that only set the land knob.
|
|
567
|
+
if (platform === 'github' || platform === 'auto') {
|
|
568
|
+
const scriptPath = '_Sprintpilot/scripts/create-pr.js';
|
|
569
|
+
const mergeFlag = squash ? '--squash' : '--merge';
|
|
570
|
+
return {
|
|
571
|
+
branch,
|
|
572
|
+
steps: [
|
|
573
|
+
{
|
|
574
|
+
args: [
|
|
575
|
+
'node',
|
|
576
|
+
scriptPath,
|
|
577
|
+
'--mode',
|
|
578
|
+
'checks',
|
|
579
|
+
'--platform',
|
|
580
|
+
platform,
|
|
581
|
+
'--branch',
|
|
582
|
+
branch,
|
|
583
|
+
'--base',
|
|
584
|
+
baseBranch,
|
|
585
|
+
'--wait-minutes',
|
|
586
|
+
String(waitMinutes),
|
|
587
|
+
],
|
|
588
|
+
description: `wait for CI green on epic ${branch} (max ${waitMinutes}m)`,
|
|
589
|
+
// SKIPPED (exit 2 — no gh CLI / no checks configured) is OK:
|
|
590
|
+
// gh pr merge will surface any real blocker.
|
|
591
|
+
tolerate_exit_codes: [0, 2],
|
|
592
|
+
retry: { attempts: 1, on: 'never' },
|
|
593
|
+
env: Object.keys(env).length ? env : undefined,
|
|
594
|
+
},
|
|
595
|
+
// Switch to base before delete-branch so gh isn't asked to
|
|
596
|
+
// delete the currently-checked-out branch (which fails on
|
|
597
|
+
// worktree setups). Safe under any setup: a switch back isn't
|
|
598
|
+
// needed because the epic is done — next state is RETROSPECTIVE
|
|
599
|
+
// or sprint finalize.
|
|
600
|
+
{
|
|
601
|
+
args: ['git', 'switch', baseBranch],
|
|
602
|
+
description:
|
|
603
|
+
`switch to ${baseBranch} so gh can delete the merged branch.` +
|
|
604
|
+
` Halts on dirty working tree or missing local ${baseBranch} — recover with` +
|
|
605
|
+
` \`git stash\` (uncommitted changes) or \`git fetch origin ${baseBranch}:${baseBranch}\`` +
|
|
606
|
+
` (no local copy), then resume autopilot.`,
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
args: ['gh', 'pr', 'merge', branch, mergeFlag, '--delete-branch'],
|
|
610
|
+
description: `merge epic PR for ${branch} via gh (${squash ? 'squash' : 'merge'}, delete branch)`,
|
|
611
|
+
env: Object.keys(env).length ? env : undefined,
|
|
612
|
+
},
|
|
613
|
+
],
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
// gitlab: glab mr merge. The autopilot follows the same wait-then-
|
|
617
|
+
// merge structure but skips the wait step (create-pr.js --mode
|
|
618
|
+
// checks returns SKIPPED for gitlab today).
|
|
619
|
+
if (platform === 'gitlab') {
|
|
620
|
+
const glabArgs = ['glab', 'mr', 'merge', branch];
|
|
621
|
+
if (squash) glabArgs.push('--squash');
|
|
622
|
+
glabArgs.push('--remove-source-branch', '--yes');
|
|
623
|
+
return {
|
|
624
|
+
branch,
|
|
625
|
+
steps: [
|
|
626
|
+
{
|
|
627
|
+
args: ['git', 'switch', baseBranch],
|
|
628
|
+
description:
|
|
629
|
+
`switch to ${baseBranch} before MR merge.` +
|
|
630
|
+
` Halts on dirty working tree or missing local ${baseBranch} — recover with` +
|
|
631
|
+
` \`git stash\` or \`git fetch origin ${baseBranch}:${baseBranch}\`, then resume.`,
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
args: glabArgs,
|
|
635
|
+
description: `merge epic MR for ${branch} via glab${squash ? ' (squash)' : ''}`,
|
|
636
|
+
env: Object.keys(env).length ? env : undefined,
|
|
637
|
+
},
|
|
638
|
+
],
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
// bitbucket / gitea: no autopilot-supported merge CLI today. Emit a
|
|
642
|
+
// user_prompt halt so the user can close the PR manually rather
|
|
643
|
+
// than silently dropping back to local merge (which would bypass
|
|
644
|
+
// the platform's review state).
|
|
645
|
+
if (platform === 'bitbucket' || platform === 'gitea') {
|
|
646
|
+
return {
|
|
647
|
+
branch,
|
|
648
|
+
// Special-cased: the runner sees a plan with `halt_action` and
|
|
649
|
+
// empty `steps`. autopilot.js#decorateGitOp converts the
|
|
650
|
+
// halt_action into a top-level user_prompt action; the LLM
|
|
651
|
+
// gets the prompt and pauses.
|
|
652
|
+
halt_action: {
|
|
653
|
+
type: 'user_prompt',
|
|
654
|
+
reason: 'epic_merge_unsupported_platform',
|
|
655
|
+
// Resume guidance: the orchestrator's adapt.js for MERGE_EPIC
|
|
656
|
+
// phase advances unconditionally on `status: success`, so
|
|
657
|
+
// there's no structured output to include here. Keeping the
|
|
658
|
+
// resume command minimal avoids implying any specific schema
|
|
659
|
+
// contract that doesn't exist.
|
|
660
|
+
prompt:
|
|
661
|
+
`Auto-merging epic PRs on ${platform} is not yet supported.\n\n` +
|
|
662
|
+
`Manual steps:\n` +
|
|
663
|
+
` 1. Merge the PR for branch '${branch}' into '${baseBranch}' via your ${platform} UI.\n` +
|
|
664
|
+
` 2. Delete the source branch (if your workflow normally does so).\n` +
|
|
665
|
+
` 3. Resume by running: autopilot record --signal '{"status":"success"}'`,
|
|
666
|
+
platform,
|
|
667
|
+
branch,
|
|
668
|
+
base_branch: baseBranch,
|
|
669
|
+
},
|
|
670
|
+
steps: [],
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
}
|
|
196
674
|
|
|
197
|
-
|
|
675
|
+
// Local-merge fallback. Used when push_create_pr=false, has_origin is
|
|
676
|
+
// false (local-only repo), or platform is git_only.
|
|
677
|
+
const steps = [];
|
|
678
|
+
if (hasOrigin) {
|
|
198
679
|
steps.push({ args: ['git', 'fetch', 'origin'], description: 'sync with remote' });
|
|
199
680
|
}
|
|
200
681
|
steps.push({ args: ['git', 'switch', baseBranch], description: `switch to ${baseBranch}` });
|
|
201
|
-
if (
|
|
682
|
+
if (hasOrigin) {
|
|
202
683
|
steps.push({
|
|
203
684
|
args: ['git', 'merge', '--ff-only', `origin/${baseBranch}`],
|
|
204
685
|
description: 'fast-forward base to remote',
|
|
@@ -209,17 +690,22 @@ function planMergeEpic(state, profile, _action, branch) {
|
|
|
209
690
|
args: ['git', 'merge', '--squash', branch],
|
|
210
691
|
description: `squash-merge ${branch}`,
|
|
211
692
|
});
|
|
693
|
+
// current_epic may originate from sprint-status.yaml / LLM signals —
|
|
694
|
+
// sanitize before interpolating into a commit message argv element.
|
|
695
|
+
// The sanitizer enforces the same [a-z0-9._-] charset used for
|
|
696
|
+
// branch names; non-matching segments fall back to 'epic'.
|
|
697
|
+
const safeEpic = sanitizeStoryKey(state.current_epic) || 'epic';
|
|
212
698
|
steps.push({
|
|
213
|
-
args: ['git', 'commit',
|
|
699
|
+
args: ['git', 'commit', `--message=feat(${safeEpic}): squash merge`],
|
|
214
700
|
description: 'squash commit',
|
|
215
701
|
});
|
|
216
702
|
} else {
|
|
217
703
|
steps.push({
|
|
218
|
-
args: ['git', 'merge', '--no-ff',
|
|
704
|
+
args: ['git', 'merge', '--no-ff', `--message=Merge ${branch}`, branch],
|
|
219
705
|
description: `non-ff merge ${branch}`,
|
|
220
706
|
});
|
|
221
707
|
}
|
|
222
|
-
if (
|
|
708
|
+
if (hasOrigin) {
|
|
223
709
|
steps.push({
|
|
224
710
|
args: ['git', 'push', 'origin', baseBranch],
|
|
225
711
|
description: `push ${baseBranch}`,
|