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