@lonca/baron-recipes 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +37 -5
- package/dist/index.js +90 -5
- package/package.json +5 -5
- package/recipes/task-finish.yaml +23 -6
- package/recipes/task-new.yaml +15 -0
- package/recipes/task-start.yaml +17 -10
package/dist/index.d.ts
CHANGED
|
@@ -7,14 +7,17 @@ import { KnowledgeLoop } from '@lonca/baron-knowledge-loop';
|
|
|
7
7
|
*/
|
|
8
8
|
declare const RECIPE_OPS: {
|
|
9
9
|
readonly issueCreate: "issue.create";
|
|
10
|
+
readonly issueGet: "issue.get";
|
|
10
11
|
readonly issueTransition: "issue.transition";
|
|
11
12
|
readonly issueComment: "issue.comment";
|
|
12
13
|
readonly issueLink: "issue.link";
|
|
14
|
+
readonly issueAssign: "issue.assign";
|
|
13
15
|
readonly issueQuery: "issue.query";
|
|
14
16
|
readonly scmBranchCreate: "scm.branch.create";
|
|
15
17
|
readonly scmPrCreate: "scm.pr.create";
|
|
16
18
|
readonly scmPrThread: "scm.pr.thread";
|
|
17
19
|
readonly scmPrStatus: "scm.pr.status";
|
|
20
|
+
readonly scmPrFind: "scm.pr.find";
|
|
18
21
|
readonly ciRunTrigger: "ci.run.trigger";
|
|
19
22
|
readonly ciRunCancel: "ci.run.cancel";
|
|
20
23
|
readonly deployDeployments: "deploy.deployments";
|
|
@@ -42,17 +45,43 @@ interface AskSpec {
|
|
|
42
45
|
interface AskStep {
|
|
43
46
|
readonly ask: AskSpec;
|
|
44
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* A declarative condition over the run context. Exactly ONE key; operands are interpolated before
|
|
50
|
+
* evaluation. Deliberately not an expression language: four primitive tests cover the reference
|
|
51
|
+
* guards (refuse-if-closed, refuse-containers, skip-when-PR-exists) without opening a parser
|
|
52
|
+
* attack/maintenance surface.
|
|
53
|
+
*/
|
|
54
|
+
interface StepCondition {
|
|
55
|
+
/** True when the interpolated value is present and not ''/false/null. */
|
|
56
|
+
readonly truthy?: string;
|
|
57
|
+
/** True when the interpolated value is absent, '', false, or null. */
|
|
58
|
+
readonly falsy?: string;
|
|
59
|
+
/** True when both interpolated operands are equal (string comparison). */
|
|
60
|
+
readonly equals?: readonly [string, string];
|
|
61
|
+
/** True when the interpolated operands differ (string comparison). */
|
|
62
|
+
readonly notEquals?: readonly [string, string];
|
|
63
|
+
}
|
|
64
|
+
/** A guard: when the condition is false the run STOPS with the (interpolated) message. */
|
|
65
|
+
interface RequireStep {
|
|
66
|
+
readonly require: StepCondition & {
|
|
67
|
+
readonly message: string;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
45
70
|
interface DoStep {
|
|
46
71
|
readonly do: RecipeOp;
|
|
47
72
|
/** Step parameters; string values may contain `${path}` references into the run context. */
|
|
48
73
|
readonly with?: Record<string, unknown>;
|
|
49
74
|
/** Context variable the step result is bound to. */
|
|
50
75
|
readonly as?: string;
|
|
76
|
+
/** Run the step only when the condition holds; otherwise it is skipped (its `as` stays unset). */
|
|
77
|
+
readonly when?: StepCondition;
|
|
51
78
|
}
|
|
52
79
|
interface MessageStep {
|
|
53
80
|
readonly message: string;
|
|
81
|
+
/** Emit the message only when the condition holds. */
|
|
82
|
+
readonly when?: StepCondition;
|
|
54
83
|
}
|
|
55
|
-
type Step = AskStep | DoStep | MessageStep;
|
|
84
|
+
type Step = AskStep | DoStep | MessageStep | RequireStep;
|
|
56
85
|
interface Recipe {
|
|
57
86
|
readonly name: string;
|
|
58
87
|
readonly description?: string;
|
|
@@ -71,6 +100,7 @@ declare function recipeInputs(recipe: Recipe): RecipeInput[];
|
|
|
71
100
|
declare function isAskStep(step: Step): step is AskStep;
|
|
72
101
|
declare function isDoStep(step: Step): step is DoStep;
|
|
73
102
|
declare function isMessageStep(step: Step): step is MessageStep;
|
|
103
|
+
declare function isRequireStep(step: Step): step is RequireStep;
|
|
74
104
|
/**
|
|
75
105
|
* Validate an untrusted object (typically `YAML.parse` of a recipe file) into a typed {@link Recipe}.
|
|
76
106
|
* Throws {@link BaronError} (`RECIPE_PARSE`) with an actionable, pathed message on any violation.
|
|
@@ -124,13 +154,15 @@ interface RunRecipeResult {
|
|
|
124
154
|
/**
|
|
125
155
|
* Execute a recipe step by step against the injected ports, threading a context: `ask` steps gather
|
|
126
156
|
* typed human input (skipped when pre-seeded), `do` steps call a primitive and bind its result,
|
|
127
|
-
* `message` steps surface a line
|
|
128
|
-
*
|
|
157
|
+
* `message` steps surface a line, `require` steps are engine-enforced guards (decision #19: the
|
|
158
|
+
* rules live in the engine, not in agent judgement), and a `when:` condition skips a do/message
|
|
159
|
+
* step. All workflow opinion lives in the recipe; this engine is pure mechanism (invariant #3) and
|
|
160
|
+
* does no role/native translation (that stays in the ports, #4).
|
|
129
161
|
*/
|
|
130
162
|
declare function runRecipe(recipe: Recipe, options: RunRecipeOptions): Promise<RunRecipeResult>;
|
|
131
163
|
|
|
132
164
|
/** The recipes Baron ships out of the box, runnable by name (no file path). */
|
|
133
|
-
declare const BUILTIN_RECIPE_NAMES: readonly ["task-start", "task-finish", "ship"];
|
|
165
|
+
declare const BUILTIN_RECIPE_NAMES: readonly ["task-new", "task-start", "task-finish", "ship"];
|
|
134
166
|
type BuiltinRecipeName = (typeof BUILTIN_RECIPE_NAMES)[number];
|
|
135
167
|
declare function isBuiltinRecipe(name: string): name is BuiltinRecipeName;
|
|
136
168
|
/** Raw YAML of a built-in recipe (the canonical file — no inlined copy, so it can never drift). */
|
|
@@ -153,4 +185,4 @@ interface RecipeService {
|
|
|
153
185
|
}
|
|
154
186
|
declare function createRecipeService(ports: RecipePorts, root: string): RecipeService;
|
|
155
187
|
|
|
156
|
-
export { ASK_TYPES, type AskSpec, type AskStep, type AskType, BUILTIN_RECIPE_NAMES, type BuiltinRecipeName, type DoStep, type MessageStep, RECIPE_OPS, type Recipe, type RecipeAsker, type RecipeContext, type RecipeInput, type RecipeOp, type RecipePorts, type RecipeService, type RecipeSummary, type RunRecipeOptions, type RunRecipeResult, type Step, createRecipeService, interpolate, isAskStep, isBuiltinRecipe, isDoStep, isMessageStep, isRecipeOp, loadBuiltinRecipe, loadBuiltinRecipeText, loadRecipe, parseRecipe, recipeInputs, runRecipe };
|
|
188
|
+
export { ASK_TYPES, type AskSpec, type AskStep, type AskType, BUILTIN_RECIPE_NAMES, type BuiltinRecipeName, type DoStep, type MessageStep, RECIPE_OPS, type Recipe, type RecipeAsker, type RecipeContext, type RecipeInput, type RecipeOp, type RecipePorts, type RecipeService, type RecipeSummary, type RequireStep, type RunRecipeOptions, type RunRecipeResult, type Step, type StepCondition, createRecipeService, interpolate, isAskStep, isBuiltinRecipe, isDoStep, isMessageStep, isRecipeOp, isRequireStep, loadBuiltinRecipe, loadBuiltinRecipeText, loadRecipe, parseRecipe, recipeInputs, runRecipe };
|
package/dist/index.js
CHANGED
|
@@ -3,14 +3,17 @@ import { BaronError } from "@lonca/baron-core";
|
|
|
3
3
|
import { parse as parseYaml } from "yaml";
|
|
4
4
|
var RECIPE_OPS = {
|
|
5
5
|
issueCreate: "issue.create",
|
|
6
|
+
issueGet: "issue.get",
|
|
6
7
|
issueTransition: "issue.transition",
|
|
7
8
|
issueComment: "issue.comment",
|
|
8
9
|
issueLink: "issue.link",
|
|
10
|
+
issueAssign: "issue.assign",
|
|
9
11
|
issueQuery: "issue.query",
|
|
10
12
|
scmBranchCreate: "scm.branch.create",
|
|
11
13
|
scmPrCreate: "scm.pr.create",
|
|
12
14
|
scmPrThread: "scm.pr.thread",
|
|
13
15
|
scmPrStatus: "scm.pr.status",
|
|
16
|
+
scmPrFind: "scm.pr.find",
|
|
14
17
|
ciRunTrigger: "ci.run.trigger",
|
|
15
18
|
ciRunCancel: "ci.run.cancel",
|
|
16
19
|
deployDeployments: "deploy.deployments",
|
|
@@ -43,6 +46,9 @@ function isDoStep(step) {
|
|
|
43
46
|
function isMessageStep(step) {
|
|
44
47
|
return "message" in step;
|
|
45
48
|
}
|
|
49
|
+
function isRequireStep(step) {
|
|
50
|
+
return "require" in step;
|
|
51
|
+
}
|
|
46
52
|
var PARSE_CODE = "RECIPE_PARSE";
|
|
47
53
|
function fail(message) {
|
|
48
54
|
throw new BaronError(message, PARSE_CODE);
|
|
@@ -81,6 +87,41 @@ function parseAsk(raw, where) {
|
|
|
81
87
|
}
|
|
82
88
|
};
|
|
83
89
|
}
|
|
90
|
+
var CONDITION_KEYS = ["truthy", "falsy", "equals", "notEquals"];
|
|
91
|
+
function parseCondition(raw, where) {
|
|
92
|
+
if (!isRecord(raw)) fail(`${where} must be an object.`);
|
|
93
|
+
const present = CONDITION_KEYS.filter((key2) => raw[key2] !== void 0);
|
|
94
|
+
if (present.length !== 1) {
|
|
95
|
+
fail(
|
|
96
|
+
`${where} must have exactly one of ${CONDITION_KEYS.join(", ")} (found: ${present.join(", ") || "none"}).`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const key = present[0];
|
|
100
|
+
const value = raw[key];
|
|
101
|
+
if (key === "truthy" || key === "falsy") {
|
|
102
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
103
|
+
fail(`${where}.${key} must be a non-empty string.`);
|
|
104
|
+
}
|
|
105
|
+
return key === "truthy" ? { truthy: value } : { falsy: value };
|
|
106
|
+
}
|
|
107
|
+
if (!Array.isArray(value) || value.length !== 2 || value.some((v) => typeof v !== "string")) {
|
|
108
|
+
fail(`${where}.${key} must be an array of exactly two strings.`);
|
|
109
|
+
}
|
|
110
|
+
const pair = value;
|
|
111
|
+
return key === "equals" ? { equals: pair } : { notEquals: pair };
|
|
112
|
+
}
|
|
113
|
+
function parseRequire(raw, where) {
|
|
114
|
+
const req = raw.require;
|
|
115
|
+
if (!isRecord(req)) fail(`${where}: 'require' must be an object.`);
|
|
116
|
+
if (typeof req.message !== "string" || req.message.length === 0) {
|
|
117
|
+
fail(`${where}: require.message must be a non-empty string (it is what the user sees).`);
|
|
118
|
+
}
|
|
119
|
+
const { message, ...condition } = req;
|
|
120
|
+
return { require: { ...parseCondition(condition, `${where}.require`), message } };
|
|
121
|
+
}
|
|
122
|
+
function parseWhen(raw, where) {
|
|
123
|
+
return raw.when === void 0 ? void 0 : parseCondition(raw.when, `${where}.when`);
|
|
124
|
+
}
|
|
84
125
|
function parseDo(raw, where) {
|
|
85
126
|
const op = raw.do;
|
|
86
127
|
if (typeof op !== "string" || !isRecipeOp(op)) {
|
|
@@ -93,25 +134,29 @@ function parseDo(raw, where) {
|
|
|
93
134
|
if (as !== void 0 && (typeof as !== "string" || as.length === 0)) {
|
|
94
135
|
fail(`${where}: 'as' must be a non-empty string.`);
|
|
95
136
|
}
|
|
137
|
+
const when = parseWhen(raw, where);
|
|
96
138
|
return {
|
|
97
139
|
do: op,
|
|
98
140
|
...withParams !== void 0 ? { with: withParams } : {},
|
|
99
|
-
...as !== void 0 ? { as } : {}
|
|
141
|
+
...as !== void 0 ? { as } : {},
|
|
142
|
+
...when !== void 0 ? { when } : {}
|
|
100
143
|
};
|
|
101
144
|
}
|
|
102
145
|
function parseStep(raw, index) {
|
|
103
146
|
const where = `steps[${index}]`;
|
|
104
147
|
if (!isRecord(raw)) fail(`${where} must be an object.`);
|
|
105
|
-
const kinds = ["ask", "do", "message"].filter((kind) => kind in raw);
|
|
148
|
+
const kinds = ["ask", "do", "message", "require"].filter((kind) => kind in raw);
|
|
106
149
|
if (kinds.length !== 1) {
|
|
107
150
|
fail(
|
|
108
|
-
`${where} must have exactly one of 'ask', 'do', or '
|
|
151
|
+
`${where} must have exactly one of 'ask', 'do', 'message', or 'require' (found: ${kinds.join(", ") || "none"}).`
|
|
109
152
|
);
|
|
110
153
|
}
|
|
111
154
|
if ("ask" in raw) return parseAsk(raw, where);
|
|
112
155
|
if ("do" in raw) return parseDo(raw, where);
|
|
156
|
+
if ("require" in raw) return parseRequire(raw, where);
|
|
113
157
|
if (typeof raw.message !== "string") fail(`${where}: 'message' must be a string.`);
|
|
114
|
-
|
|
158
|
+
const when = parseWhen(raw, where);
|
|
159
|
+
return { message: raw.message, ...when !== void 0 ? { when } : {} };
|
|
115
160
|
}
|
|
116
161
|
function parseRecipe(raw) {
|
|
117
162
|
if (!isRecord(raw)) fail("recipe must be an object.");
|
|
@@ -336,10 +381,14 @@ async function dispatchOp(ports, op, params) {
|
|
|
336
381
|
...optStrArray(params, "labels", op) !== void 0 ? { labels: optStrArray(params, "labels", op) } : {},
|
|
337
382
|
...optStr(params, "initialRole", op) !== void 0 ? { initialRole: reqRole(params, "initialRole", op) } : {}
|
|
338
383
|
});
|
|
384
|
+
case RECIPE_OPS.issueGet:
|
|
385
|
+
return issues(ports, op).get(reqStr(params, "id", op));
|
|
339
386
|
case RECIPE_OPS.issueTransition:
|
|
340
387
|
return issues(ports, op).transition(reqStr(params, "id", op), reqRole(params, "role", op));
|
|
341
388
|
case RECIPE_OPS.issueComment:
|
|
342
389
|
return issues(ports, op).comment(reqStr(params, "id", op), reqStr(params, "body", op));
|
|
390
|
+
case RECIPE_OPS.issueAssign:
|
|
391
|
+
return issues(ports, op).assign(reqStr(params, "id", op), reqStr(params, "assignee", op));
|
|
343
392
|
case RECIPE_OPS.issueLink:
|
|
344
393
|
return issues(ports, op).link(
|
|
345
394
|
reqStr(params, "fromId", op),
|
|
@@ -348,9 +397,11 @@ async function dispatchOp(ports, op, params) {
|
|
|
348
397
|
);
|
|
349
398
|
case RECIPE_OPS.issueQuery: {
|
|
350
399
|
const limit = optNum(params, "limit", op);
|
|
400
|
+
const assignee = optStr(params, "assignee", op);
|
|
351
401
|
const query = {
|
|
352
402
|
...optStr(params, "role", op) !== void 0 ? { role: reqRole(params, "role", op) } : {},
|
|
353
403
|
...optStr(params, "typeRole", op) !== void 0 ? { typeRole: reqTypeRole(params, "typeRole", op) } : {},
|
|
404
|
+
...assignee !== void 0 ? { assignee } : {},
|
|
354
405
|
...limit !== void 0 ? { limit } : {}
|
|
355
406
|
};
|
|
356
407
|
return issues(ports, op).query(query);
|
|
@@ -375,6 +426,10 @@ async function dispatchOp(ports, op, params) {
|
|
|
375
426
|
);
|
|
376
427
|
case RECIPE_OPS.scmPrStatus:
|
|
377
428
|
return scm(ports, op).prStatus(reqStr(params, "pullRequestId", op));
|
|
429
|
+
case RECIPE_OPS.scmPrFind: {
|
|
430
|
+
const found = await scm(ports, op).prForBranch(reqStr(params, "sourceBranch", op));
|
|
431
|
+
return found ?? null;
|
|
432
|
+
}
|
|
378
433
|
case RECIPE_OPS.ciRunTrigger: {
|
|
379
434
|
const ref = optStr(params, "ref", op);
|
|
380
435
|
const variables = optStrRecord(params, "variables", op);
|
|
@@ -446,6 +501,26 @@ async function dispatchOp(ports, op, params) {
|
|
|
446
501
|
}
|
|
447
502
|
}
|
|
448
503
|
}
|
|
504
|
+
var REQUIRE = "RECIPE_REQUIRE";
|
|
505
|
+
function isTruthy(value) {
|
|
506
|
+
return value !== void 0 && value !== null && value !== "" && value !== false;
|
|
507
|
+
}
|
|
508
|
+
function asComparable(value) {
|
|
509
|
+
return value === void 0 || value === null ? "" : String(value);
|
|
510
|
+
}
|
|
511
|
+
function evalCondition(condition, context) {
|
|
512
|
+
if (condition.truthy !== void 0) return isTruthy(interpolate(condition.truthy, context));
|
|
513
|
+
if (condition.falsy !== void 0) return !isTruthy(interpolate(condition.falsy, context));
|
|
514
|
+
if (condition.equals !== void 0) {
|
|
515
|
+
const [a, b] = condition.equals;
|
|
516
|
+
return asComparable(interpolate(a, context)) === asComparable(interpolate(b, context));
|
|
517
|
+
}
|
|
518
|
+
if (condition.notEquals !== void 0) {
|
|
519
|
+
const [a, b] = condition.notEquals;
|
|
520
|
+
return asComparable(interpolate(a, context)) !== asComparable(interpolate(b, context));
|
|
521
|
+
}
|
|
522
|
+
throw new BaronError2("Empty step condition.", REQUIRE);
|
|
523
|
+
}
|
|
449
524
|
async function runRecipe(recipe, options) {
|
|
450
525
|
const context = { ...options.inputs };
|
|
451
526
|
for (const step of recipe.steps) {
|
|
@@ -461,11 +536,20 @@ async function runRecipe(recipe, options) {
|
|
|
461
536
|
}
|
|
462
537
|
continue;
|
|
463
538
|
}
|
|
539
|
+
if (isRequireStep(step)) {
|
|
540
|
+
const { message, ...condition } = step.require;
|
|
541
|
+
if (!evalCondition(condition, context)) {
|
|
542
|
+
throw new BaronError2(String(interpolate(message, context)), REQUIRE);
|
|
543
|
+
}
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
464
546
|
if (isMessageStep(step)) {
|
|
547
|
+
if (step.when !== void 0 && !evalCondition(step.when, context)) continue;
|
|
465
548
|
options.asker.note(String(interpolate(step.message, context)));
|
|
466
549
|
continue;
|
|
467
550
|
}
|
|
468
551
|
if (isDoStep(step)) {
|
|
552
|
+
if (step.when !== void 0 && !evalCondition(step.when, context)) continue;
|
|
469
553
|
const params = interpolate(step.with ?? {}, context) ?? {};
|
|
470
554
|
const result = await dispatchOp(options.ports, step.do, params);
|
|
471
555
|
if (step.as !== void 0) context[step.as] = result;
|
|
@@ -478,7 +562,7 @@ async function runRecipe(recipe, options) {
|
|
|
478
562
|
import { readFileSync } from "fs";
|
|
479
563
|
import { fileURLToPath } from "url";
|
|
480
564
|
var RECIPES_DIR = fileURLToPath(new URL("../recipes/", import.meta.url));
|
|
481
|
-
var BUILTIN_RECIPE_NAMES = ["task-start", "task-finish", "ship"];
|
|
565
|
+
var BUILTIN_RECIPE_NAMES = ["task-new", "task-start", "task-finish", "ship"];
|
|
482
566
|
function isBuiltinRecipe(name) {
|
|
483
567
|
return BUILTIN_RECIPE_NAMES.includes(name);
|
|
484
568
|
}
|
|
@@ -567,6 +651,7 @@ export {
|
|
|
567
651
|
isDoStep,
|
|
568
652
|
isMessageStep,
|
|
569
653
|
isRecipeOp,
|
|
654
|
+
isRequireStep,
|
|
570
655
|
loadBuiltinRecipe,
|
|
571
656
|
loadBuiltinRecipeText,
|
|
572
657
|
loadRecipe,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lonca/baron-recipes",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -17,13 +17,13 @@
|
|
|
17
17
|
],
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"yaml": "^2.6.0",
|
|
20
|
-
"@lonca/baron-core": "0.
|
|
21
|
-
"@lonca/baron-knowledge-loop": "0.
|
|
20
|
+
"@lonca/baron-core": "0.3.0",
|
|
21
|
+
"@lonca/baron-knowledge-loop": "0.3.0"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"@types/node": "^22.0.0",
|
|
25
|
-
"@lonca/baron-
|
|
26
|
-
"@lonca/baron-
|
|
25
|
+
"@lonca/baron-adapter-github": "0.3.0",
|
|
26
|
+
"@lonca/baron-conformance": "0.3.0"
|
|
27
27
|
},
|
|
28
28
|
"description": "Baron recipe engine: run declarative YAML workflows over Baron primitives, deterministically.",
|
|
29
29
|
"keywords": [
|
package/recipes/task-finish.yaml
CHANGED
|
@@ -1,22 +1,39 @@
|
|
|
1
1
|
name: task-finish
|
|
2
|
-
description: Open a pull request for a branch and move
|
|
2
|
+
description: Open a draft pull request for a work-item branch and post the PR link back on the item. Idempotent — an existing open PR is reported, never duplicated. Deliberately does NOT move the item's role; review state changes when the PR merges.
|
|
3
3
|
steps:
|
|
4
4
|
- ask: { as: issueId, type: text, message: "Issue id?" }
|
|
5
5
|
- ask: { as: branch, type: text, message: "Source branch?" }
|
|
6
6
|
- ask: { as: title, type: text, message: "Pull request title?" }
|
|
7
|
+
# Idempotency probe: the engine (not the agent) decides create-vs-report (decision #19).
|
|
8
|
+
- do: scm.pr.find
|
|
9
|
+
as: existingPr
|
|
10
|
+
with:
|
|
11
|
+
sourceBranch: ${branch}
|
|
12
|
+
- message: "PR already open for ${branch}: ${existingPr.url} — not duplicating."
|
|
13
|
+
when:
|
|
14
|
+
truthy: "${existingPr}"
|
|
7
15
|
- do: scm.pr.create
|
|
8
16
|
as: pr
|
|
17
|
+
when:
|
|
18
|
+
falsy: "${existingPr}"
|
|
9
19
|
with:
|
|
10
20
|
# targetBranch omitted — defaults to the repo's default branch, keeping the recipe portable.
|
|
11
21
|
title: ${title}
|
|
12
22
|
sourceBranch: ${branch}
|
|
13
23
|
draft: true
|
|
14
|
-
- do: issue.transition
|
|
15
|
-
with:
|
|
16
|
-
id: ${issueId}
|
|
17
|
-
role: in_review
|
|
18
24
|
- do: scm.pr.thread
|
|
25
|
+
when:
|
|
26
|
+
falsy: "${existingPr}"
|
|
19
27
|
with:
|
|
20
28
|
pullRequestId: ${pr.id}
|
|
21
29
|
body: "Opened for issue ${issueId}."
|
|
22
|
-
|
|
30
|
+
# Give the card full context: reviewers/QA opening the work item see the PR without digging.
|
|
31
|
+
- do: issue.comment
|
|
32
|
+
when:
|
|
33
|
+
falsy: "${existingPr}"
|
|
34
|
+
with:
|
|
35
|
+
id: ${issueId}
|
|
36
|
+
body: "PR opened: ${pr.url} (from ${branch}). Role unchanged — moves to in_review on merge."
|
|
37
|
+
- message: "Opened PR ${pr.url} for ${issueId}. Role unchanged; transition on merge (task-move)."
|
|
38
|
+
when:
|
|
39
|
+
falsy: "${existingPr}"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
name: task-new
|
|
2
|
+
description: Create a NEW work item (type + optional parent + optional starting role). Starting work on it afterwards is `task-start`.
|
|
3
|
+
steps:
|
|
4
|
+
- ask: { as: title, type: text, message: "Work item title?" }
|
|
5
|
+
- ask: { as: typeRole, type: choice, message: "Type role?", choices: [task, bug, story] }
|
|
6
|
+
- ask: { as: parentId, type: text, message: "Parent id (empty for none)?", optional: true }
|
|
7
|
+
- do: issue.create
|
|
8
|
+
as: issue
|
|
9
|
+
with:
|
|
10
|
+
title: ${title}
|
|
11
|
+
typeRole: ${typeRole}
|
|
12
|
+
# Exactly-one-reference interpolation: an unanswered optional ask stays truly unset,
|
|
13
|
+
# so hierarchy/gap negotiation only runs when a parent was actually given.
|
|
14
|
+
parentId: ${parentId}
|
|
15
|
+
- message: "Created ${issue.key} (${issue.nativeType}): ${issue.title}"
|
package/recipes/task-start.yaml
CHANGED
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
name: task-start
|
|
2
|
-
description:
|
|
2
|
+
description: Start work on an EXISTING work item — load it, cut its canonical branch, move it to in_progress, and note the branch on the item. (Creating a new item is `task-new`.)
|
|
3
3
|
steps:
|
|
4
|
-
- ask: { as:
|
|
5
|
-
- do: issue.
|
|
4
|
+
- ask: { as: issueId, type: text, message: "Work item id?" }
|
|
5
|
+
- do: issue.get
|
|
6
6
|
as: issue
|
|
7
7
|
with:
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
id: ${issueId}
|
|
9
|
+
# Engine-enforced guards (decision #19): a failed require STOPS the run before any mutation.
|
|
10
|
+
- require:
|
|
11
|
+
notEquals: ["${issue.role}", "done"]
|
|
12
|
+
message: "${issue.key} is already done — reopen it (task-move) or pick an active item."
|
|
13
|
+
- require:
|
|
14
|
+
truthy: "${issue.branchName}"
|
|
15
|
+
message: "${issue.key} (${issue.nativeType}) has no canonical branch — containers (epic/initiative) and unmapped types are never branched. Pick a child story/task/bug."
|
|
16
|
+
# ${issue.branchName} is the core-derived canonical name (<prefix>/<id>-<slug>) — every agent and
|
|
17
|
+
# recipe derives the SAME name for the same item.
|
|
10
18
|
- do: scm.branch.create
|
|
11
19
|
as: branch
|
|
12
20
|
with:
|
|
13
|
-
# fromBranch omitted on purpose — defaults to the repo's default branch (main/release/master)
|
|
14
|
-
|
|
15
|
-
name: feature/${issue.id}
|
|
21
|
+
# fromBranch omitted on purpose — defaults to the repo's default branch (main/release/master).
|
|
22
|
+
name: ${issue.branchName}
|
|
16
23
|
- do: issue.transition
|
|
17
24
|
as: issue
|
|
18
25
|
with:
|
|
@@ -21,5 +28,5 @@ steps:
|
|
|
21
28
|
- do: issue.comment
|
|
22
29
|
with:
|
|
23
30
|
id: ${issue.id}
|
|
24
|
-
body: "
|
|
25
|
-
- message: "
|
|
31
|
+
body: "Branch created: ${branch.name}."
|
|
32
|
+
- message: "${issue.key} is in progress on ${branch.name}."
|