@kody-ade/engine 0.1.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/LICENSE +21 -0
- package/README.md +322 -0
- package/dist/agent-runner.d.ts +4 -0
- package/dist/agent-runner.js +122 -0
- package/dist/bin/cli.js +11276 -0
- package/dist/ci/parse-inputs.d.ts +6 -0
- package/dist/ci/parse-inputs.js +76 -0
- package/dist/ci/parse-safety.d.ts +6 -0
- package/dist/ci/parse-safety.js +22 -0
- package/dist/cli/args.d.ts +13 -0
- package/dist/cli/args.js +42 -0
- package/dist/cli/litellm.d.ts +2 -0
- package/dist/cli/litellm.js +85 -0
- package/dist/cli/task-resolution.d.ts +2 -0
- package/dist/cli/task-resolution.js +41 -0
- package/dist/config.d.ts +49 -0
- package/dist/config.js +72 -0
- package/dist/context.d.ts +4 -0
- package/dist/context.js +83 -0
- package/dist/definitions.d.ts +3 -0
- package/dist/definitions.js +59 -0
- package/dist/entry.d.ts +1 -0
- package/dist/entry.js +236 -0
- package/dist/git-utils.d.ts +13 -0
- package/dist/git-utils.js +174 -0
- package/dist/github-api.d.ts +14 -0
- package/dist/github-api.js +114 -0
- package/dist/kody-utils.d.ts +1 -0
- package/dist/kody-utils.js +9 -0
- package/dist/learning/auto-learn.d.ts +2 -0
- package/dist/learning/auto-learn.js +169 -0
- package/dist/logger.d.ts +14 -0
- package/dist/logger.js +51 -0
- package/dist/memory.d.ts +1 -0
- package/dist/memory.js +20 -0
- package/dist/observer.d.ts +9 -0
- package/dist/observer.js +80 -0
- package/dist/pipeline/complexity.d.ts +3 -0
- package/dist/pipeline/complexity.js +12 -0
- package/dist/pipeline/executor-registry.d.ts +3 -0
- package/dist/pipeline/executor-registry.js +20 -0
- package/dist/pipeline/hooks.d.ts +17 -0
- package/dist/pipeline/hooks.js +110 -0
- package/dist/pipeline/questions.d.ts +2 -0
- package/dist/pipeline/questions.js +44 -0
- package/dist/pipeline/runner-selection.d.ts +2 -0
- package/dist/pipeline/runner-selection.js +13 -0
- package/dist/pipeline/state.d.ts +4 -0
- package/dist/pipeline/state.js +37 -0
- package/dist/pipeline.d.ts +3 -0
- package/dist/pipeline.js +213 -0
- package/dist/preflight.d.ts +1 -0
- package/dist/preflight.js +69 -0
- package/dist/retrospective.d.ts +26 -0
- package/dist/retrospective.js +211 -0
- package/dist/stages/agent.d.ts +2 -0
- package/dist/stages/agent.js +94 -0
- package/dist/stages/gate.d.ts +2 -0
- package/dist/stages/gate.js +32 -0
- package/dist/stages/review.d.ts +2 -0
- package/dist/stages/review.js +32 -0
- package/dist/stages/ship.d.ts +3 -0
- package/dist/stages/ship.js +154 -0
- package/dist/stages/verify.d.ts +2 -0
- package/dist/stages/verify.js +94 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.js +1 -0
- package/dist/validators.d.ts +8 -0
- package/dist/validators.js +42 -0
- package/dist/verify-runner.d.ts +11 -0
- package/dist/verify-runner.js +110 -0
- package/kody.config.schema.json +299 -0
- package/package.json +39 -0
- package/prompts/autofix.md +52 -0
- package/prompts/build.md +26 -0
- package/prompts/decompose.md +77 -0
- package/prompts/plan.md +65 -0
- package/prompts/review-fix.md +27 -0
- package/prompts/review.md +115 -0
- package/prompts/taskify-ticket.md +122 -0
- package/prompts/taskify.md +70 -0
- package/templates/kody-watch.yml +57 -0
- package/templates/kody.yml +450 -0
- package/templates/watch-agents/branch-cleanup/agent.json +7 -0
- package/templates/watch-agents/branch-cleanup/agent.md +13 -0
- package/templates/watch-agents/dependency-checker/agent.json +7 -0
- package/templates/watch-agents/dependency-checker/agent.md +14 -0
- package/templates/watch-agents/readme-health/agent.json +7 -0
- package/templates/watch-agents/readme-health/agent.md +17 -0
- package/templates/watch-agents/stale-pr-reviewer/agent.json +7 -0
- package/templates/watch-agents/stale-pr-reviewer/agent.md +13 -0
- package/templates/watch-agents/todo-scanner/agent.json +7 -0
- package/templates/watch-agents/todo-scanner/agent.md +10 -0
package/dist/entry.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { createRunners } from "./agent-runner.js";
|
|
4
|
+
import { runPipeline, printStatus } from "./pipeline.js";
|
|
5
|
+
import { runPreflight } from "./preflight.js";
|
|
6
|
+
import { setConfigDir, getProjectConfig } from "./config.js";
|
|
7
|
+
import { setGhCwd, getIssue, postComment } from "./github-api.js";
|
|
8
|
+
import { logger } from "./logger.js";
|
|
9
|
+
// Extracted modules
|
|
10
|
+
import { parseArgs } from "./cli/args.js";
|
|
11
|
+
import { checkLitellmHealth, tryStartLitellm } from "./cli/litellm.js";
|
|
12
|
+
import { findLatestTaskForIssue, generateTaskId } from "./cli/task-resolution.js";
|
|
13
|
+
async function main() {
|
|
14
|
+
const input = parseArgs();
|
|
15
|
+
// Resolve working directory first (needed for task lookup)
|
|
16
|
+
const projectDir = input.cwd ? path.resolve(input.cwd) : process.cwd();
|
|
17
|
+
if (input.cwd) {
|
|
18
|
+
if (!fs.existsSync(projectDir)) {
|
|
19
|
+
console.error(`--cwd path does not exist: ${projectDir}`);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
setConfigDir(projectDir);
|
|
23
|
+
setGhCwd(projectDir);
|
|
24
|
+
logger.info(`Working directory: ${projectDir}`);
|
|
25
|
+
}
|
|
26
|
+
// Resolve taskId
|
|
27
|
+
let taskId = input.taskId;
|
|
28
|
+
if (!taskId) {
|
|
29
|
+
if ((input.command === "rerun" || input.command === "fix") && input.issueNumber) {
|
|
30
|
+
const found = findLatestTaskForIssue(input.issueNumber, projectDir);
|
|
31
|
+
if (!found) {
|
|
32
|
+
console.error(`No previous task found for issue #${input.issueNumber}`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
taskId = found;
|
|
36
|
+
logger.info(`Found latest task for issue #${input.issueNumber}: ${taskId}`);
|
|
37
|
+
}
|
|
38
|
+
else if (input.issueNumber) {
|
|
39
|
+
taskId = `${input.issueNumber}-${generateTaskId()}`;
|
|
40
|
+
}
|
|
41
|
+
else if (input.command === "run" && input.task) {
|
|
42
|
+
taskId = generateTaskId();
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
console.error("--task-id is required (or provide --issue-number to auto-generate)");
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const taskDir = path.join(projectDir, ".tasks", taskId);
|
|
50
|
+
fs.mkdirSync(taskDir, { recursive: true });
|
|
51
|
+
// Status command — no preflight needed
|
|
52
|
+
if (input.command === "status") {
|
|
53
|
+
printStatus(taskId, taskDir);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
// Preflight
|
|
57
|
+
logger.info("Preflight checks:");
|
|
58
|
+
runPreflight();
|
|
59
|
+
// Write task.md if --task provided
|
|
60
|
+
if (input.task) {
|
|
61
|
+
fs.writeFileSync(path.join(taskDir, "task.md"), input.task);
|
|
62
|
+
}
|
|
63
|
+
// Auto-fetch issue body as task if no task.md and issue-number provided
|
|
64
|
+
const taskMdPath = path.join(taskDir, "task.md");
|
|
65
|
+
if (!fs.existsSync(taskMdPath) && input.issueNumber) {
|
|
66
|
+
logger.info(`Fetching issue #${input.issueNumber} body as task...`);
|
|
67
|
+
const issue = getIssue(input.issueNumber);
|
|
68
|
+
if (issue) {
|
|
69
|
+
const taskContent = `# ${issue.title}\n\n${issue.body ?? ""}`;
|
|
70
|
+
fs.writeFileSync(taskMdPath, taskContent);
|
|
71
|
+
logger.info(` Task loaded from issue #${input.issueNumber}: ${issue.title}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Verify task.md exists for run
|
|
75
|
+
if (input.command === "run") {
|
|
76
|
+
if (!fs.existsSync(taskMdPath)) {
|
|
77
|
+
console.error("No task.md found. Provide --task, --issue-number, or ensure .tasks/<id>/task.md exists.");
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Fix command defaults to --from build
|
|
82
|
+
if (input.command === "fix" && !input.fromStage) {
|
|
83
|
+
input.fromStage = "build";
|
|
84
|
+
}
|
|
85
|
+
// Auto-detect --from for rerun if not provided (find paused stage)
|
|
86
|
+
if (input.command === "rerun" && !input.fromStage) {
|
|
87
|
+
const statusPath = path.join(taskDir, "status.json");
|
|
88
|
+
if (fs.existsSync(statusPath)) {
|
|
89
|
+
try {
|
|
90
|
+
const status = JSON.parse(fs.readFileSync(statusPath, "utf-8"));
|
|
91
|
+
const stageNames = ["taskify", "plan", "build", "verify", "review", "review-fix", "ship"];
|
|
92
|
+
let foundPaused = false;
|
|
93
|
+
for (const name of stageNames) {
|
|
94
|
+
const s = status.stages[name];
|
|
95
|
+
if (s?.error?.includes("paused")) {
|
|
96
|
+
const idx = stageNames.indexOf(name);
|
|
97
|
+
if (idx < stageNames.length - 1) {
|
|
98
|
+
input.fromStage = stageNames[idx + 1];
|
|
99
|
+
foundPaused = true;
|
|
100
|
+
logger.info(`Auto-detected resume from: ${input.fromStage} (after paused ${name})`);
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (s?.state === "failed" || s?.state === "pending") {
|
|
105
|
+
input.fromStage = name;
|
|
106
|
+
foundPaused = true;
|
|
107
|
+
logger.info(`Auto-detected resume from: ${input.fromStage}`);
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (!foundPaused) {
|
|
112
|
+
input.fromStage = "taskify";
|
|
113
|
+
logger.info("No paused/failed stage found, resuming from taskify");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
console.error("--from <stage> is required (could not read status.json)");
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
// No status.json — fall back to full run with feedback preserved
|
|
123
|
+
logger.info("No status.json found — running full pipeline with feedback");
|
|
124
|
+
input.command = "run";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Start LiteLLM proxy if configured and not running
|
|
128
|
+
const config = getProjectConfig();
|
|
129
|
+
let litellmProcess = null;
|
|
130
|
+
const cleanupLitellm = () => { if (litellmProcess) {
|
|
131
|
+
litellmProcess.kill();
|
|
132
|
+
litellmProcess = null;
|
|
133
|
+
} };
|
|
134
|
+
process.on("exit", cleanupLitellm);
|
|
135
|
+
process.on("SIGINT", () => { cleanupLitellm(); process.exit(130); });
|
|
136
|
+
process.on("SIGTERM", () => { cleanupLitellm(); process.exit(143); });
|
|
137
|
+
if (config.agent.litellmUrl) {
|
|
138
|
+
const proxyRunning = await checkLitellmHealth(config.agent.litellmUrl);
|
|
139
|
+
if (!proxyRunning) {
|
|
140
|
+
litellmProcess = await tryStartLitellm(config.agent.litellmUrl, projectDir);
|
|
141
|
+
if (!litellmProcess) {
|
|
142
|
+
logger.warn("LiteLLM not available — falling back to Anthropic models");
|
|
143
|
+
config.agent.litellmUrl = undefined;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
logger.info(`LiteLLM proxy already running at ${config.agent.litellmUrl}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Create runners
|
|
151
|
+
const runners = createRunners(config);
|
|
152
|
+
const defaultRunnerName = config.agent.defaultRunner ?? Object.keys(runners)[0] ?? "claude";
|
|
153
|
+
const defaultRunner = runners[defaultRunnerName];
|
|
154
|
+
if (!defaultRunner) {
|
|
155
|
+
console.error(`Default runner "${defaultRunnerName}" not configured`);
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
const healthy = await defaultRunner.healthCheck();
|
|
159
|
+
if (!healthy) {
|
|
160
|
+
console.error(`Runner "${defaultRunnerName}" health check failed`);
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
// Build context
|
|
164
|
+
const ctx = {
|
|
165
|
+
taskId,
|
|
166
|
+
taskDir,
|
|
167
|
+
projectDir,
|
|
168
|
+
runners,
|
|
169
|
+
input: {
|
|
170
|
+
mode: (input.command === "rerun" || input.command === "fix") ? "rerun" : "full",
|
|
171
|
+
fromStage: input.fromStage,
|
|
172
|
+
dryRun: input.dryRun,
|
|
173
|
+
issueNumber: input.issueNumber,
|
|
174
|
+
feedback: input.feedback,
|
|
175
|
+
local: input.local,
|
|
176
|
+
complexity: input.complexity,
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
logger.info(`Task: ${taskId}`);
|
|
180
|
+
logger.info(`Mode: ${ctx.input.mode}${ctx.input.local ? " (local)" : " (CI)"}`);
|
|
181
|
+
if (ctx.input.issueNumber)
|
|
182
|
+
logger.info(`Issue: #${ctx.input.issueNumber}`);
|
|
183
|
+
// Post task-id comment so user knows the ID for rerun
|
|
184
|
+
if (ctx.input.issueNumber && !ctx.input.local && ctx.input.mode === "full") {
|
|
185
|
+
const runUrl = process.env.RUN_URL ?? "";
|
|
186
|
+
const runLink = runUrl ? ` ([logs](${runUrl}))` : "";
|
|
187
|
+
try {
|
|
188
|
+
postComment(ctx.input.issueNumber, `🚀 Kody pipeline started: \`${taskId}\`${runLink}\n\nTo rerun: \`@kody rerun ${taskId} --from <stage>\``);
|
|
189
|
+
}
|
|
190
|
+
catch { /* best effort */ }
|
|
191
|
+
}
|
|
192
|
+
// Run pipeline
|
|
193
|
+
const state = await runPipeline(ctx);
|
|
194
|
+
// Report
|
|
195
|
+
const files = fs.readdirSync(taskDir);
|
|
196
|
+
console.log(`\nArtifacts in ${taskDir}:`);
|
|
197
|
+
for (const f of files) {
|
|
198
|
+
console.log(` ${f}`);
|
|
199
|
+
}
|
|
200
|
+
if (state.state === "failed") {
|
|
201
|
+
// Check if this is a "paused" state (questions posted) — not a real failure
|
|
202
|
+
const isPaused = Object.values(state.stages).some((s) => s.error?.includes("paused") ?? false);
|
|
203
|
+
if (isPaused) {
|
|
204
|
+
process.exit(0);
|
|
205
|
+
}
|
|
206
|
+
// Post failure comment on issue
|
|
207
|
+
if (ctx.input.issueNumber && !ctx.input.local) {
|
|
208
|
+
const failedStage = Object.entries(state.stages).find(([, s]) => s.state === "failed" || s.state === "timeout");
|
|
209
|
+
const stageName = failedStage ? failedStage[0] : "unknown";
|
|
210
|
+
const error = failedStage ? failedStage[1].error ?? "" : "";
|
|
211
|
+
try {
|
|
212
|
+
postComment(ctx.input.issueNumber, `❌ Pipeline failed at **${stageName}**${error ? `: ${error.slice(0, 200)}` : ""}`);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// Best effort
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
main().catch(async (err) => {
|
|
222
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
223
|
+
console.error(msg);
|
|
224
|
+
// Post crash comment if we have issue context
|
|
225
|
+
const issueStr = process.argv.find((_, i, a) => a[i - 1] === "--issue-number") ?? process.env.ISSUE_NUMBER;
|
|
226
|
+
const isLocal = process.argv.includes("--local") || !process.env.GITHUB_ACTIONS;
|
|
227
|
+
if (issueStr && !isLocal) {
|
|
228
|
+
try {
|
|
229
|
+
postComment(parseInt(issueStr, 10), `❌ Pipeline crashed: ${msg.slice(0, 200)}`);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
// Best effort
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
process.exit(1);
|
|
236
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare function deriveBranchName(issueNumber: number, title: string): string;
|
|
2
|
+
export declare function getDefaultBranch(cwd?: string): string;
|
|
3
|
+
export declare function getCurrentBranch(cwd?: string): string;
|
|
4
|
+
export declare function ensureFeatureBranch(issueNumber: number, title: string, cwd?: string): string;
|
|
5
|
+
export declare function syncWithDefault(cwd?: string): void;
|
|
6
|
+
export declare function commitAll(message: string, cwd?: string): {
|
|
7
|
+
success: boolean;
|
|
8
|
+
hash: string;
|
|
9
|
+
message: string;
|
|
10
|
+
};
|
|
11
|
+
export declare function pushBranch(cwd?: string): void;
|
|
12
|
+
export declare function getChangedFiles(baseBranch: string, cwd?: string): string[];
|
|
13
|
+
export declare function getDiff(baseBranch: string, cwd?: string): string;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { execFileSync } from "child_process";
|
|
2
|
+
import { logger } from "./logger.js";
|
|
3
|
+
const BASE_BRANCHES = ["dev", "main", "master"];
|
|
4
|
+
let _hookSafeEnv = null;
|
|
5
|
+
function getHookSafeEnv() {
|
|
6
|
+
if (!_hookSafeEnv) {
|
|
7
|
+
_hookSafeEnv = { ...process.env, HUSKY: "0", SKIP_HOOKS: "1" };
|
|
8
|
+
}
|
|
9
|
+
return _hookSafeEnv;
|
|
10
|
+
}
|
|
11
|
+
function git(args, options) {
|
|
12
|
+
return execFileSync("git", args, {
|
|
13
|
+
encoding: "utf-8",
|
|
14
|
+
timeout: options?.timeout ?? 30_000,
|
|
15
|
+
cwd: options?.cwd,
|
|
16
|
+
env: options?.env ?? getHookSafeEnv(),
|
|
17
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
18
|
+
}).trim();
|
|
19
|
+
}
|
|
20
|
+
export function deriveBranchName(issueNumber, title) {
|
|
21
|
+
const slug = title
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.replace(/[^a-z0-9\s-]/g, "")
|
|
24
|
+
.replace(/\s+/g, "-")
|
|
25
|
+
.replace(/-+/g, "-")
|
|
26
|
+
.slice(0, 50)
|
|
27
|
+
.replace(/-$/, "");
|
|
28
|
+
return `${issueNumber}-${slug}`;
|
|
29
|
+
}
|
|
30
|
+
export function getDefaultBranch(cwd) {
|
|
31
|
+
// Method 1: symbolic-ref (fast, no network)
|
|
32
|
+
try {
|
|
33
|
+
const ref = git(["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd });
|
|
34
|
+
return ref.replace("refs/remotes/origin/", "");
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Fall through
|
|
38
|
+
}
|
|
39
|
+
// Method 2: remote show (needs network, 10s timeout)
|
|
40
|
+
try {
|
|
41
|
+
const output = git(["remote", "show", "origin"], { cwd, timeout: 10_000 });
|
|
42
|
+
const match = output.match(/HEAD branch:\s*(\S+)/);
|
|
43
|
+
if (match)
|
|
44
|
+
return match[1];
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Fall through
|
|
48
|
+
}
|
|
49
|
+
// Method 3: hardcoded fallback
|
|
50
|
+
return "dev";
|
|
51
|
+
}
|
|
52
|
+
export function getCurrentBranch(cwd) {
|
|
53
|
+
return git(["branch", "--show-current"], { cwd });
|
|
54
|
+
}
|
|
55
|
+
export function ensureFeatureBranch(issueNumber, title, cwd) {
|
|
56
|
+
const current = getCurrentBranch(cwd);
|
|
57
|
+
const branchName = deriveBranchName(issueNumber, title);
|
|
58
|
+
// Already on the correct feature branch for this issue
|
|
59
|
+
if (current === branchName || current.startsWith(`${issueNumber}-`)) {
|
|
60
|
+
logger.info(` Already on feature branch: ${current}`);
|
|
61
|
+
return current;
|
|
62
|
+
}
|
|
63
|
+
// On a different feature branch — switch to default first
|
|
64
|
+
if (!BASE_BRANCHES.includes(current) && current !== "") {
|
|
65
|
+
const defaultBranch = getDefaultBranch(cwd);
|
|
66
|
+
logger.info(` Switching from ${current} to ${defaultBranch} before creating ${branchName}`);
|
|
67
|
+
try {
|
|
68
|
+
git(["checkout", defaultBranch], { cwd });
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
logger.warn(` Failed to checkout ${defaultBranch}, aborting branch creation`);
|
|
72
|
+
return current;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Fetch origin
|
|
76
|
+
try {
|
|
77
|
+
git(["fetch", "origin"], { cwd, timeout: 30_000 });
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
logger.warn(" Failed to fetch origin");
|
|
81
|
+
}
|
|
82
|
+
// Check if branch exists on remote
|
|
83
|
+
try {
|
|
84
|
+
git(["rev-parse", "--verify", `origin/${branchName}`], { cwd });
|
|
85
|
+
git(["checkout", branchName], { cwd });
|
|
86
|
+
git(["pull", "origin", branchName], { cwd, timeout: 30_000 });
|
|
87
|
+
logger.info(` Checked out existing remote branch: ${branchName}`);
|
|
88
|
+
return branchName;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// Branch doesn't exist on remote
|
|
92
|
+
}
|
|
93
|
+
// Check if branch exists locally
|
|
94
|
+
try {
|
|
95
|
+
git(["rev-parse", "--verify", branchName], { cwd });
|
|
96
|
+
git(["checkout", branchName], { cwd });
|
|
97
|
+
logger.info(` Checked out existing local branch: ${branchName}`);
|
|
98
|
+
return branchName;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// Branch doesn't exist locally either
|
|
102
|
+
}
|
|
103
|
+
// Create new branch tracking default branch
|
|
104
|
+
const defaultBranch = getDefaultBranch(cwd);
|
|
105
|
+
try {
|
|
106
|
+
git(["checkout", "-b", branchName, `origin/${defaultBranch}`], { cwd });
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// If origin/default doesn't exist, create from current HEAD
|
|
110
|
+
git(["checkout", "-b", branchName], { cwd });
|
|
111
|
+
}
|
|
112
|
+
logger.info(` Created new branch: ${branchName}`);
|
|
113
|
+
return branchName;
|
|
114
|
+
}
|
|
115
|
+
export function syncWithDefault(cwd) {
|
|
116
|
+
const defaultBranch = getDefaultBranch(cwd);
|
|
117
|
+
const current = getCurrentBranch(cwd);
|
|
118
|
+
if (current === defaultBranch)
|
|
119
|
+
return; // already on default, no merge needed
|
|
120
|
+
// Fetch latest
|
|
121
|
+
try {
|
|
122
|
+
git(["fetch", "origin", defaultBranch], { cwd, timeout: 30_000 });
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
logger.warn(" Failed to fetch latest from origin");
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
// Merge default into feature branch
|
|
129
|
+
try {
|
|
130
|
+
git(["merge", `origin/${defaultBranch}`, "--no-edit"], { cwd, timeout: 30_000 });
|
|
131
|
+
logger.info(` Synced with origin/${defaultBranch}`);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// Merge conflict — abort and warn
|
|
135
|
+
try {
|
|
136
|
+
git(["merge", "--abort"], { cwd });
|
|
137
|
+
}
|
|
138
|
+
catch { /* ignore */ }
|
|
139
|
+
logger.warn(` Merge conflict with origin/${defaultBranch} — skipping sync`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export function commitAll(message, cwd) {
|
|
143
|
+
// Check for changes
|
|
144
|
+
const status = git(["status", "--porcelain"], { cwd });
|
|
145
|
+
if (!status) {
|
|
146
|
+
return { success: false, hash: "", message: "No changes to commit" };
|
|
147
|
+
}
|
|
148
|
+
git(["add", "."], { cwd });
|
|
149
|
+
git(["commit", "--no-gpg-sign", "-m", message], { cwd });
|
|
150
|
+
const hash = git(["rev-parse", "HEAD"], { cwd }).slice(0, 7);
|
|
151
|
+
logger.info(` Committed: ${hash} ${message}`);
|
|
152
|
+
return { success: true, hash, message };
|
|
153
|
+
}
|
|
154
|
+
export function pushBranch(cwd) {
|
|
155
|
+
git(["push", "-u", "origin", "HEAD"], { cwd, timeout: 120_000 });
|
|
156
|
+
logger.info(" Pushed to origin");
|
|
157
|
+
}
|
|
158
|
+
export function getChangedFiles(baseBranch, cwd) {
|
|
159
|
+
try {
|
|
160
|
+
const output = git(["diff", "--name-only", `origin/${baseBranch}...HEAD`], { cwd });
|
|
161
|
+
return output ? output.split("\n").filter(Boolean) : [];
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
export function getDiff(baseBranch, cwd) {
|
|
168
|
+
try {
|
|
169
|
+
return git(["diff", `origin/${baseBranch}...HEAD`], { cwd });
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return "";
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare function setGhCwd(cwd: string): void;
|
|
2
|
+
export declare function getIssue(issueNumber: number): {
|
|
3
|
+
body: string;
|
|
4
|
+
title: string;
|
|
5
|
+
} | null;
|
|
6
|
+
export declare function setLabel(issueNumber: number, label: string): void;
|
|
7
|
+
export declare function removeLabel(issueNumber: number, label: string): void;
|
|
8
|
+
export declare function postComment(issueNumber: number, body: string): void;
|
|
9
|
+
export declare function createPR(head: string, base: string, title: string, body: string): {
|
|
10
|
+
number: number;
|
|
11
|
+
url: string;
|
|
12
|
+
} | null;
|
|
13
|
+
export declare function setLifecycleLabel(issueNumber: number, phase: string): void;
|
|
14
|
+
export declare function closeIssue(issueNumber: number, reason?: "completed" | "not planned"): void;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { execFileSync } from "child_process";
|
|
2
|
+
import { logger } from "./logger.js";
|
|
3
|
+
const API_TIMEOUT_MS = 30_000;
|
|
4
|
+
const LIFECYCLE_LABELS = ["planning", "building", "review", "done", "failed", "waiting", "low", "medium", "high"];
|
|
5
|
+
let _ghCwd;
|
|
6
|
+
export function setGhCwd(cwd) {
|
|
7
|
+
_ghCwd = cwd;
|
|
8
|
+
}
|
|
9
|
+
function ghToken() {
|
|
10
|
+
return process.env.GH_PAT?.trim() || process.env.GH_TOKEN;
|
|
11
|
+
}
|
|
12
|
+
function gh(args, options) {
|
|
13
|
+
const token = ghToken();
|
|
14
|
+
const env = token
|
|
15
|
+
? { ...process.env, GH_TOKEN: token }
|
|
16
|
+
: { ...process.env };
|
|
17
|
+
return execFileSync("gh", args, {
|
|
18
|
+
encoding: "utf-8",
|
|
19
|
+
timeout: API_TIMEOUT_MS,
|
|
20
|
+
cwd: _ghCwd,
|
|
21
|
+
env,
|
|
22
|
+
input: options?.input,
|
|
23
|
+
stdio: options?.input ? ["pipe", "pipe", "pipe"] : ["inherit", "pipe", "pipe"],
|
|
24
|
+
}).trim();
|
|
25
|
+
}
|
|
26
|
+
export function getIssue(issueNumber) {
|
|
27
|
+
try {
|
|
28
|
+
const output = gh([
|
|
29
|
+
"issue", "view", String(issueNumber),
|
|
30
|
+
"--json", "body,title",
|
|
31
|
+
]);
|
|
32
|
+
return JSON.parse(output);
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
logger.error(` Failed to get issue #${issueNumber}: ${err}`);
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function setLabel(issueNumber, label) {
|
|
40
|
+
try {
|
|
41
|
+
gh(["issue", "edit", String(issueNumber), "--add-label", label]);
|
|
42
|
+
logger.info(` Label added: ${label}`);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
logger.warn(` Failed to set label ${label}: ${err}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function removeLabel(issueNumber, label) {
|
|
49
|
+
try {
|
|
50
|
+
gh(["issue", "edit", String(issueNumber), "--remove-label", label]);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Label may not exist — ignore
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function postComment(issueNumber, body) {
|
|
57
|
+
try {
|
|
58
|
+
gh(["issue", "comment", String(issueNumber), "--body-file", "-"], { input: body });
|
|
59
|
+
logger.info(` Comment posted on #${issueNumber}`);
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
logger.warn(` Failed to post comment: ${err}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export function createPR(head, base, title, body) {
|
|
66
|
+
try {
|
|
67
|
+
const output = gh([
|
|
68
|
+
"pr", "create",
|
|
69
|
+
"--head", head,
|
|
70
|
+
"--base", base,
|
|
71
|
+
"--title", title,
|
|
72
|
+
"--body-file", "-",
|
|
73
|
+
], { input: body });
|
|
74
|
+
const url = output.trim();
|
|
75
|
+
const match = url.match(/\/pull\/(\d+)$/);
|
|
76
|
+
const number = match ? parseInt(match[1], 10) : 0;
|
|
77
|
+
logger.info(` PR created: ${url}`);
|
|
78
|
+
return { number, url };
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
logger.error(` Failed to create PR: ${err}`);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export function setLifecycleLabel(issueNumber, phase) {
|
|
86
|
+
if (!LIFECYCLE_LABELS.includes(phase)) {
|
|
87
|
+
logger.warn(` Invalid lifecycle phase: ${phase}`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
// Remove all other lifecycle labels
|
|
91
|
+
const othersToRemove = LIFECYCLE_LABELS
|
|
92
|
+
.filter((l) => l !== phase)
|
|
93
|
+
.map((l) => `kody:${l}`)
|
|
94
|
+
.join(",");
|
|
95
|
+
if (othersToRemove) {
|
|
96
|
+
try {
|
|
97
|
+
gh(["issue", "edit", String(issueNumber), "--remove-label", othersToRemove]);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Labels may not exist — ignore
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Add new label
|
|
104
|
+
setLabel(issueNumber, `kody:${phase}`);
|
|
105
|
+
}
|
|
106
|
+
export function closeIssue(issueNumber, reason = "completed") {
|
|
107
|
+
try {
|
|
108
|
+
gh(["issue", "close", String(issueNumber), "--reason", reason]);
|
|
109
|
+
logger.info(` Issue #${issueNumber} closed: ${reason}`);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
logger.warn(` Failed to close issue: ${err}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function ensureTaskDir(taskId: string): string;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
export function ensureTaskDir(taskId) {
|
|
4
|
+
const taskDir = path.join(process.cwd(), ".tasks", taskId);
|
|
5
|
+
if (!fs.existsSync(taskDir)) {
|
|
6
|
+
fs.mkdirSync(taskDir, { recursive: true });
|
|
7
|
+
}
|
|
8
|
+
return taskDir;
|
|
9
|
+
}
|