@4-r-c-4-n-4/todo 0.1.5 → 0.1.6
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/BIBLE.md +13 -0
- package/dist/branch-guard.d.ts +9 -0
- package/dist/branch-guard.js +27 -0
- package/dist/cli.js +8 -1
- package/dist/commands/close.js +9 -4
- package/dist/commands/pr.d.ts +2 -0
- package/dist/commands/pr.js +55 -0
- package/dist/pr.d.ts +35 -0
- package/dist/pr.js +174 -0
- package/dist/state.js +22 -0
- package/package.json +1 -1
package/BIBLE.md
CHANGED
|
@@ -140,6 +140,19 @@ git branch -d todo/<parent-id>
|
|
|
140
140
|
|
|
141
141
|
If you need manual control instead of a loop, use `todo work --skip-branch <child-id>` to activate a child without a redundant checkout. Do NOT use plain `todo work` for subsequent children on a shared branch — it performs a no-op checkout and prints confusing resume output.
|
|
142
142
|
|
|
143
|
+
### Branch Protection (recommended)
|
|
144
|
+
|
|
145
|
+
The local-merge step above is convenient for solo work, but `todo` is built for agent-driven flows where unreviewed code shouldn't land on `main` silently. GitHub's branch-protection rules close that gap without adding any process inside the tool — turn them on once and the same `todo` workflow stops bypassing review.
|
|
146
|
+
|
|
147
|
+
For `main`, enable:
|
|
148
|
+
|
|
149
|
+
- **Require a pull request before merging** — the agent stops at the PR; you click Merge.
|
|
150
|
+
- **Require status checks to pass** — your CI's `Lint, typecheck, test` job. Catches the kind of regression that this session's commit `b6709ed` (missing `pretest` hook) would have stopped on red.
|
|
151
|
+
- **Require linear history** — enforces the skill's `--no-ff` rule. Squash merges would orphan the resolution-commit SHAs stored in `.todo/done/<id>.json`.
|
|
152
|
+
- **Require approvals (optional)** — even for a solo repo, setting "1 approval" forces you to actually open the PR and skim the diff before clicking through. Cheap insurance against autopilot merges.
|
|
153
|
+
|
|
154
|
+
Once protected, the closing step becomes `todo pr` (push branch + open PR + stop) instead of a local merge. The local-merge sequence in the previous sections remains valid for unprotected repos or quick solo work.
|
|
155
|
+
|
|
143
156
|
---
|
|
144
157
|
|
|
145
158
|
## Ticket Types
|
package/dist/branch-guard.d.ts
CHANGED
|
@@ -6,4 +6,13 @@ export interface BranchCheck {
|
|
|
6
6
|
}
|
|
7
7
|
export declare function checkOnExpectedBranch(ticket: Ticket, currentBranch: string): BranchCheck;
|
|
8
8
|
export declare function checkBranchHasTodoCommit(ticket: Ticket, repoRoot: string, commitPrefix: string): BranchCheck;
|
|
9
|
+
/**
|
|
10
|
+
* True iff the ticket has at least one child AND every child is in a
|
|
11
|
+
* terminal state (done/wontfix/duplicate). Parents in this shape carry no
|
|
12
|
+
* code commit of their own — the work lives in children whose commits use
|
|
13
|
+
* `todo:<child-id>` prefixes — so the commit-prefix branch guard should
|
|
14
|
+
* skip them. Children with missing files are treated as non-terminal so
|
|
15
|
+
* the guard stays conservative.
|
|
16
|
+
*/
|
|
17
|
+
export declare function isParentWithAllChildrenClosed(ticket: Ticket, repoRoot: string): boolean;
|
|
9
18
|
export declare function checkWorkingTreeClean(repoRoot: string): BranchCheck;
|
package/dist/branch-guard.js
CHANGED
|
@@ -8,8 +8,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
8
8
|
exports.expectedBranchFor = expectedBranchFor;
|
|
9
9
|
exports.checkOnExpectedBranch = checkOnExpectedBranch;
|
|
10
10
|
exports.checkBranchHasTodoCommit = checkBranchHasTodoCommit;
|
|
11
|
+
exports.isParentWithAllChildrenClosed = isParentWithAllChildrenClosed;
|
|
11
12
|
exports.checkWorkingTreeClean = checkWorkingTreeClean;
|
|
12
13
|
const git_js_1 = require("./git.js");
|
|
14
|
+
const ticket_js_1 = require("./ticket.js");
|
|
13
15
|
function expectedBranchFor(ticket) {
|
|
14
16
|
if (ticket.work?.branch)
|
|
15
17
|
return ticket.work.branch;
|
|
@@ -42,6 +44,31 @@ function checkBranchHasTodoCommit(ticket, repoRoot, commitPrefix) {
|
|
|
42
44
|
`if this ticket genuinely has no code change attached.`,
|
|
43
45
|
};
|
|
44
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* True iff the ticket has at least one child AND every child is in a
|
|
49
|
+
* terminal state (done/wontfix/duplicate). Parents in this shape carry no
|
|
50
|
+
* code commit of their own — the work lives in children whose commits use
|
|
51
|
+
* `todo:<child-id>` prefixes — so the commit-prefix branch guard should
|
|
52
|
+
* skip them. Children with missing files are treated as non-terminal so
|
|
53
|
+
* the guard stays conservative.
|
|
54
|
+
*/
|
|
55
|
+
function isParentWithAllChildrenClosed(ticket, repoRoot) {
|
|
56
|
+
const children = ticket.relationships?.children ?? [];
|
|
57
|
+
if (children.length === 0)
|
|
58
|
+
return false;
|
|
59
|
+
for (const childId of children) {
|
|
60
|
+
let child;
|
|
61
|
+
try {
|
|
62
|
+
child = (0, ticket_js_1.readTicket)(repoRoot, childId);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
if (!ticket_js_1.TERMINAL_STATES.includes(child.state))
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
45
72
|
function checkWorkingTreeClean(repoRoot) {
|
|
46
73
|
if (!(0, git_js_1.hasUncommittedChanges)(repoRoot))
|
|
47
74
|
return { ok: true };
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const node_fs_1 = require("node:fs");
|
|
5
|
+
const node_path_1 = require("node:path");
|
|
4
6
|
const commander_1 = require("commander");
|
|
5
7
|
const analyze_js_1 = require("./commands/analyze.js");
|
|
6
8
|
const close_js_1 = require("./commands/close.js");
|
|
@@ -13,16 +15,20 @@ const link_js_1 = require("./commands/link.js");
|
|
|
13
15
|
const list_js_1 = require("./commands/list.js");
|
|
14
16
|
const new_js_1 = require("./commands/new.js");
|
|
15
17
|
const next_js_1 = require("./commands/next.js");
|
|
18
|
+
const pr_js_1 = require("./commands/pr.js");
|
|
16
19
|
const scan_js_1 = require("./commands/scan.js");
|
|
17
20
|
const show_js_1 = require("./commands/show.js");
|
|
18
21
|
const sync_js_1 = require("./commands/sync.js");
|
|
19
22
|
const transition_js_1 = require("./commands/transition.js");
|
|
20
23
|
const work_js_1 = require("./commands/work.js");
|
|
24
|
+
// Read version from the package.json shipped alongside dist/. Avoids drift
|
|
25
|
+
// between the published npm version and what `todo --version` prints.
|
|
26
|
+
const pkg = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(__dirname, "..", "package.json"), "utf8"));
|
|
21
27
|
const program = new commander_1.Command();
|
|
22
28
|
program
|
|
23
29
|
.name("todo")
|
|
24
30
|
.description("Git-native work tracking for coding agents")
|
|
25
|
-
.version(
|
|
31
|
+
.version(pkg.version);
|
|
26
32
|
(0, init_js_1.registerInit)(program);
|
|
27
33
|
(0, new_js_1.registerNew)(program);
|
|
28
34
|
(0, list_js_1.registerList)(program);
|
|
@@ -39,4 +45,5 @@ program
|
|
|
39
45
|
(0, dedup_js_1.registerDedup)(program);
|
|
40
46
|
(0, install_hooks_js_1.registerInstallHooks)(program);
|
|
41
47
|
(0, sync_js_1.registerSync)(program);
|
|
48
|
+
(0, pr_js_1.registerPr)(program);
|
|
42
49
|
program.parse(process.argv);
|
package/dist/commands/close.js
CHANGED
|
@@ -36,10 +36,15 @@ function registerClose(program) {
|
|
|
36
36
|
console.error(`Error: ${branchCheck.message}`);
|
|
37
37
|
process.exit(1);
|
|
38
38
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
// Parents with all-terminal children carry no code commit of
|
|
40
|
+
// their own — skip the commit-prefix check for them. The
|
|
41
|
+
// branch-match check above still fires.
|
|
42
|
+
if (!(0, branch_guard_js_1.isParentWithAllChildrenClosed)(ticket, repoRoot)) {
|
|
43
|
+
const commitCheck = (0, branch_guard_js_1.checkBranchHasTodoCommit)(ticket, repoRoot, (0, config_js_1.getCommitPrefix)(config));
|
|
44
|
+
if (!commitCheck.ok) {
|
|
45
|
+
console.error(`Error: ${commitCheck.message}`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
43
48
|
}
|
|
44
49
|
}
|
|
45
50
|
else {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerPr = registerPr;
|
|
4
|
+
const context_js_1 = require("../context.js");
|
|
5
|
+
const errors_js_1 = require("../errors.js");
|
|
6
|
+
const git_js_1 = require("../git.js");
|
|
7
|
+
const pr_js_1 = require("../pr.js");
|
|
8
|
+
const ticket_js_1 = require("../ticket.js");
|
|
9
|
+
function registerPr(program) {
|
|
10
|
+
program
|
|
11
|
+
.command("pr")
|
|
12
|
+
.description("Push the current todo/<id> branch and open (or update) a GitHub PR")
|
|
13
|
+
.option("--base <branch>", "PR base branch (default: repo default)")
|
|
14
|
+
.option("--draft", "open the PR as a draft")
|
|
15
|
+
.action((opts) => {
|
|
16
|
+
const ctx = (0, context_js_1.getContext)(true);
|
|
17
|
+
const { repoRoot } = ctx;
|
|
18
|
+
try {
|
|
19
|
+
let branch;
|
|
20
|
+
try {
|
|
21
|
+
branch = (0, git_js_1.getCurrentBranch)(repoRoot);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
console.error("Error: could not resolve current branch (detached HEAD?).");
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
const id = (0, pr_js_1.branchToTicketId)(branch);
|
|
28
|
+
if (!id) {
|
|
29
|
+
console.error(`Error: not on a todo/<id> branch (HEAD is on '${branch}').\n` +
|
|
30
|
+
" Run `todo work <id>` first.");
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
let ticket;
|
|
34
|
+
try {
|
|
35
|
+
ticket = (0, ticket_js_1.readTicket)(repoRoot, id);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
console.error(`Error: branch '${branch}' references ticket '${id}' but no .todo/ ` +
|
|
39
|
+
"file exists for it. Was it deleted?");
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
const outcome = (0, pr_js_1.runPr)(repoRoot, branch, ticket, { base: opts.base, draft: !!opts.draft }, (0, pr_js_1.defaultPrEnv)(repoRoot));
|
|
43
|
+
if (outcome.kind === "error") {
|
|
44
|
+
console.error(`Error: ${outcome.message}`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
console.log(outcome.kind === "created"
|
|
48
|
+
? `Opened PR: ${outcome.url}`
|
|
49
|
+
: `Updated PR: ${outcome.url}`);
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
(0, errors_js_1.handleError)(err);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
package/dist/pr.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Ticket } from "./types.js";
|
|
2
|
+
/** Extract the ticket id from a `todo/<id>` branch name. */
|
|
3
|
+
export declare function branchToTicketId(branch: string): string | null;
|
|
4
|
+
/**
|
|
5
|
+
* Truncate a one-line title to MAX_TITLE_CHARS, ending with an ellipsis
|
|
6
|
+
* when truncated. Single-line input only — strips newlines.
|
|
7
|
+
*/
|
|
8
|
+
export declare function makeTitle(summary: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* Render the PR body for a given root ticket. For parents, includes a
|
|
11
|
+
* "Children" section with each terminal child's resolution. For single
|
|
12
|
+
* tickets, includes the description and resolution.
|
|
13
|
+
*/
|
|
14
|
+
export declare function makeBody(ticket: Ticket, repoRoot: string): string;
|
|
15
|
+
export interface PrEnv {
|
|
16
|
+
gh: (args: string[], input?: string) => string;
|
|
17
|
+
git: (args: string[]) => string;
|
|
18
|
+
hasGh: () => boolean;
|
|
19
|
+
}
|
|
20
|
+
export declare function defaultPrEnv(cwd: string): PrEnv;
|
|
21
|
+
export interface PrOptions {
|
|
22
|
+
base?: string;
|
|
23
|
+
draft?: boolean;
|
|
24
|
+
}
|
|
25
|
+
export type PrOutcome = {
|
|
26
|
+
kind: "created";
|
|
27
|
+
url: string;
|
|
28
|
+
} | {
|
|
29
|
+
kind: "updated";
|
|
30
|
+
url: string;
|
|
31
|
+
} | {
|
|
32
|
+
kind: "error";
|
|
33
|
+
message: string;
|
|
34
|
+
};
|
|
35
|
+
export declare function runPr(repoRoot: string, currentBranch: string, ticket: Ticket, opts: PrOptions, env: PrEnv): PrOutcome;
|
package/dist/pr.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Pull-request creation helpers used by `todo pr`.
|
|
3
|
+
//
|
|
4
|
+
// The CLI command is a thin wrapper around the `gh` binary plus `git push`,
|
|
5
|
+
// with title and body auto-generated from the ticket(s) on the current
|
|
6
|
+
// todo/<id> branch. Keeping the formatting and decision logic in this
|
|
7
|
+
// module so it can be unit-tested without touching the network.
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.branchToTicketId = branchToTicketId;
|
|
10
|
+
exports.makeTitle = makeTitle;
|
|
11
|
+
exports.makeBody = makeBody;
|
|
12
|
+
exports.defaultPrEnv = defaultPrEnv;
|
|
13
|
+
exports.runPr = runPr;
|
|
14
|
+
const node_child_process_1 = require("node:child_process");
|
|
15
|
+
const ticket_js_1 = require("./ticket.js");
|
|
16
|
+
const MAX_TITLE_CHARS = 70;
|
|
17
|
+
/** Extract the ticket id from a `todo/<id>` branch name. */
|
|
18
|
+
function branchToTicketId(branch) {
|
|
19
|
+
const m = branch.match(/^todo\/(.+)$/);
|
|
20
|
+
return m ? (m[1] ?? null) : null;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Truncate a one-line title to MAX_TITLE_CHARS, ending with an ellipsis
|
|
24
|
+
* when truncated. Single-line input only — strips newlines.
|
|
25
|
+
*/
|
|
26
|
+
function makeTitle(summary) {
|
|
27
|
+
const oneLine = summary.replace(/\s+/g, " ").trim();
|
|
28
|
+
if (oneLine.length <= MAX_TITLE_CHARS)
|
|
29
|
+
return oneLine;
|
|
30
|
+
return `${oneLine.slice(0, MAX_TITLE_CHARS - 1)}…`;
|
|
31
|
+
}
|
|
32
|
+
function shortSha(sha) {
|
|
33
|
+
if (!sha)
|
|
34
|
+
return "";
|
|
35
|
+
return sha.length > 7 ? sha.slice(0, 7) : sha;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Render the PR body for a given root ticket. For parents, includes a
|
|
39
|
+
* "Children" section with each terminal child's resolution. For single
|
|
40
|
+
* tickets, includes the description and resolution.
|
|
41
|
+
*/
|
|
42
|
+
function makeBody(ticket, repoRoot) {
|
|
43
|
+
const lines = [];
|
|
44
|
+
if (ticket.description) {
|
|
45
|
+
lines.push(ticket.description.trim());
|
|
46
|
+
lines.push("");
|
|
47
|
+
}
|
|
48
|
+
const children = ticket.relationships?.children ?? [];
|
|
49
|
+
if (children.length > 0) {
|
|
50
|
+
lines.push("## Children");
|
|
51
|
+
lines.push("");
|
|
52
|
+
for (const childId of children) {
|
|
53
|
+
let child;
|
|
54
|
+
try {
|
|
55
|
+
child = (0, ticket_js_1.readTicket)(repoRoot, childId);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
lines.push(`- ${childId} — _ticket file missing_`);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const status = ticket_js_1.TERMINAL_STATES.includes(child.state)
|
|
62
|
+
? child.state
|
|
63
|
+
: `**${child.state}**`;
|
|
64
|
+
const note = child.resolution?.note ?? "_no note_";
|
|
65
|
+
const sha = shortSha(child.resolution?.commit);
|
|
66
|
+
lines.push(`- **${child.id}** (${status}) — ${child.summary}` +
|
|
67
|
+
(sha ? ` _(${sha})_` : "") +
|
|
68
|
+
`\n ${note}`);
|
|
69
|
+
}
|
|
70
|
+
lines.push("");
|
|
71
|
+
}
|
|
72
|
+
if (ticket.resolution?.note) {
|
|
73
|
+
lines.push("## Resolution");
|
|
74
|
+
lines.push("");
|
|
75
|
+
lines.push(ticket.resolution.note);
|
|
76
|
+
if (ticket.resolution.commit) {
|
|
77
|
+
lines.push("");
|
|
78
|
+
lines.push(`Commit: \`${shortSha(ticket.resolution.commit)}\``);
|
|
79
|
+
}
|
|
80
|
+
lines.push("");
|
|
81
|
+
}
|
|
82
|
+
lines.push("---");
|
|
83
|
+
lines.push(`<sub>Generated by \`todo pr\` from ticket ${ticket.id}.</sub>`);
|
|
84
|
+
return lines.join("\n").trim() + "\n";
|
|
85
|
+
}
|
|
86
|
+
function defaultPrEnv(cwd) {
|
|
87
|
+
return {
|
|
88
|
+
gh: (args, input) => {
|
|
89
|
+
return (0, node_child_process_1.execFileSync)("gh", args, {
|
|
90
|
+
cwd,
|
|
91
|
+
encoding: "utf8",
|
|
92
|
+
input,
|
|
93
|
+
stdio: input ? ["pipe", "pipe", "pipe"] : ["ignore", "pipe", "pipe"],
|
|
94
|
+
}).trim();
|
|
95
|
+
},
|
|
96
|
+
git: (args) => (0, node_child_process_1.execFileSync)("git", args, { cwd, encoding: "utf8" }).trim(),
|
|
97
|
+
hasGh: () => {
|
|
98
|
+
try {
|
|
99
|
+
(0, node_child_process_1.execFileSync)("gh", ["--version"], { stdio: "ignore" });
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function findExistingPrUrl(env, branch) {
|
|
109
|
+
try {
|
|
110
|
+
const out = env.gh(["pr", "view", branch, "--json", "url", "-q", ".url"]);
|
|
111
|
+
return out || null;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function runPr(repoRoot, currentBranch, ticket, opts, env) {
|
|
118
|
+
if (!env.hasGh()) {
|
|
119
|
+
return {
|
|
120
|
+
kind: "error",
|
|
121
|
+
message: "`gh` CLI not found in PATH. Install from https://cli.github.com/ " +
|
|
122
|
+
"and run `gh auth login` once, or use the local-merge fallback " +
|
|
123
|
+
"documented in /todo-implement step 8.",
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// Push branch first (idempotent; -u sets upstream if not set).
|
|
127
|
+
try {
|
|
128
|
+
env.git(["push", "-u", "origin", currentBranch]);
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
return {
|
|
132
|
+
kind: "error",
|
|
133
|
+
message: `failed to push '${currentBranch}' to origin: ${err.message}\n` +
|
|
134
|
+
" Confirm a git remote is configured and you have push access.",
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const title = makeTitle(ticket.summary);
|
|
138
|
+
const body = makeBody(ticket, repoRoot);
|
|
139
|
+
const existingUrl = findExistingPrUrl(env, currentBranch);
|
|
140
|
+
if (existingUrl) {
|
|
141
|
+
try {
|
|
142
|
+
env.gh(["pr", "edit", currentBranch, "--title", title, "--body-file", "-"], body);
|
|
143
|
+
return { kind: "updated", url: existingUrl };
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
return {
|
|
147
|
+
kind: "error",
|
|
148
|
+
message: `failed to update existing PR: ${err.message}`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const createArgs = ["pr", "create", "--title", title, "--body-file", "-"];
|
|
153
|
+
if (opts.base)
|
|
154
|
+
createArgs.push("--base", opts.base);
|
|
155
|
+
if (opts.draft)
|
|
156
|
+
createArgs.push("--draft");
|
|
157
|
+
let url;
|
|
158
|
+
try {
|
|
159
|
+
url = env.gh(createArgs, body);
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
return {
|
|
163
|
+
kind: "error",
|
|
164
|
+
message: `gh pr create failed: ${err.message}`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
// gh prints the URL as the last non-empty line.
|
|
168
|
+
const lines = url
|
|
169
|
+
.split("\n")
|
|
170
|
+
.map((l) => l.trim())
|
|
171
|
+
.filter(Boolean);
|
|
172
|
+
const lastLine = lines[lines.length - 1] ?? "";
|
|
173
|
+
return { kind: "created", url: lastLine };
|
|
174
|
+
}
|
package/dist/state.js
CHANGED
|
@@ -34,6 +34,28 @@ function validateTransition(ticket, targetState, params, repoRoot) {
|
|
|
34
34
|
if (!params.note) {
|
|
35
35
|
throw new Error(`Parent ticket requires a resolution note when closing as done`);
|
|
36
36
|
}
|
|
37
|
+
// Every child must be in a terminal state. Belt-and-suspenders to
|
|
38
|
+
// the branch-guard check in close.ts — that one only fires when
|
|
39
|
+
// the commit-prefix needle is missing on the branch.
|
|
40
|
+
const openChildren = [];
|
|
41
|
+
const missingChildren = [];
|
|
42
|
+
for (const childId of ticket.relationships?.children ?? []) {
|
|
43
|
+
try {
|
|
44
|
+
const child = (0, ticket_js_1.readTicket)(repoRoot, childId);
|
|
45
|
+
if (!ticket_js_1.TERMINAL_STATES.includes(child.state)) {
|
|
46
|
+
openChildren.push(`${childId} (${child.state})`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
missingChildren.push(childId);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (missingChildren.length > 0) {
|
|
54
|
+
throw new Error(`Cannot close parent ${ticket.id}: child ticket(s) not found in .todo/: ${missingChildren.join(", ")}`);
|
|
55
|
+
}
|
|
56
|
+
if (openChildren.length > 0) {
|
|
57
|
+
throw new Error(`Cannot close parent ${ticket.id}: ${openChildren.length} child ticket(s) still open: ${openChildren.join(", ")}. Close them first.`);
|
|
58
|
+
}
|
|
37
59
|
// commit is optional for parent (defaults to HEAD — caller handles HEAD resolution)
|
|
38
60
|
}
|
|
39
61
|
else {
|