@lumpcode/recipes 0.3.0 → 0.4.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 +16 -7
- package/dist/index.cjs +605 -260
- package/dist/index.d.ts +77 -14
- package/dist/index.js +592 -262
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { normalizeSteps, readYamlList, defineConfig, getContextStatus } from '@lumpcode/cli-utils';
|
|
2
|
-
import { shellSingleQuote, pathExists } from '@lumpcode/core';
|
|
1
|
+
import { getGitCommitMessage, normalizeSteps, readYamlList, defineConfig, getContextStatus } from '@lumpcode/cli-utils';
|
|
2
|
+
import { execBinary, shellSingleQuote, pathExists } from '@lumpcode/core';
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import path$1 from 'node:path';
|
|
@@ -16,6 +16,111 @@ function shellCommand(script) {
|
|
|
16
16
|
};
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
const LOG_PREFIX = '[lumpcode/recipes]';
|
|
20
|
+
async function openGithubPr(input) {
|
|
21
|
+
const { workspacePath, baseBranch, branchName, title, body } = input;
|
|
22
|
+
const cwd = workspacePath;
|
|
23
|
+
const listed = await execBinary({
|
|
24
|
+
binaryPath: 'gh',
|
|
25
|
+
args: ['pr', 'list', '--head', branchName, '--base', baseBranch, '--json', 'number'],
|
|
26
|
+
cwd,
|
|
27
|
+
});
|
|
28
|
+
if (listed.success && githubPrListHasItems(listed.data.stdout)) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const created = await execBinary({
|
|
32
|
+
binaryPath: 'gh',
|
|
33
|
+
args: [
|
|
34
|
+
'pr',
|
|
35
|
+
'create',
|
|
36
|
+
'--base',
|
|
37
|
+
baseBranch,
|
|
38
|
+
'--head',
|
|
39
|
+
branchName,
|
|
40
|
+
'--title',
|
|
41
|
+
title,
|
|
42
|
+
'--body',
|
|
43
|
+
body,
|
|
44
|
+
],
|
|
45
|
+
cwd,
|
|
46
|
+
});
|
|
47
|
+
if (!created.success) {
|
|
48
|
+
console.error(`${LOG_PREFIX} gh pr create failed: ${created.data.message}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function githubPrListHasItems(stdout) {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(stdout);
|
|
54
|
+
return Array.isArray(parsed) && parsed.length > 0;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const LUMP_BRANCH_PREFIX = 'lump/';
|
|
62
|
+
const OPEN_PR_PROVIDERS = ['github'];
|
|
63
|
+
function openPrPostTeardown(options) {
|
|
64
|
+
const { provider, lumpName: lumpNameOption, title, body } = options;
|
|
65
|
+
return async (input) => {
|
|
66
|
+
const { baseBranch, branchName, contextList, workspacePath } = input;
|
|
67
|
+
if (!branchName || branchName === baseBranch) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const remote = await execBinary({
|
|
71
|
+
binaryPath: 'git',
|
|
72
|
+
args: ['ls-remote', '--heads', 'origin', branchName],
|
|
73
|
+
cwd: workspacePath,
|
|
74
|
+
});
|
|
75
|
+
if (!remote.success || !remote.data.stdout.trim()) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const names = contextList.map((ctx) => ctx.name).filter((name) => name.length > 0);
|
|
79
|
+
const label = names.length > 0 ? names.join(', ') : branchName;
|
|
80
|
+
const lumpName = lumpNameOption ?? lumpNameFromBranch(branchName);
|
|
81
|
+
const resolvedTitle = title?.(input) ?? defaultPrTitle({ lumpName, label });
|
|
82
|
+
const resolvedBody = body?.(input) ?? `LUMP contexts: ${label}`;
|
|
83
|
+
await openPrWithProvider({
|
|
84
|
+
provider,
|
|
85
|
+
workspacePath,
|
|
86
|
+
baseBranch,
|
|
87
|
+
branchName,
|
|
88
|
+
title: resolvedTitle,
|
|
89
|
+
body: resolvedBody,
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function defaultPrTitle(input) {
|
|
94
|
+
const { lumpName, label } = input;
|
|
95
|
+
if (lumpName) {
|
|
96
|
+
return getGitCommitMessage({ lumpName, contextName: label });
|
|
97
|
+
}
|
|
98
|
+
return `LUMP: ${label}`;
|
|
99
|
+
}
|
|
100
|
+
function lumpNameFromBranch(branchName) {
|
|
101
|
+
if (!branchName.startsWith(LUMP_BRANCH_PREFIX)) {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
const rest = branchName.slice(LUMP_BRANCH_PREFIX.length);
|
|
105
|
+
const slash = rest.indexOf('/');
|
|
106
|
+
if (slash <= 0) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
return rest.slice(0, slash);
|
|
110
|
+
}
|
|
111
|
+
async function openPrWithProvider(input) {
|
|
112
|
+
const { provider, ...prInput } = input;
|
|
113
|
+
switch (provider) {
|
|
114
|
+
case 'github':
|
|
115
|
+
await openGithubPr(prInput);
|
|
116
|
+
return;
|
|
117
|
+
default: {
|
|
118
|
+
const _exhaustive = provider;
|
|
119
|
+
throw new Error(`Unhandled PR provider: ${_exhaustive}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
19
124
|
function normalizeMaybePromGetter(maybePromGetter, defaultValue) {
|
|
20
125
|
if (typeof maybePromGetter === 'function') {
|
|
21
126
|
return maybePromGetter;
|
|
@@ -285,26 +390,54 @@ async function listTodoFolderNames(todoDir) {
|
|
|
285
390
|
throw error;
|
|
286
391
|
}
|
|
287
392
|
}
|
|
288
|
-
/**
|
|
289
|
-
async function
|
|
393
|
+
/** Live and completed ticket folder names for an umbrella parent, sorted uniquely. */
|
|
394
|
+
async function listUmbrellaTicketNames(input) {
|
|
395
|
+
const { todoDir, parentName } = input;
|
|
396
|
+
const completedDir = path$1.join(path$1.dirname(todoDir), 'completed');
|
|
397
|
+
const liveTicketNames = await listTodoFolderNames(path$1.join(todoDir, parentName, 'tickets'));
|
|
398
|
+
const completedTicketNames = await listTodoFolderNames(path$1.join(completedDir, parentName, 'tickets'));
|
|
399
|
+
return [...new Set([...liveTicketNames, ...completedTicketNames])].sort();
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Path relative to `todo/`, using `/` so it is stable in context variables.
|
|
403
|
+
* A top-level folder is an umbrella (never itself an item) when it has live
|
|
404
|
+
* tickets, a leftover `tickets/` directory, or tickets already under
|
|
405
|
+
* sibling `completed/<name>/tickets/`.
|
|
406
|
+
*/
|
|
407
|
+
async function listTodoRelativeDirs(todoDir, options) {
|
|
408
|
+
const includeUmbrellaParents = options?.includeUmbrellaParents ?? false;
|
|
290
409
|
const topNames = await listTodoFolderNames(todoDir);
|
|
410
|
+
const completedDir = path$1.join(path$1.dirname(todoDir), 'completed');
|
|
291
411
|
const relativeDirs = [];
|
|
292
412
|
for (const name of topNames) {
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
413
|
+
const liveTicketsDir = path$1.join(todoDir, name, 'tickets');
|
|
414
|
+
const liveTicketNames = await listTodoFolderNames(liveTicketsDir);
|
|
415
|
+
const completedTicketNames = await listTodoFolderNames(path$1.join(completedDir, name, 'tickets'));
|
|
416
|
+
const hasAnyTicket = liveTicketNames.length > 0 || completedTicketNames.length > 0;
|
|
417
|
+
if (liveTicketNames.length > 0) {
|
|
418
|
+
for (const ticketName of liveTicketNames) {
|
|
419
|
+
relativeDirs.push(`${name}/tickets/${ticketName}`);
|
|
420
|
+
}
|
|
421
|
+
if (includeUmbrellaParents && hasAnyTicket) {
|
|
422
|
+
relativeDirs.push(name);
|
|
423
|
+
}
|
|
296
424
|
continue;
|
|
297
425
|
}
|
|
298
|
-
|
|
299
|
-
|
|
426
|
+
const isUmbrella = (await pathExists(liveTicketsDir)) || completedTicketNames.length > 0;
|
|
427
|
+
if (isUmbrella) {
|
|
428
|
+
if (includeUmbrellaParents && hasAnyTicket) {
|
|
429
|
+
relativeDirs.push(name);
|
|
430
|
+
}
|
|
431
|
+
continue;
|
|
300
432
|
}
|
|
433
|
+
relativeDirs.push(name);
|
|
301
434
|
}
|
|
302
435
|
return relativeDirs;
|
|
303
436
|
}
|
|
304
|
-
function folderBacklogContexts({ backlogItemsDir, parseItem, parseContext, }) {
|
|
437
|
+
function folderBacklogContexts({ backlogItemsDir, includeUmbrellaParents, parseItem, parseContext, }) {
|
|
305
438
|
return async () => {
|
|
306
439
|
const todoDir = path$1.join(backlogItemsDir, 'todo');
|
|
307
|
-
const folderNames = await listTodoRelativeDirs(todoDir);
|
|
440
|
+
const folderNames = await listTodoRelativeDirs(todoDir, { includeUmbrellaParents });
|
|
308
441
|
const discovered = await Promise.all(folderNames.map(async (folderName) => {
|
|
309
442
|
const descPath = path$1.join(todoDir, folderName, 'desc.yml');
|
|
310
443
|
let rawText;
|
|
@@ -365,6 +498,37 @@ function folderBacklogContexts({ backlogItemsDir, parseItem, parseContext, }) {
|
|
|
365
498
|
function isPlainObject(value) {
|
|
366
499
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
367
500
|
}
|
|
501
|
+
async function listSubdirNames(dir) {
|
|
502
|
+
try {
|
|
503
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
504
|
+
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
505
|
+
}
|
|
506
|
+
catch (error) {
|
|
507
|
+
const err = error;
|
|
508
|
+
if (err.code === 'ENOENT') {
|
|
509
|
+
return [];
|
|
510
|
+
}
|
|
511
|
+
throw error;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
async function mergeTodoUmbrellaParentIntoCompleted(input) {
|
|
515
|
+
const { fromDir, toDir, completedDescPath, updatedDesc } = input;
|
|
516
|
+
await fs.mkdir(toDir, { recursive: true });
|
|
517
|
+
await fs.writeFile(completedDescPath, dump(updatedDesc));
|
|
518
|
+
const entries = await fs.readdir(fromDir, { withFileTypes: true });
|
|
519
|
+
for (const entry of entries) {
|
|
520
|
+
if (entry.name === 'desc.yml') {
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
const src = path$1.join(fromDir, entry.name);
|
|
524
|
+
const dest = path$1.join(toDir, entry.name);
|
|
525
|
+
if (await pathExists(dest)) {
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
await fs.rename(src, dest);
|
|
529
|
+
}
|
|
530
|
+
await fs.rm(fromDir, { recursive: true, force: true });
|
|
531
|
+
}
|
|
368
532
|
/** Moves a finished backlog item folder from todo/ to the same relative path under completed/. */
|
|
369
533
|
function folderSetTaskDoneStep(input) {
|
|
370
534
|
const nameVarName = input.nameVarName ?? 'TASK_NAME';
|
|
@@ -394,10 +558,6 @@ function folderSetTaskDoneStep(input) {
|
|
|
394
558
|
if (!(await pathExists(fromDir))) {
|
|
395
559
|
return null;
|
|
396
560
|
}
|
|
397
|
-
if (await pathExists(toDir)) {
|
|
398
|
-
console.warn(`[lumpcode/recipes] Cannot move backlog item "${taskName ?? relativeFromTodo}": already exists at ${toDir}`);
|
|
399
|
-
return null;
|
|
400
|
-
}
|
|
401
561
|
const rawText = await fs.readFile(descPath, 'utf-8');
|
|
402
562
|
const raw = load(rawText);
|
|
403
563
|
if (!isPlainObject(raw)) {
|
|
@@ -407,6 +567,26 @@ function folderSetTaskDoneStep(input) {
|
|
|
407
567
|
...raw,
|
|
408
568
|
completedAt: new Date().toISOString(),
|
|
409
569
|
};
|
|
570
|
+
const pathSegments = relativeFromTodo.split(/[/\\]/);
|
|
571
|
+
const isTopLevelParent = pathSegments.length === 1;
|
|
572
|
+
const completedTicketsDir = path$1.join(toDir, 'tickets');
|
|
573
|
+
const hasCompletedTickets = (await listSubdirNames(completedTicketsDir)).length > 0;
|
|
574
|
+
if (await pathExists(toDir)) {
|
|
575
|
+
if (isTopLevelParent && hasCompletedTickets) {
|
|
576
|
+
await mergeTodoUmbrellaParentIntoCompleted({
|
|
577
|
+
fromDir,
|
|
578
|
+
toDir,
|
|
579
|
+
completedDescPath,
|
|
580
|
+
updatedDesc: updated,
|
|
581
|
+
});
|
|
582
|
+
return {
|
|
583
|
+
executable: 'cat',
|
|
584
|
+
args: [completedDescPath],
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
console.warn(`[lumpcode/recipes] Cannot move backlog item "${taskName ?? relativeFromTodo}": already exists at ${toDir}`);
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
410
590
|
await fs.mkdir(path$1.dirname(toDir), { recursive: true });
|
|
411
591
|
await fs.rename(fromDir, toDir);
|
|
412
592
|
await fs.writeFile(completedDescPath, dump(updated));
|
|
@@ -543,7 +723,7 @@ function buildStageSteps(stages, stageName) {
|
|
|
543
723
|
return normalized;
|
|
544
724
|
}
|
|
545
725
|
function backlog(options) {
|
|
546
|
-
const { configUrl, backlogItemsDir: backlogItemsDirOverride, stages, parseItem, resolveItem, ...rest } = options;
|
|
726
|
+
const { configUrl, backlogItemsDir: backlogItemsDirOverride, includeUmbrellaParents, stages, parseItem, resolveItem, ...rest } = options;
|
|
547
727
|
const paths = resolveBacklogPaths(configUrl, {
|
|
548
728
|
backlogItemsDir: backlogItemsDirOverride,
|
|
549
729
|
});
|
|
@@ -553,6 +733,7 @@ function backlog(options) {
|
|
|
553
733
|
getContextListFn: async (input) => {
|
|
554
734
|
const listFn = folderBacklogContexts({
|
|
555
735
|
backlogItemsDir: absoluteBacklogItemsDir,
|
|
736
|
+
includeUmbrellaParents,
|
|
556
737
|
parseItem,
|
|
557
738
|
async parseContext(item, folderName) {
|
|
558
739
|
const resolution = await resolveItem({
|
|
@@ -741,32 +922,309 @@ Do not take too much time looking for every possible abstraction. Once you found
|
|
|
741
922
|
`.trim();
|
|
742
923
|
}
|
|
743
924
|
|
|
744
|
-
|
|
745
|
-
|
|
925
|
+
function featureContextVars(context) {
|
|
926
|
+
return context.variables;
|
|
927
|
+
}
|
|
928
|
+
const defaultReqPrompt = ({ context }) => {
|
|
929
|
+
const { BACKLOG_ITEM_DIR, TASK_NAME, TASK, REQ_FILE } = featureContextVars(context);
|
|
930
|
+
return `
|
|
931
|
+
Write a requirements document for the following backlog item from @${BACKLOG_ITEM_DIR}/desc.yml.
|
|
932
|
+
|
|
933
|
+
Task name: ${TASK_NAME}
|
|
934
|
+
|
|
935
|
+
Task:
|
|
936
|
+
${TASK}
|
|
937
|
+
|
|
938
|
+
Save the requirements document to @${REQ_FILE}. Do not edit @${BACKLOG_ITEM_DIR}/desc.yml.
|
|
939
|
+
|
|
940
|
+
The requirements document should be self-contained and implementation-ready. Include:
|
|
941
|
+
- Problem statement and motivation
|
|
942
|
+
- Goals and non-goals
|
|
943
|
+
- User stories / use cases
|
|
944
|
+
- Docs updates (if relevant)
|
|
945
|
+
- Proposed behavior and UX (for CLI work, include command syntax where relevant)
|
|
946
|
+
- Technical approach and affected packages or docs
|
|
947
|
+
- Acceptance criteria
|
|
948
|
+
|
|
949
|
+
Do not implement the feature — only create the requirements markdown file.
|
|
950
|
+
Do not wait the user to answer any questions — make the best assumptions and just write the requirements document.
|
|
951
|
+
The requirements document should not contain any testing strategy details.
|
|
952
|
+
`.trim();
|
|
953
|
+
};
|
|
954
|
+
function defaultReqFixPrompt(prevValidateCommandResult) {
|
|
955
|
+
return ({ context }) => {
|
|
956
|
+
const { BACKLOG_ITEM_DIR, REQ_FILE } = featureContextVars(context);
|
|
957
|
+
return `
|
|
958
|
+
The requirements document was not created at @${REQ_FILE}.
|
|
959
|
+
|
|
960
|
+
Create it now at that exact path. Do not edit @${BACKLOG_ITEM_DIR}/desc.yml.
|
|
961
|
+
Do not implement the feature — only write the requirements markdown file.
|
|
962
|
+
The requirements document should not contain any testing strategy details.
|
|
963
|
+
|
|
964
|
+
Verification output:
|
|
965
|
+
${prevValidateCommandResult ?? '(no output captured)'}
|
|
966
|
+
`.trim();
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
const defaultTestPlanPrompt = ({ context }) => {
|
|
970
|
+
const { BACKLOG_ITEM_DIR, TASK_NAME, TASK, REQ_FILE, TEST_PLAN_FILE } = featureContextVars(context);
|
|
971
|
+
return `
|
|
972
|
+
Write a test plan for the following backlog item from @${BACKLOG_ITEM_DIR}/desc.yml.
|
|
973
|
+
|
|
974
|
+
Task name: ${TASK_NAME}
|
|
975
|
+
Task:
|
|
976
|
+
${TASK}
|
|
977
|
+
|
|
978
|
+
The requirements for this task are in @${REQ_FILE}. The test plan should match those requirements.
|
|
979
|
+
|
|
980
|
+
Save the test plan to @${TEST_PLAN_FILE}. Do not edit @${BACKLOG_ITEM_DIR}/desc.yml nor @${REQ_FILE}.
|
|
981
|
+
|
|
982
|
+
The test plan should be self-contained and implementation-ready. Include:
|
|
983
|
+
- Test cases
|
|
984
|
+
- Test data
|
|
985
|
+
- Test expectations
|
|
986
|
+
- Test implementation details
|
|
987
|
+
`.trim();
|
|
988
|
+
};
|
|
989
|
+
function defaultTestPlanFixPrompt(prevValidateCommandResult) {
|
|
990
|
+
return ({ context }) => {
|
|
991
|
+
const { BACKLOG_ITEM_DIR, REQ_FILE, TEST_PLAN_FILE } = featureContextVars(context);
|
|
992
|
+
return `
|
|
993
|
+
The test plan was not created at @${TEST_PLAN_FILE}.
|
|
994
|
+
|
|
995
|
+
Create it now at that exact path. Match the requirements in @${REQ_FILE}.
|
|
996
|
+
Do not edit @${BACKLOG_ITEM_DIR}/desc.yml nor @${REQ_FILE}.
|
|
997
|
+
|
|
998
|
+
Verification output:
|
|
999
|
+
${prevValidateCommandResult ?? '(no output captured)'}
|
|
1000
|
+
`.trim();
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
const defaultTestImplPrompt = ({ context }) => {
|
|
1004
|
+
const { BACKLOG_ITEM_DIR, TASK_NAME, TASK, REQ_FILE, TEST_PLAN_FILE } = featureContextVars(context);
|
|
1005
|
+
const planLine = TEST_PLAN_FILE
|
|
1006
|
+
? `Follow the test plan in @${TEST_PLAN_FILE}.`
|
|
1007
|
+
: 'Write skipped tests from the requirements and your judgement. Do not create a testPlan.md.';
|
|
1008
|
+
return `
|
|
1009
|
+
Write a test implementation for the following backlog item from @${BACKLOG_ITEM_DIR}/desc.yml.
|
|
1010
|
+
|
|
1011
|
+
The new tests should be skipped in order to not break the whole test suite.
|
|
1012
|
+
|
|
1013
|
+
Task name: ${TASK_NAME}
|
|
1014
|
+
Task:
|
|
1015
|
+
${TASK}
|
|
1016
|
+
|
|
1017
|
+
${planLine}
|
|
1018
|
+
The requirements for this task are in @${REQ_FILE}.
|
|
1019
|
+
`.trim();
|
|
1020
|
+
};
|
|
1021
|
+
const defaultImplPrompt = ({ context }) => {
|
|
1022
|
+
const { REQ_FILE, TEST_PLAN_FILE, WORKFLOW } = featureContextVars(context);
|
|
1023
|
+
const wantsTestImpl = (WORKFLOW ?? '').split(',').includes('testImpl');
|
|
1024
|
+
if (wantsTestImpl) {
|
|
1025
|
+
return `
|
|
1026
|
+
Implement the feature described in @${REQ_FILE}.
|
|
1027
|
+
The tests have already been implemented according to the test plan${TEST_PLAN_FILE ? ` in @${TEST_PLAN_FILE}` : ''}.
|
|
1028
|
+
Unskip all the tests that were skipped in the tests implementation.
|
|
1029
|
+
The implementation should make the tests pass. Do not edit any test file except to unskip them or if absolutely necessary.
|
|
1030
|
+
`.trim();
|
|
1031
|
+
}
|
|
1032
|
+
if (TEST_PLAN_FILE) {
|
|
1033
|
+
return `
|
|
1034
|
+
Implement the feature described in @${REQ_FILE}.
|
|
1035
|
+
Write or update tests to match the test plan in @${TEST_PLAN_FILE}, and make validation pass.
|
|
1036
|
+
Do not edit @${REQ_FILE} unless absolutely necessary.
|
|
1037
|
+
`.trim();
|
|
1038
|
+
}
|
|
1039
|
+
return `
|
|
1040
|
+
Implement the feature described in @${REQ_FILE}.
|
|
1041
|
+
Add or update tests as needed so the suite covers the change, and make validation pass.
|
|
1042
|
+
Do not edit @${REQ_FILE} unless absolutely necessary.
|
|
1043
|
+
`.trim();
|
|
1044
|
+
};
|
|
1045
|
+
const defaultDirectImplPrompt = ({ context }) => {
|
|
1046
|
+
const { BACKLOG_ITEM_DIR, REQ_FILE, TEST_PLAN_FILE } = featureContextVars(context);
|
|
1047
|
+
const extras = [
|
|
1048
|
+
REQ_FILE ? `Use the requirements in @${REQ_FILE}.` : '',
|
|
1049
|
+
TEST_PLAN_FILE ? `Use the test plan in @${TEST_PLAN_FILE} for tests.` : '',
|
|
1050
|
+
]
|
|
1051
|
+
.filter(Boolean)
|
|
1052
|
+
.join('\n');
|
|
1053
|
+
return `
|
|
1054
|
+
Implement the feature described in @${BACKLOG_ITEM_DIR}/desc.yml.
|
|
1055
|
+
${extras}
|
|
1056
|
+
Add tests as needed so the suite covers the change, and make validation pass.
|
|
1057
|
+
`.trim();
|
|
1058
|
+
};
|
|
1059
|
+
|
|
1060
|
+
const FEATURE_BACKLOG_WORKFLOW_STAGES = [
|
|
1061
|
+
'req',
|
|
1062
|
+
'testPlan',
|
|
1063
|
+
'testImpl',
|
|
1064
|
+
'impl',
|
|
746
1065
|
'directImpl',
|
|
747
|
-
'manual',
|
|
748
1066
|
];
|
|
749
|
-
const
|
|
1067
|
+
const DEFAULT_FEATURE_BACKLOG_WORKFLOW = [
|
|
1068
|
+
'req',
|
|
1069
|
+
'testPlan',
|
|
1070
|
+
'testImpl',
|
|
1071
|
+
];
|
|
1072
|
+
const DEFAULT_PRIMARY_DISCOVERY_BRANCH = 'dev';
|
|
1073
|
+
const DEFAULT_ITEM_DISCOVERY_BRANCH_PREFIX = 'feature';
|
|
1074
|
+
const FEATURE_BACKLOG_RESERVED_NAME_SUFFIXES = [
|
|
1075
|
+
'_req',
|
|
1076
|
+
'_testPlan',
|
|
1077
|
+
'_testImpl',
|
|
1078
|
+
];
|
|
1079
|
+
const WORKFLOW_PREFIX_ORDER = ['req', 'testPlan', 'testImpl'];
|
|
1080
|
+
|
|
1081
|
+
function assertDiscoveryToken(value, field) {
|
|
1082
|
+
if (value.length === 0 || /\s/.test(value) || value.includes('*') || value.endsWith('/')) {
|
|
1083
|
+
throw new Error(`featureBacklog ${field} must be a non-empty exact name without whitespace, *, or a trailing /`);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
function resolveFeatureBacklogDiscoveryOptions(options) {
|
|
1087
|
+
const primaryDiscoveryBranch = options.primaryDiscoveryBranch ?? DEFAULT_PRIMARY_DISCOVERY_BRANCH;
|
|
1088
|
+
const itemDiscoveryBranchPrefix = options.itemDiscoveryBranchPrefix ?? DEFAULT_ITEM_DISCOVERY_BRANCH_PREFIX;
|
|
1089
|
+
assertDiscoveryToken(primaryDiscoveryBranch, 'primaryDiscoveryBranch');
|
|
1090
|
+
assertDiscoveryToken(itemDiscoveryBranchPrefix, 'itemDiscoveryBranchPrefix');
|
|
1091
|
+
return { primaryDiscoveryBranch, itemDiscoveryBranchPrefix };
|
|
1092
|
+
}
|
|
1093
|
+
function campaignBranchPrefix(itemDiscoveryBranchPrefix) {
|
|
1094
|
+
return `${itemDiscoveryBranchPrefix}/`;
|
|
1095
|
+
}
|
|
1096
|
+
function workflowHasCampaignStages(workflow) {
|
|
1097
|
+
return workflow.includes('testPlan') || workflow.includes('testImpl');
|
|
1098
|
+
}
|
|
1099
|
+
function classifyDiscoveryScan(discoveryBranch, primaryDiscoveryBranch, itemDiscoveryBranchPrefix) {
|
|
1100
|
+
if (discoveryBranch === primaryDiscoveryBranch) {
|
|
1101
|
+
return 'primary';
|
|
1102
|
+
}
|
|
1103
|
+
if (discoveryBranch.startsWith(campaignBranchPrefix(itemDiscoveryBranchPrefix))) {
|
|
1104
|
+
return 'itemCampaign';
|
|
1105
|
+
}
|
|
1106
|
+
return 'unmatched';
|
|
1107
|
+
}
|
|
1108
|
+
function itemNameFromCampaignBranch(discoveryBranch, itemDiscoveryBranchPrefix) {
|
|
1109
|
+
return discoveryBranch.slice(campaignBranchPrefix(itemDiscoveryBranchPrefix).length);
|
|
1110
|
+
}
|
|
1111
|
+
function itemIsEligibleForDiscoveryScan(input) {
|
|
1112
|
+
const kind = classifyDiscoveryScan(input.discoveryBranch, input.primaryDiscoveryBranch, input.itemDiscoveryBranchPrefix);
|
|
1113
|
+
switch (kind) {
|
|
1114
|
+
case 'primary':
|
|
1115
|
+
return input.parentName === undefined && !workflowHasCampaignStages(input.workflow);
|
|
1116
|
+
case 'itemCampaign': {
|
|
1117
|
+
const campaignItemName = itemNameFromCampaignBranch(input.discoveryBranch, input.itemDiscoveryBranchPrefix);
|
|
1118
|
+
return (input.parentName ?? input.itemName) === campaignItemName;
|
|
1119
|
+
}
|
|
1120
|
+
case 'unmatched':
|
|
1121
|
+
return false;
|
|
1122
|
+
default: {
|
|
1123
|
+
const _exhaustive = kind;
|
|
1124
|
+
return _exhaustive;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
let warnedManualReq = false;
|
|
1130
|
+
function warnManualReqDeprecated() {
|
|
1131
|
+
if (warnedManualReq) {
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
warnedManualReq = true;
|
|
1135
|
+
console.warn('[lumpcode/recipes] featureBacklog: desc.yml field "manualReq" is deprecated and ignored. ' +
|
|
1136
|
+
'Omit "req" from workflow to wait for a human requirements file, or set manual: true to skip the item.');
|
|
1137
|
+
}
|
|
750
1138
|
function assertValidFeatureItemName(name) {
|
|
751
|
-
for (const suffix of
|
|
1139
|
+
for (const suffix of FEATURE_BACKLOG_RESERVED_NAME_SUFFIXES) {
|
|
752
1140
|
if (name.endsWith(suffix)) {
|
|
753
1141
|
throw new Error(`Backlog item name must not end with reserved suffix ${suffix}: ${name}`);
|
|
754
1142
|
}
|
|
755
1143
|
}
|
|
756
1144
|
}
|
|
1145
|
+
function isWorkflowStage(value) {
|
|
1146
|
+
return (typeof value === 'string' &&
|
|
1147
|
+
FEATURE_BACKLOG_WORKFLOW_STAGES.includes(value));
|
|
1148
|
+
}
|
|
1149
|
+
function normalizeWorkflow(stages) {
|
|
1150
|
+
const unique = new Set(stages);
|
|
1151
|
+
const normalized = [];
|
|
1152
|
+
for (const stage of WORKFLOW_PREFIX_ORDER) {
|
|
1153
|
+
if (unique.has(stage)) {
|
|
1154
|
+
normalized.push(stage);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
if (unique.has('directImpl')) {
|
|
1158
|
+
normalized.push('directImpl');
|
|
1159
|
+
}
|
|
1160
|
+
else if (unique.has('impl')) {
|
|
1161
|
+
normalized.push('impl');
|
|
1162
|
+
}
|
|
1163
|
+
return normalized;
|
|
1164
|
+
}
|
|
1165
|
+
function parseFeatureWorkflow(itemName, raw) {
|
|
1166
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
1167
|
+
return undefined;
|
|
1168
|
+
}
|
|
1169
|
+
const record = raw;
|
|
1170
|
+
if ('manualReq' in record) {
|
|
1171
|
+
warnManualReqDeprecated();
|
|
1172
|
+
}
|
|
1173
|
+
if (record.workflow === undefined) {
|
|
1174
|
+
return undefined;
|
|
1175
|
+
}
|
|
1176
|
+
if (!Array.isArray(record.workflow)) {
|
|
1177
|
+
throw new Error(`Backlog item "${itemName}" field "workflow" must be an array of stages: ${FEATURE_BACKLOG_WORKFLOW_STAGES.join(', ')}`);
|
|
1178
|
+
}
|
|
1179
|
+
if (record.workflow.length !== new Set(record.workflow).size) {
|
|
1180
|
+
throw new Error(`Backlog item "${itemName}" field "workflow" must not contain duplicate stages`);
|
|
1181
|
+
}
|
|
1182
|
+
const stages = [];
|
|
1183
|
+
for (const entry of record.workflow) {
|
|
1184
|
+
if (!isWorkflowStage(entry)) {
|
|
1185
|
+
throw new Error(`Backlog item "${itemName}" field "workflow" contains unknown stage ${JSON.stringify(entry)}`);
|
|
1186
|
+
}
|
|
1187
|
+
stages.push(entry);
|
|
1188
|
+
}
|
|
1189
|
+
return normalizeWorkflow(stages);
|
|
1190
|
+
}
|
|
1191
|
+
function parseManual(itemName, raw) {
|
|
1192
|
+
if (raw.manual === undefined) {
|
|
1193
|
+
return undefined;
|
|
1194
|
+
}
|
|
1195
|
+
if (typeof raw.manual !== 'boolean') {
|
|
1196
|
+
throw new Error(`Backlog item "${itemName}" field "manual" must be a boolean`);
|
|
1197
|
+
}
|
|
1198
|
+
return raw.manual === true ? true : undefined;
|
|
1199
|
+
}
|
|
1200
|
+
function resolveTerminal(workflow) {
|
|
1201
|
+
if (workflow.includes('directImpl')) {
|
|
1202
|
+
return 'directImpl';
|
|
1203
|
+
}
|
|
1204
|
+
return 'impl';
|
|
1205
|
+
}
|
|
1206
|
+
function parentNameFromTodoRelativeDir(todoRelativeDir) {
|
|
1207
|
+
const parts = todoRelativeDir.split('/');
|
|
1208
|
+
if (parts.length === 3 && parts[1] === 'tickets') {
|
|
1209
|
+
return parts[0];
|
|
1210
|
+
}
|
|
1211
|
+
return undefined;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
757
1214
|
function featureItemContextBaseName(item) {
|
|
758
1215
|
return item.parentName ? `${item.parentName}-${item.name}` : item.name;
|
|
759
1216
|
}
|
|
760
1217
|
function featureContextName(itemName, stage) {
|
|
761
1218
|
switch (stage) {
|
|
762
|
-
case '
|
|
1219
|
+
case 'req':
|
|
763
1220
|
return `${itemName}_req`;
|
|
764
|
-
case '
|
|
1221
|
+
case 'testPlan':
|
|
765
1222
|
return `${itemName}_testPlan`;
|
|
766
1223
|
case 'testImpl':
|
|
767
|
-
return `${itemName}
|
|
768
|
-
case '
|
|
1224
|
+
return `${itemName}_testImpl`;
|
|
1225
|
+
case 'impl':
|
|
769
1226
|
case 'directImpl':
|
|
1227
|
+
case 'completion':
|
|
770
1228
|
return itemName;
|
|
771
1229
|
default: {
|
|
772
1230
|
const _exhaustive = stage;
|
|
@@ -774,34 +1232,13 @@ function featureContextName(itemName, stage) {
|
|
|
774
1232
|
}
|
|
775
1233
|
}
|
|
776
1234
|
}
|
|
777
|
-
function parentNameFromTodoRelativeDir(todoRelativeDir) {
|
|
778
|
-
const parts = todoRelativeDir.split('/');
|
|
779
|
-
if (parts.length === 3 && parts[1] === 'tickets') {
|
|
780
|
-
return parts[0];
|
|
781
|
-
}
|
|
782
|
-
return undefined;
|
|
783
|
-
}
|
|
784
|
-
function parseFeatureWorkflow(itemName, raw) {
|
|
785
|
-
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
786
|
-
return undefined;
|
|
787
|
-
}
|
|
788
|
-
const record = raw;
|
|
789
|
-
if (record.workflow === undefined) {
|
|
790
|
-
return undefined;
|
|
791
|
-
}
|
|
792
|
-
if (typeof record.workflow !== 'string' ||
|
|
793
|
-
!FEATURE_BACKLOG_WORKFLOWS.includes(record.workflow)) {
|
|
794
|
-
throw new Error(`Backlog item "${itemName}" field "workflow" must be one of: ${FEATURE_BACKLOG_WORKFLOWS.join(', ')}`);
|
|
795
|
-
}
|
|
796
|
-
return record.workflow;
|
|
797
|
-
}
|
|
798
1235
|
async function resolveItemWorkflow(input) {
|
|
799
1236
|
const { item, paths, projectRoot } = input;
|
|
800
1237
|
if (item.workflow !== undefined) {
|
|
801
1238
|
return item.workflow;
|
|
802
1239
|
}
|
|
803
1240
|
if (item.parentName === undefined) {
|
|
804
|
-
return
|
|
1241
|
+
return DEFAULT_FEATURE_BACKLOG_WORKFLOW;
|
|
805
1242
|
}
|
|
806
1243
|
const parentDescPath = path$1.join(projectRoot, paths.backlogItemsDir, 'todo', item.parentName, 'desc.yml');
|
|
807
1244
|
let rawText;
|
|
@@ -811,41 +1248,58 @@ async function resolveItemWorkflow(input) {
|
|
|
811
1248
|
catch (error) {
|
|
812
1249
|
const err = error;
|
|
813
1250
|
if (err.code === 'ENOENT') {
|
|
814
|
-
return
|
|
1251
|
+
return DEFAULT_FEATURE_BACKLOG_WORKFLOW;
|
|
815
1252
|
}
|
|
816
1253
|
throw error;
|
|
817
1254
|
}
|
|
818
|
-
return parseFeatureWorkflow(item.parentName, load(rawText)) ??
|
|
1255
|
+
return parseFeatureWorkflow(item.parentName, load(rawText)) ?? DEFAULT_FEATURE_BACKLOG_WORKFLOW;
|
|
819
1256
|
}
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
const { itemName, parentName, discoveryBranch, workflow } = input;
|
|
827
|
-
if (discoveryBranch === 'dev') {
|
|
828
|
-
return workflow === 'directImpl' && parentName === undefined;
|
|
1257
|
+
function artifactVariables(input) {
|
|
1258
|
+
const variables = {
|
|
1259
|
+
WORKFLOW: input.workflow.join(','),
|
|
1260
|
+
};
|
|
1261
|
+
if (input.hasReq || input.stage === 'req') {
|
|
1262
|
+
variables.REQ_FILE = input.reqFilePath;
|
|
829
1263
|
}
|
|
830
|
-
if (
|
|
831
|
-
|
|
1264
|
+
if (input.hasTestPlan || input.stage === 'testPlan') {
|
|
1265
|
+
variables.TEST_PLAN_FILE = input.testPlanFilePath;
|
|
832
1266
|
}
|
|
833
|
-
|
|
834
|
-
return (parentName ?? itemName) === key;
|
|
1267
|
+
return variables;
|
|
835
1268
|
}
|
|
836
1269
|
async function resolveFeatureBacklogItem(input) {
|
|
837
|
-
const { item, paths, projectRoot, discoveryBranch } = input;
|
|
1270
|
+
const { item, paths, projectRoot, discoveryBranch, primaryDiscoveryBranch, itemDiscoveryBranchPrefix, } = input;
|
|
1271
|
+
if (item.parentName === undefined) {
|
|
1272
|
+
const todoDir = path$1.join(projectRoot, paths.backlogItemsDir, 'todo');
|
|
1273
|
+
const ticketNames = await listUmbrellaTicketNames({
|
|
1274
|
+
todoDir,
|
|
1275
|
+
parentName: item.name,
|
|
1276
|
+
});
|
|
1277
|
+
if (ticketNames.length > 0) {
|
|
1278
|
+
if (item.completedAt) {
|
|
1279
|
+
return { ignored: true };
|
|
1280
|
+
}
|
|
1281
|
+
if (classifyDiscoveryScan(discoveryBranch, primaryDiscoveryBranch, itemDiscoveryBranchPrefix) !== 'itemCampaign' ||
|
|
1282
|
+
itemNameFromCampaignBranch(discoveryBranch, itemDiscoveryBranchPrefix) !== item.name) {
|
|
1283
|
+
return { ignored: true };
|
|
1284
|
+
}
|
|
1285
|
+
return {
|
|
1286
|
+
stage: 'completion',
|
|
1287
|
+
contextName: item.name,
|
|
1288
|
+
additionalDependsOnContexts: ticketNames.map((ticketName) => `${item.name}-${ticketName}`),
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
838
1292
|
const contextBaseName = featureItemContextBaseName(item);
|
|
839
1293
|
const workflow = await resolveItemWorkflow({ item, paths, projectRoot });
|
|
840
|
-
if (
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
if (!!item.completedAt ||
|
|
844
|
-
!itemMatchesDiscoveryBranch({
|
|
1294
|
+
if (item.manual === true ||
|
|
1295
|
+
!!item.completedAt ||
|
|
1296
|
+
!itemIsEligibleForDiscoveryScan({
|
|
845
1297
|
itemName: item.name,
|
|
846
1298
|
parentName: item.parentName,
|
|
847
1299
|
discoveryBranch,
|
|
848
1300
|
workflow,
|
|
1301
|
+
primaryDiscoveryBranch,
|
|
1302
|
+
itemDiscoveryBranchPrefix,
|
|
849
1303
|
})) {
|
|
850
1304
|
return { ignored: true };
|
|
851
1305
|
}
|
|
@@ -853,77 +1307,75 @@ async function resolveFeatureBacklogItem(input) {
|
|
|
853
1307
|
const reqFilePath = path$1.join(itemDir, 'requirements.md');
|
|
854
1308
|
const testPlanFilePath = path$1.join(itemDir, 'testPlan.md');
|
|
855
1309
|
const hasReq = await pathExists(path$1.join(projectRoot, reqFilePath));
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
1310
|
+
const hasTestPlan = await pathExists(path$1.join(projectRoot, testPlanFilePath));
|
|
1311
|
+
const wants = (stage) => workflow.includes(stage);
|
|
1312
|
+
const terminal = resolveTerminal(workflow);
|
|
1313
|
+
const resolveStage = (stage) => ({
|
|
1314
|
+
stage,
|
|
1315
|
+
contextName: featureContextName(contextBaseName, stage),
|
|
1316
|
+
variables: artifactVariables({
|
|
1317
|
+
stage,
|
|
1318
|
+
workflow,
|
|
1319
|
+
reqFilePath,
|
|
1320
|
+
testPlanFilePath,
|
|
1321
|
+
hasReq,
|
|
1322
|
+
hasTestPlan,
|
|
1323
|
+
}),
|
|
1324
|
+
});
|
|
1325
|
+
if (wants('req') && !hasReq) {
|
|
1326
|
+
return resolveStage('req');
|
|
865
1327
|
}
|
|
866
|
-
if (
|
|
867
|
-
return {
|
|
868
|
-
stage: 'directImpl',
|
|
869
|
-
contextName: featureContextName(contextBaseName, 'directImpl'),
|
|
870
|
-
variables: { REQ_FILE: reqFilePath },
|
|
871
|
-
};
|
|
1328
|
+
if (!hasReq && (wants('testPlan') || wants('testImpl'))) {
|
|
1329
|
+
return { ignored: true };
|
|
872
1330
|
}
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
return {
|
|
876
|
-
stage: 'makeTestPlan',
|
|
877
|
-
contextName: featureContextName(contextBaseName, 'makeTestPlan'),
|
|
878
|
-
variables: {
|
|
879
|
-
REQ_FILE: reqFilePath,
|
|
880
|
-
TEST_PLAN_FILE: testPlanFilePath,
|
|
881
|
-
},
|
|
882
|
-
};
|
|
1331
|
+
if (wants('testPlan') && !hasTestPlan) {
|
|
1332
|
+
return resolveStage('testPlan');
|
|
883
1333
|
}
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1334
|
+
if (wants('testImpl')) {
|
|
1335
|
+
const testsImplContextName = featureContextName(contextBaseName, 'testImpl');
|
|
1336
|
+
const testsImplStatus = await getContextStatus({
|
|
1337
|
+
projectRoot,
|
|
1338
|
+
contextName: testsImplContextName,
|
|
1339
|
+
lumpName: paths.lumpName,
|
|
1340
|
+
baseBranch: discoveryBranch,
|
|
1341
|
+
});
|
|
1342
|
+
if (testsImplStatus === 'branchPushed') {
|
|
1343
|
+
return { ignored: true };
|
|
1344
|
+
}
|
|
1345
|
+
if (testsImplStatus !== 'finished') {
|
|
1346
|
+
return resolveStage('testImpl');
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
if (terminal === 'directImpl') {
|
|
1350
|
+
return resolveStage('directImpl');
|
|
900
1351
|
}
|
|
901
|
-
if (
|
|
1352
|
+
if (!hasReq) {
|
|
902
1353
|
return { ignored: true };
|
|
903
1354
|
}
|
|
904
|
-
return
|
|
905
|
-
stage: 'testImpl',
|
|
906
|
-
contextName: testsImplContextName,
|
|
907
|
-
variables: {
|
|
908
|
-
REQ_FILE: reqFilePath,
|
|
909
|
-
TEST_PLAN_FILE: testPlanFilePath,
|
|
910
|
-
},
|
|
911
|
-
};
|
|
1355
|
+
return resolveStage('impl');
|
|
912
1356
|
}
|
|
1357
|
+
|
|
913
1358
|
const featureBacklog = defineRecipe(function featureBacklog(options) {
|
|
914
|
-
const { configUrl, implValidateCommand, backlogItemsDir, ...rest } = options;
|
|
1359
|
+
const { configUrl, implValidateCommand, backlogItemsDir, primaryDiscoveryBranch, itemDiscoveryBranchPrefix, promptFns, ...rest } = options;
|
|
1360
|
+
const discovery = resolveFeatureBacklogDiscoveryOptions({
|
|
1361
|
+
primaryDiscoveryBranch,
|
|
1362
|
+
itemDiscoveryBranchPrefix,
|
|
1363
|
+
});
|
|
915
1364
|
const projectRoot = projectRootFromConfigUrl(configUrl);
|
|
916
1365
|
const runImplValidation = resolveImplValidateCommand(implValidateCommand ??
|
|
917
1366
|
'echo "No implementation validation command provided. I say, trust but verify, but well..."');
|
|
918
1367
|
return backlog({
|
|
919
1368
|
configUrl,
|
|
920
1369
|
backlogItemsDir,
|
|
1370
|
+
includeUmbrellaParents: true,
|
|
1371
|
+
...rest,
|
|
1372
|
+
discoveryBranches: [
|
|
1373
|
+
discovery.primaryDiscoveryBranch,
|
|
1374
|
+
`${discovery.itemDiscoveryBranchPrefix}/*`,
|
|
1375
|
+
],
|
|
921
1376
|
parseItem(baseItem, folderName, raw) {
|
|
922
1377
|
assertValidFeatureItemName(baseItem.name);
|
|
923
1378
|
const record = raw;
|
|
924
|
-
if (record.manualReq !== undefined && typeof record.manualReq !== 'boolean') {
|
|
925
|
-
throw new Error(`Backlog item "${baseItem.name}" field "manualReq" must be a boolean`);
|
|
926
|
-
}
|
|
927
1379
|
const parentName = parentNameFromTodoRelativeDir(folderName);
|
|
928
1380
|
return {
|
|
929
1381
|
...baseItem,
|
|
@@ -932,7 +1384,7 @@ const featureBacklog = defineRecipe(function featureBacklog(options) {
|
|
|
932
1384
|
dependsOn: parentName
|
|
933
1385
|
? baseItem.dependsOn?.map((dep) => `${parentName}-${dep}`)
|
|
934
1386
|
: baseItem.dependsOn,
|
|
935
|
-
|
|
1387
|
+
manual: parseManual(baseItem.name, record),
|
|
936
1388
|
workflow: parseFeatureWorkflow(baseItem.name, raw),
|
|
937
1389
|
};
|
|
938
1390
|
},
|
|
@@ -942,177 +1394,55 @@ const featureBacklog = defineRecipe(function featureBacklog(options) {
|
|
|
942
1394
|
paths,
|
|
943
1395
|
projectRoot,
|
|
944
1396
|
discoveryBranch,
|
|
1397
|
+
primaryDiscoveryBranch: discovery.primaryDiscoveryBranch,
|
|
1398
|
+
itemDiscoveryBranchPrefix: discovery.itemDiscoveryBranchPrefix,
|
|
945
1399
|
});
|
|
946
1400
|
},
|
|
947
1401
|
stages: {
|
|
948
|
-
|
|
1402
|
+
req: {
|
|
949
1403
|
completion: 'keepPending',
|
|
950
1404
|
steps: retryUntilGreen({
|
|
951
|
-
steps: [
|
|
952
|
-
{
|
|
953
|
-
promptFn({ context: ctx }) {
|
|
954
|
-
const vars = ctx.variables;
|
|
955
|
-
const { BACKLOG_ITEM_DIR, TASK_NAME, TASK, REQ_FILE } = vars;
|
|
956
|
-
return `
|
|
957
|
-
Write a requirements document for the following backlog item from @${BACKLOG_ITEM_DIR}/desc.yml.
|
|
958
|
-
|
|
959
|
-
Task name: ${TASK_NAME}
|
|
960
|
-
|
|
961
|
-
Task:
|
|
962
|
-
${TASK}
|
|
963
|
-
|
|
964
|
-
Save the requirements document to @${REQ_FILE}. Do not edit @${BACKLOG_ITEM_DIR}/desc.yml.
|
|
965
|
-
|
|
966
|
-
The requirements document should be self-contained and implementation-ready. Include:
|
|
967
|
-
- Problem statement and motivation
|
|
968
|
-
- Goals and non-goals
|
|
969
|
-
- User stories / use cases
|
|
970
|
-
- Docs updates (if relevant)
|
|
971
|
-
- Proposed behavior and UX (for CLI work, include command syntax where relevant)
|
|
972
|
-
- Technical approach and affected packages or docs
|
|
973
|
-
- Acceptance criteria
|
|
974
|
-
|
|
975
|
-
Do not implement the feature — only create the requirements markdown file.
|
|
976
|
-
Do not wait the user to answer any questions — make the best assumptions and just write the requirements document.
|
|
977
|
-
The requirements document should not contain any testing strategy details.
|
|
978
|
-
`.trim();
|
|
979
|
-
},
|
|
980
|
-
},
|
|
981
|
-
],
|
|
1405
|
+
steps: [{ promptFn: promptFns?.req ?? defaultReqPrompt }],
|
|
982
1406
|
validationCommandFn: requireArtifactStep('REQ_FILE'),
|
|
983
1407
|
fixSteps: ({ prevValidateCommandResult }) => [
|
|
984
|
-
{
|
|
985
|
-
promptFn({ context: ctx }) {
|
|
986
|
-
const vars = ctx.variables;
|
|
987
|
-
const { BACKLOG_ITEM_DIR, REQ_FILE } = vars;
|
|
988
|
-
return `
|
|
989
|
-
The requirements document was not created at @${REQ_FILE}.
|
|
990
|
-
|
|
991
|
-
Create it now at that exact path. Do not edit @${BACKLOG_ITEM_DIR}/desc.yml.
|
|
992
|
-
Do not implement the feature — only write the requirements markdown file.
|
|
993
|
-
The requirements document should not contain any testing strategy details.
|
|
994
|
-
|
|
995
|
-
Verification output:
|
|
996
|
-
${prevValidateCommandResult ?? '(no output captured)'}
|
|
997
|
-
`.trim();
|
|
998
|
-
},
|
|
999
|
-
},
|
|
1408
|
+
{ promptFn: defaultReqFixPrompt(prevValidateCommandResult) },
|
|
1000
1409
|
],
|
|
1001
1410
|
}),
|
|
1002
1411
|
},
|
|
1003
|
-
|
|
1412
|
+
testPlan: {
|
|
1004
1413
|
completion: 'keepPending',
|
|
1005
1414
|
steps: retryUntilGreen({
|
|
1006
|
-
steps: [
|
|
1007
|
-
{
|
|
1008
|
-
promptFn({ context: ctx }) {
|
|
1009
|
-
const vars = ctx.variables;
|
|
1010
|
-
const { BACKLOG_ITEM_DIR, TASK_NAME, TASK, REQ_FILE, TEST_PLAN_FILE } = vars;
|
|
1011
|
-
return `
|
|
1012
|
-
Write a test plan for the following backlog item from @${BACKLOG_ITEM_DIR}/desc.yml.
|
|
1013
|
-
|
|
1014
|
-
Task name: ${TASK_NAME}
|
|
1015
|
-
Task:
|
|
1016
|
-
${TASK}
|
|
1017
|
-
|
|
1018
|
-
The requirements for this task are in @${REQ_FILE}. The test plan should match those requirements.
|
|
1019
|
-
|
|
1020
|
-
Save the test plan to @${TEST_PLAN_FILE}. Do not edit @${BACKLOG_ITEM_DIR}/desc.yml nor @${REQ_FILE}.
|
|
1021
|
-
|
|
1022
|
-
The test plan should be self-contained and implementation-ready. Include:
|
|
1023
|
-
- Test cases
|
|
1024
|
-
- Test data
|
|
1025
|
-
- Test expectations
|
|
1026
|
-
- Test implementation details
|
|
1027
|
-
`.trim();
|
|
1028
|
-
},
|
|
1029
|
-
},
|
|
1030
|
-
],
|
|
1415
|
+
steps: [{ promptFn: promptFns?.testPlan ?? defaultTestPlanPrompt }],
|
|
1031
1416
|
validationCommandFn: requireArtifactStep('TEST_PLAN_FILE'),
|
|
1032
1417
|
fixSteps: ({ prevValidateCommandResult }) => [
|
|
1033
|
-
{
|
|
1034
|
-
promptFn({ context: ctx }) {
|
|
1035
|
-
const vars = ctx.variables;
|
|
1036
|
-
const { BACKLOG_ITEM_DIR, REQ_FILE, TEST_PLAN_FILE } = vars;
|
|
1037
|
-
return `
|
|
1038
|
-
The test plan was not created at @${TEST_PLAN_FILE}.
|
|
1039
|
-
|
|
1040
|
-
Create it now at that exact path. Match the requirements in @${REQ_FILE}.
|
|
1041
|
-
Do not edit @${BACKLOG_ITEM_DIR}/desc.yml nor @${REQ_FILE}.
|
|
1042
|
-
|
|
1043
|
-
Verification output:
|
|
1044
|
-
${prevValidateCommandResult ?? '(no output captured)'}
|
|
1045
|
-
`.trim();
|
|
1046
|
-
},
|
|
1047
|
-
},
|
|
1418
|
+
{ promptFn: defaultTestPlanFixPrompt(prevValidateCommandResult) },
|
|
1048
1419
|
],
|
|
1049
1420
|
}),
|
|
1050
1421
|
},
|
|
1051
1422
|
testImpl: {
|
|
1052
1423
|
completion: 'keepPending',
|
|
1053
|
-
steps: [
|
|
1054
|
-
{
|
|
1055
|
-
promptFn({ context: ctx }) {
|
|
1056
|
-
const vars = ctx.variables;
|
|
1057
|
-
const { BACKLOG_ITEM_DIR, TASK_NAME, TASK, REQ_FILE, TEST_PLAN_FILE } = vars;
|
|
1058
|
-
return `
|
|
1059
|
-
Write a test implementation for the following backlog item from @${BACKLOG_ITEM_DIR}/desc.yml.
|
|
1060
|
-
|
|
1061
|
-
The new tests should be skipped in order to not break the whole test suite.
|
|
1062
|
-
|
|
1063
|
-
Task name: ${TASK_NAME}
|
|
1064
|
-
Task:
|
|
1065
|
-
${TASK}
|
|
1066
|
-
|
|
1067
|
-
Follow the test plan in @${TEST_PLAN_FILE}.
|
|
1068
|
-
The requirements for this task are in @${REQ_FILE}.
|
|
1069
|
-
`.trim();
|
|
1070
|
-
},
|
|
1071
|
-
},
|
|
1072
|
-
],
|
|
1424
|
+
steps: [{ promptFn: promptFns?.testImpl ?? defaultTestImplPrompt }],
|
|
1073
1425
|
},
|
|
1074
|
-
|
|
1426
|
+
impl: {
|
|
1075
1427
|
completion: 'moveToDone',
|
|
1076
1428
|
steps: retryUntilGreen({
|
|
1077
|
-
steps: [
|
|
1078
|
-
{
|
|
1079
|
-
promptFn({ context: ctx }) {
|
|
1080
|
-
const vars = ctx.variables;
|
|
1081
|
-
const { REQ_FILE, TEST_PLAN_FILE } = vars;
|
|
1082
|
-
return `
|
|
1083
|
-
Implement the feature described in @${REQ_FILE}.
|
|
1084
|
-
The tests have already been implemented according to the test plan in @${TEST_PLAN_FILE}.
|
|
1085
|
-
Unskip all the tests that were skipped in the tests implementation.
|
|
1086
|
-
The implementation should make the tests pass. Do not edit any test file except to unskip them or if absolutely necessary.
|
|
1087
|
-
`.trim();
|
|
1088
|
-
},
|
|
1089
|
-
},
|
|
1090
|
-
],
|
|
1429
|
+
steps: [{ promptFn: promptFns?.impl ?? defaultImplPrompt }],
|
|
1091
1430
|
validationCommandFn: runImplValidation,
|
|
1092
1431
|
}),
|
|
1093
1432
|
},
|
|
1094
1433
|
directImpl: {
|
|
1095
1434
|
completion: 'moveToDone',
|
|
1096
1435
|
steps: retryUntilGreen({
|
|
1097
|
-
steps: [
|
|
1098
|
-
{
|
|
1099
|
-
promptFn({ context: ctx }) {
|
|
1100
|
-
const vars = ctx.variables;
|
|
1101
|
-
const { REQ_FILE } = vars;
|
|
1102
|
-
return `
|
|
1103
|
-
Implement the feature described in @${REQ_FILE}.
|
|
1104
|
-
Add or update tests as needed so the suite covers the change, and make validation pass.
|
|
1105
|
-
Do not edit @${REQ_FILE} unless absolutely necessary.
|
|
1106
|
-
`.trim();
|
|
1107
|
-
},
|
|
1108
|
-
},
|
|
1109
|
-
],
|
|
1436
|
+
steps: [{ promptFn: promptFns?.directImpl ?? defaultDirectImplPrompt }],
|
|
1110
1437
|
validationCommandFn: runImplValidation,
|
|
1111
1438
|
}),
|
|
1112
1439
|
},
|
|
1440
|
+
completion: {
|
|
1441
|
+
completion: 'moveToDone',
|
|
1442
|
+
steps: [],
|
|
1443
|
+
},
|
|
1113
1444
|
},
|
|
1114
|
-
...rest,
|
|
1115
1445
|
});
|
|
1116
1446
|
});
|
|
1117
1447
|
|
|
1118
|
-
export { BACKLOG_ITEMS_DIR_VAR, BACKLOG_ITEM_DIR_VAR, BACKLOG_STAGE_VAR, BACKLOG_TASK_NAME_VAR, BACKLOG_TASK_VAR,
|
|
1448
|
+
export { BACKLOG_ITEMS_DIR_VAR, BACKLOG_ITEM_DIR_VAR, BACKLOG_STAGE_VAR, BACKLOG_TASK_NAME_VAR, BACKLOG_TASK_VAR, DEFAULT_FEATURE_BACKLOG_WORKFLOW, DEFAULT_ITEM_DISCOVERY_BRANCH_PREFIX, DEFAULT_PRIMARY_DISCOVERY_BRANCH, FEATURE_BACKLOG_RESERVED_NAME_SUFFIXES, FEATURE_BACKLOG_WORKFLOW_STAGES, OPEN_PR_PROVIDERS, abstractionBacklog, abstractionFinder, backlog, backlogRecipe, defaultDirectImplPrompt, defaultImplPrompt, defaultReqFixPrompt, defaultReqPrompt, defaultTestImplPrompt, defaultTestPlanFixPrompt, defaultTestPlanPrompt, defineRecipe, ephemeralContextListFn, featureBacklog, folderBacklogContexts, folderSetTaskDoneStep, getRecursiveSteps, listTodoRelativeDirs, listUmbrellaTicketNames, lumpPathAndName, normalizeMaybePromGetter, openPrPostTeardown, parseFeatureWorkflow, projectRootFromConfigUrl, requireArtifactStep, resolveBacklogPaths, resolveFeatureBacklogDiscoveryOptions, resolveFeatureBacklogItem, resolveImplValidateCommand, retryUntilGreen, setTaskDoneStep, shellCommand, validateBaseBacklogItem, ymlBacklogContexts };
|