@calltelemetry/openclaw-linear 0.7.0 → 0.8.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 +719 -539
- package/index.ts +40 -1
- package/openclaw.plugin.json +4 -4
- package/package.json +2 -1
- package/prompts.yaml +19 -5
- package/src/__test__/fixtures/linear-responses.ts +75 -0
- package/src/__test__/fixtures/webhook-payloads.ts +113 -0
- package/src/__test__/helpers.ts +133 -0
- package/src/agent/agent.test.ts +143 -0
- package/src/api/linear-api.test.ts +586 -0
- package/src/api/linear-api.ts +50 -11
- package/src/gateway/dispatch-methods.test.ts +409 -0
- package/src/gateway/dispatch-methods.ts +243 -0
- package/src/infra/cli.ts +273 -30
- package/src/infra/codex-worktree.ts +83 -0
- package/src/infra/commands.test.ts +276 -0
- package/src/infra/commands.ts +156 -0
- package/src/infra/doctor.test.ts +19 -0
- package/src/infra/doctor.ts +28 -23
- package/src/infra/file-lock.test.ts +61 -0
- package/src/infra/file-lock.ts +49 -0
- package/src/infra/multi-repo.test.ts +163 -0
- package/src/infra/multi-repo.ts +114 -0
- package/src/infra/notify.test.ts +155 -16
- package/src/infra/notify.ts +137 -26
- package/src/infra/observability.test.ts +85 -0
- package/src/infra/observability.ts +48 -0
- package/src/infra/resilience.test.ts +94 -0
- package/src/infra/resilience.ts +101 -0
- package/src/pipeline/artifacts.test.ts +26 -3
- package/src/pipeline/artifacts.ts +38 -2
- package/src/pipeline/dag-dispatch.test.ts +553 -0
- package/src/pipeline/dag-dispatch.ts +390 -0
- package/src/pipeline/dispatch-service.ts +48 -1
- package/src/pipeline/dispatch-state.ts +3 -42
- package/src/pipeline/e2e-dispatch.test.ts +584 -0
- package/src/pipeline/e2e-planning.test.ts +455 -0
- package/src/pipeline/pipeline.test.ts +69 -0
- package/src/pipeline/pipeline.ts +132 -29
- package/src/pipeline/planner.test.ts +1 -1
- package/src/pipeline/planner.ts +18 -31
- package/src/pipeline/planning-state.ts +2 -40
- package/src/pipeline/tier-assess.test.ts +264 -0
- package/src/pipeline/webhook.ts +134 -36
- package/src/tools/cli-shared.test.ts +155 -0
- package/src/tools/code-tool.test.ts +210 -0
- package/src/tools/dispatch-history-tool.test.ts +315 -0
- package/src/tools/dispatch-history-tool.ts +201 -0
- package/src/tools/orchestration-tools.test.ts +158 -0
package/src/infra/cli.ts
CHANGED
|
@@ -5,11 +5,11 @@ import type { Command } from "commander";
|
|
|
5
5
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
6
6
|
import { createInterface } from "node:readline";
|
|
7
7
|
import { exec } from "node:child_process";
|
|
8
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
9
|
-
import { readFileSync as readFileSyncFs, existsSync } from "node:fs";
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
10
9
|
import { join, dirname } from "node:path";
|
|
11
10
|
import { fileURLToPath } from "node:url";
|
|
12
|
-
import { resolveLinearToken, AUTH_PROFILES_PATH, LINEAR_GRAPHQL_URL } from "../api/linear-api.js";
|
|
11
|
+
import { resolveLinearToken, LinearAgentApi, AUTH_PROFILES_PATH, LINEAR_GRAPHQL_URL } from "../api/linear-api.js";
|
|
12
|
+
import { validateRepoPath } from "./multi-repo.js";
|
|
13
13
|
import { LINEAR_OAUTH_AUTH_URL, LINEAR_OAUTH_TOKEN_URL, LINEAR_AGENT_SCOPES } from "../api/auth.js";
|
|
14
14
|
import { listWorktrees } from "./codex-worktree.js";
|
|
15
15
|
import { loadPrompts, clearPromptCache } from "../pipeline/pipeline.js";
|
|
@@ -251,6 +251,25 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
|
|
|
251
251
|
console.log(`\nTo remove one: openclaw openclaw-linear worktrees --prune <path>\n`);
|
|
252
252
|
});
|
|
253
253
|
|
|
254
|
+
// --- openclaw openclaw-linear repos ---
|
|
255
|
+
const repos = linear
|
|
256
|
+
.command("repos")
|
|
257
|
+
.description("Validate multi-repo config and sync labels to Linear");
|
|
258
|
+
|
|
259
|
+
repos
|
|
260
|
+
.command("check")
|
|
261
|
+
.description("Validate repo paths and show what labels would be created (dry run)")
|
|
262
|
+
.action(async () => {
|
|
263
|
+
await reposAction(api, { dryRun: true });
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
repos
|
|
267
|
+
.command("sync")
|
|
268
|
+
.description("Create missing repo: labels in Linear from your repos config")
|
|
269
|
+
.action(async () => {
|
|
270
|
+
await reposAction(api, { dryRun: false });
|
|
271
|
+
});
|
|
272
|
+
|
|
254
273
|
// --- openclaw openclaw-linear prompts ---
|
|
255
274
|
const prompts = linear
|
|
256
275
|
.command("prompts")
|
|
@@ -258,33 +277,21 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
|
|
|
258
277
|
|
|
259
278
|
prompts
|
|
260
279
|
.command("show")
|
|
261
|
-
.description("Print
|
|
262
|
-
.
|
|
280
|
+
.description("Print resolved prompts (global or per-project)")
|
|
281
|
+
.option("--worktree <path>", "Show merged prompts for a specific worktree")
|
|
282
|
+
.action(async (opts: { worktree?: string }) => {
|
|
263
283
|
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
264
|
-
|
|
284
|
+
clearPromptCache();
|
|
285
|
+
const loaded = loadPrompts(pluginConfig, opts.worktree);
|
|
265
286
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
resolvedPath = customPath.startsWith("~")
|
|
269
|
-
? customPath.replace("~", process.env.HOME ?? "")
|
|
270
|
-
: customPath;
|
|
287
|
+
if (opts.worktree) {
|
|
288
|
+
console.log(`\nResolved prompts for worktree: ${opts.worktree}\n`);
|
|
271
289
|
} else {
|
|
272
|
-
|
|
273
|
-
resolvedPath = join(pluginRoot, "prompts.yaml");
|
|
290
|
+
console.log(`\nGlobal resolved prompts\n`);
|
|
274
291
|
}
|
|
275
292
|
|
|
276
|
-
console.log(
|
|
277
|
-
|
|
278
|
-
try {
|
|
279
|
-
const content = readFileSyncFs(resolvedPath, "utf-8");
|
|
280
|
-
console.log(content);
|
|
281
|
-
} catch {
|
|
282
|
-
console.log("(file not found — using built-in defaults)\n");
|
|
283
|
-
// Show the loaded defaults
|
|
284
|
-
clearPromptCache();
|
|
285
|
-
const loaded = loadPrompts(pluginConfig);
|
|
286
|
-
console.log(JSON.stringify(loaded, null, 2));
|
|
287
|
-
}
|
|
293
|
+
console.log(JSON.stringify(loaded, null, 2));
|
|
294
|
+
console.log();
|
|
288
295
|
});
|
|
289
296
|
|
|
290
297
|
prompts
|
|
@@ -310,13 +317,14 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
|
|
|
310
317
|
|
|
311
318
|
prompts
|
|
312
319
|
.command("validate")
|
|
313
|
-
.description("Validate prompts.yaml structure")
|
|
314
|
-
.
|
|
320
|
+
.description("Validate prompts.yaml structure (global or per-project)")
|
|
321
|
+
.option("--worktree <path>", "Validate merged prompts for a specific worktree")
|
|
322
|
+
.action(async (opts: { worktree?: string }) => {
|
|
315
323
|
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
316
324
|
clearPromptCache();
|
|
317
325
|
|
|
318
326
|
try {
|
|
319
|
-
const loaded = loadPrompts(pluginConfig);
|
|
327
|
+
const loaded = loadPrompts(pluginConfig, opts.worktree);
|
|
320
328
|
const errors: string[] = [];
|
|
321
329
|
|
|
322
330
|
if (!loaded.worker?.system) errors.push("Missing worker.system");
|
|
@@ -336,13 +344,14 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
|
|
|
336
344
|
}
|
|
337
345
|
}
|
|
338
346
|
|
|
347
|
+
const label = opts.worktree ? `worktree ${opts.worktree}` : "global";
|
|
339
348
|
if (errors.length > 0) {
|
|
340
|
-
console.log(
|
|
349
|
+
console.log(`\nValidation FAILED (${label}):\n`);
|
|
341
350
|
for (const e of errors) console.log(` - ${e}`);
|
|
342
351
|
console.log();
|
|
343
352
|
process.exitCode = 1;
|
|
344
353
|
} else {
|
|
345
|
-
console.log(
|
|
354
|
+
console.log(`\nValidation PASSED (${label}) — all sections and template variables present.\n`);
|
|
346
355
|
}
|
|
347
356
|
} catch (err) {
|
|
348
357
|
console.error(`\nFailed to load prompts: ${err}\n`);
|
|
@@ -350,6 +359,85 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
|
|
|
350
359
|
}
|
|
351
360
|
});
|
|
352
361
|
|
|
362
|
+
prompts
|
|
363
|
+
.command("init")
|
|
364
|
+
.description("Scaffold per-project .claw/prompts.yaml in a worktree")
|
|
365
|
+
.argument("<worktree-path>", "Path to the worktree")
|
|
366
|
+
.action(async (worktreePath: string) => {
|
|
367
|
+
const { mkdirSync, writeFileSync: writeFS } = await import("node:fs");
|
|
368
|
+
const clawDir = join(worktreePath, ".claw");
|
|
369
|
+
const promptsFile = join(clawDir, "prompts.yaml");
|
|
370
|
+
|
|
371
|
+
if (existsSync(promptsFile)) {
|
|
372
|
+
console.log(`\n ${promptsFile} already exists.\n`);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
mkdirSync(clawDir, { recursive: true });
|
|
377
|
+
writeFS(promptsFile, [
|
|
378
|
+
"# Per-project prompt overrides for Linear Agent pipeline.",
|
|
379
|
+
"# Only include sections/fields you want to override.",
|
|
380
|
+
"# Unspecified fields inherit from the global prompts.yaml.",
|
|
381
|
+
"#",
|
|
382
|
+
"# Available sections: worker, audit, rework",
|
|
383
|
+
"# Template variables: {{identifier}}, {{title}}, {{description}}, {{worktreePath}}, {{tier}}, {{attempt}}, {{gaps}}",
|
|
384
|
+
"",
|
|
385
|
+
"# worker:",
|
|
386
|
+
"# system: \"Custom system prompt for workers in this project.\"",
|
|
387
|
+
"# task: \"Implement issue {{identifier}}: {{title}}\\n\\n{{description}}\\n\\nWorktree: {{worktreePath}}\"",
|
|
388
|
+
"",
|
|
389
|
+
"# audit:",
|
|
390
|
+
"# system: \"Custom audit system prompt for this project.\"",
|
|
391
|
+
"",
|
|
392
|
+
"# rework:",
|
|
393
|
+
"# addendum: \"Custom rework addendum for this project.\"",
|
|
394
|
+
"",
|
|
395
|
+
].join("\n"), "utf-8");
|
|
396
|
+
|
|
397
|
+
console.log(`\n Created: ${promptsFile}`);
|
|
398
|
+
console.log(` Edit this file to customize prompts for this worktree.\n`);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
prompts
|
|
402
|
+
.command("diff")
|
|
403
|
+
.description("Show differences between global and per-project prompts")
|
|
404
|
+
.argument("<worktree-path>", "Path to the worktree")
|
|
405
|
+
.action(async (worktreePath: string) => {
|
|
406
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
407
|
+
clearPromptCache();
|
|
408
|
+
|
|
409
|
+
const global = loadPrompts(pluginConfig);
|
|
410
|
+
const merged = loadPrompts(pluginConfig, worktreePath);
|
|
411
|
+
|
|
412
|
+
const projectFile = join(worktreePath, ".claw", "prompts.yaml");
|
|
413
|
+
if (!existsSync(projectFile)) {
|
|
414
|
+
console.log(`\n No per-project prompts at ${projectFile}`);
|
|
415
|
+
console.log(` Run 'openclaw openclaw-linear prompts init ${worktreePath}' to create one.\n`);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
console.log(`\nPrompt diff: global vs ${worktreePath}\n`);
|
|
420
|
+
|
|
421
|
+
let hasDiffs = false;
|
|
422
|
+
for (const section of ["worker", "audit", "rework"] as const) {
|
|
423
|
+
const globalSection = global[section] as Record<string, string>;
|
|
424
|
+
const mergedSection = merged[section] as Record<string, string>;
|
|
425
|
+
for (const [key, val] of Object.entries(mergedSection)) {
|
|
426
|
+
if (globalSection[key] !== val) {
|
|
427
|
+
hasDiffs = true;
|
|
428
|
+
console.log(` ${section}.${key}:`);
|
|
429
|
+
console.log(` global: ${globalSection[key]?.slice(0, 100)}...`);
|
|
430
|
+
console.log(` project: ${val.slice(0, 100)}...`);
|
|
431
|
+
console.log();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (!hasDiffs) {
|
|
437
|
+
console.log(" No differences — per-project prompts match global.\n");
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
|
|
353
441
|
// --- openclaw openclaw-linear notify ---
|
|
354
442
|
const notifyCmd = linear
|
|
355
443
|
.command("notify")
|
|
@@ -557,3 +645,158 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
|
|
|
557
645
|
}
|
|
558
646
|
});
|
|
559
647
|
}
|
|
648
|
+
|
|
649
|
+
// ---------------------------------------------------------------------------
|
|
650
|
+
// repos sync / check helper
|
|
651
|
+
// ---------------------------------------------------------------------------
|
|
652
|
+
|
|
653
|
+
const REPO_LABEL_COLOR = "#5e6ad2"; // Linear indigo
|
|
654
|
+
|
|
655
|
+
async function reposAction(
|
|
656
|
+
api: OpenClawPluginApi,
|
|
657
|
+
opts: { dryRun: boolean },
|
|
658
|
+
): Promise<void> {
|
|
659
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
660
|
+
const reposMap = (pluginConfig?.repos as Record<string, string> | undefined) ?? {};
|
|
661
|
+
const repoNames = Object.keys(reposMap);
|
|
662
|
+
|
|
663
|
+
const mode = opts.dryRun ? "Repos Check" : "Repos Sync";
|
|
664
|
+
console.log(`\n${mode}`);
|
|
665
|
+
console.log("─".repeat(40));
|
|
666
|
+
|
|
667
|
+
// 1. Validate config
|
|
668
|
+
if (repoNames.length === 0) {
|
|
669
|
+
console.log(`\n No "repos" configured in plugin config.`);
|
|
670
|
+
console.log(` Add a repos map to openclaw.json → plugins.entries.openclaw-linear.config:`);
|
|
671
|
+
console.log(`\n "repos": {`);
|
|
672
|
+
console.log(` "api": "/home/claw/repos/api",`);
|
|
673
|
+
console.log(` "frontend": "/home/claw/repos/frontend"`);
|
|
674
|
+
console.log(` }\n`);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// 2. Validate each repo path
|
|
679
|
+
console.log("\n Repos from config:");
|
|
680
|
+
const warnings: string[] = [];
|
|
681
|
+
|
|
682
|
+
for (const name of repoNames) {
|
|
683
|
+
const repoPath = reposMap[name];
|
|
684
|
+
const status = validateRepoPath(repoPath);
|
|
685
|
+
const pad = name.padEnd(16);
|
|
686
|
+
|
|
687
|
+
if (!status.exists) {
|
|
688
|
+
console.log(` \u2717 ${pad} ${repoPath} (path not found)`);
|
|
689
|
+
warnings.push(`"${name}" at ${repoPath} does not exist`);
|
|
690
|
+
} else if (!status.isGitRepo) {
|
|
691
|
+
console.log(` \u2717 ${pad} ${repoPath} (not a git repo)`);
|
|
692
|
+
warnings.push(`"${name}" at ${repoPath} is not a git repository`);
|
|
693
|
+
} else if (status.isSubmodule) {
|
|
694
|
+
console.log(` \u26a0 ${pad} ${repoPath} (submodule)`);
|
|
695
|
+
warnings.push(`"${name}" at ${repoPath} is a git submodule`);
|
|
696
|
+
} else {
|
|
697
|
+
console.log(` \u2714 ${pad} ${repoPath} (git repo)`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// 3. Connect to Linear
|
|
702
|
+
const tokenInfo = resolveLinearToken(pluginConfig);
|
|
703
|
+
if (!tokenInfo.accessToken) {
|
|
704
|
+
console.log(`\n No Linear token found. Run "openclaw openclaw-linear auth" first.\n`);
|
|
705
|
+
process.exitCode = 1;
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const linearApi = new LinearAgentApi(tokenInfo.accessToken, {
|
|
710
|
+
refreshToken: tokenInfo.refreshToken,
|
|
711
|
+
expiresAt: tokenInfo.expiresAt,
|
|
712
|
+
clientId: pluginConfig?.clientId as string | undefined,
|
|
713
|
+
clientSecret: pluginConfig?.clientSecret as string | undefined,
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
// 4. Get teams
|
|
717
|
+
let teams: Array<{ id: string; name: string; key: string }>;
|
|
718
|
+
try {
|
|
719
|
+
teams = await linearApi.getTeams();
|
|
720
|
+
} catch (err) {
|
|
721
|
+
console.log(`\n Failed to fetch teams: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
722
|
+
process.exitCode = 1;
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (teams.length === 0) {
|
|
727
|
+
console.log(`\n No teams found in your Linear workspace.\n`);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// 5. Sync labels per team
|
|
732
|
+
let totalCreated = 0;
|
|
733
|
+
let totalExisted = 0;
|
|
734
|
+
|
|
735
|
+
for (const team of teams) {
|
|
736
|
+
console.log(`\n Team: ${team.name} (${team.key})`);
|
|
737
|
+
|
|
738
|
+
let existingLabels: Array<{ id: string; name: string }>;
|
|
739
|
+
try {
|
|
740
|
+
existingLabels = await linearApi.getTeamLabels(team.id);
|
|
741
|
+
} catch (err) {
|
|
742
|
+
console.log(` Failed to fetch labels: ${err instanceof Error ? err.message : String(err)}`);
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const existingNames = new Set(existingLabels.map(l => l.name.toLowerCase()));
|
|
747
|
+
|
|
748
|
+
for (const name of repoNames) {
|
|
749
|
+
const labelName = `repo:${name}`;
|
|
750
|
+
|
|
751
|
+
if (existingNames.has(labelName.toLowerCase())) {
|
|
752
|
+
console.log(` \u2714 ${labelName.padEnd(24)} already exists`);
|
|
753
|
+
totalExisted++;
|
|
754
|
+
} else if (opts.dryRun) {
|
|
755
|
+
console.log(` + ${labelName.padEnd(24)} would be created`);
|
|
756
|
+
} else {
|
|
757
|
+
try {
|
|
758
|
+
await linearApi.createLabel(team.id, labelName, {
|
|
759
|
+
color: REPO_LABEL_COLOR,
|
|
760
|
+
description: `Multi-repo dispatch: ${name}`,
|
|
761
|
+
});
|
|
762
|
+
console.log(` + ${labelName.padEnd(24)} created`);
|
|
763
|
+
totalCreated++;
|
|
764
|
+
} catch (err) {
|
|
765
|
+
console.log(` \u2717 ${labelName.padEnd(24)} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// 6. Summary
|
|
772
|
+
if (opts.dryRun) {
|
|
773
|
+
const wouldCreate = repoNames.length * teams.length - totalExisted;
|
|
774
|
+
console.log(`\n Dry run: ${wouldCreate} label(s) would be created, ${totalExisted} already exist`);
|
|
775
|
+
} else {
|
|
776
|
+
console.log(`\n Summary: ${totalCreated} created, ${totalExisted} already existed`);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// 7. Submodule warnings
|
|
780
|
+
const submoduleWarnings = warnings.filter(w => w.includes("submodule"));
|
|
781
|
+
if (submoduleWarnings.length > 0) {
|
|
782
|
+
console.log(`\n \u26a0 Submodule warning:`);
|
|
783
|
+
for (const w of submoduleWarnings) {
|
|
784
|
+
console.log(` ${w}`);
|
|
785
|
+
}
|
|
786
|
+
console.log(` Multi-repo dispatch uses "git worktree add" which doesn't work on submodules.`);
|
|
787
|
+
console.log(` Options:`);
|
|
788
|
+
console.log(` 1. Clone the repo as a standalone repo instead`);
|
|
789
|
+
console.log(` 2. Remove it from "repos" config and use the parent repo as codexBaseRepo`);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Other warnings
|
|
793
|
+
const otherWarnings = warnings.filter(w => !w.includes("submodule"));
|
|
794
|
+
if (otherWarnings.length > 0) {
|
|
795
|
+
console.log(`\n Warnings:`);
|
|
796
|
+
for (const w of otherWarnings) {
|
|
797
|
+
console.log(` \u26a0 ${w}`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
console.log();
|
|
802
|
+
}
|
|
@@ -3,6 +3,7 @@ import { existsSync, statSync, readdirSync, mkdirSync } from "node:fs";
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { ensureGitignore } from "../pipeline/artifacts.js";
|
|
6
|
+
import type { RepoConfig } from "./multi-repo.js";
|
|
6
7
|
|
|
7
8
|
const DEFAULT_BASE_REPO = "/home/claw/ai-workspace";
|
|
8
9
|
const DEFAULT_WORKTREE_BASE_DIR = path.join(homedir(), ".openclaw", "worktrees");
|
|
@@ -117,6 +118,88 @@ export function createWorktree(
|
|
|
117
118
|
return { path: worktreePath, branch, resumed: false };
|
|
118
119
|
}
|
|
119
120
|
|
|
121
|
+
export interface MultiWorktreeResult {
|
|
122
|
+
/** Parent directory containing all repo worktrees for this issue. */
|
|
123
|
+
parentPath: string;
|
|
124
|
+
worktrees: Array<{
|
|
125
|
+
repoName: string;
|
|
126
|
+
path: string;
|
|
127
|
+
branch: string;
|
|
128
|
+
resumed: boolean;
|
|
129
|
+
}>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Create worktrees for multiple repos.
|
|
134
|
+
*
|
|
135
|
+
* Layout: {baseDir}/{issueIdentifier}/{repoName}/
|
|
136
|
+
* Branch: codex/{issueIdentifier} (same branch name in each repo)
|
|
137
|
+
*
|
|
138
|
+
* Each individual repo worktree follows the same idempotent/resume logic
|
|
139
|
+
* as createWorktree: if the worktree or branch already exists, it resumes.
|
|
140
|
+
*/
|
|
141
|
+
export function createMultiWorktree(
|
|
142
|
+
identifier: string,
|
|
143
|
+
repos: RepoConfig[],
|
|
144
|
+
opts?: { baseDir?: string },
|
|
145
|
+
): MultiWorktreeResult {
|
|
146
|
+
const baseDir = resolveBaseDir(opts?.baseDir);
|
|
147
|
+
const parentPath = path.join(baseDir, identifier);
|
|
148
|
+
|
|
149
|
+
// Ensure parent directory exists
|
|
150
|
+
if (!existsSync(parentPath)) {
|
|
151
|
+
mkdirSync(parentPath, { recursive: true });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const branch = `codex/${identifier}`;
|
|
155
|
+
const worktrees: MultiWorktreeResult["worktrees"] = [];
|
|
156
|
+
|
|
157
|
+
for (const repo of repos) {
|
|
158
|
+
if (!existsSync(repo.path)) {
|
|
159
|
+
throw new Error(`Repo not found: ${repo.name} at ${repo.path}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const worktreePath = path.join(parentPath, repo.name);
|
|
163
|
+
|
|
164
|
+
// Fetch latest from origin (best effort)
|
|
165
|
+
try {
|
|
166
|
+
git(["fetch", "origin"], repo.path);
|
|
167
|
+
} catch {
|
|
168
|
+
// Offline or no remote — continue with local state
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Idempotent: if worktree already exists, resume it
|
|
172
|
+
if (existsSync(worktreePath)) {
|
|
173
|
+
try {
|
|
174
|
+
git(["rev-parse", "--git-dir"], worktreePath);
|
|
175
|
+
ensureGitignore(worktreePath);
|
|
176
|
+
worktrees.push({ repoName: repo.name, path: worktreePath, branch, resumed: true });
|
|
177
|
+
continue;
|
|
178
|
+
} catch {
|
|
179
|
+
// Directory exists but isn't a valid worktree — remove and recreate
|
|
180
|
+
try {
|
|
181
|
+
git(["worktree", "remove", "--force", worktreePath], repo.path);
|
|
182
|
+
} catch { /* best effort */ }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Check if branch already exists (resume scenario)
|
|
187
|
+
const exists = branchExistsInRepo(branch, repo.path);
|
|
188
|
+
|
|
189
|
+
if (exists) {
|
|
190
|
+
git(["worktree", "add", worktreePath, branch], repo.path);
|
|
191
|
+
ensureGitignore(worktreePath);
|
|
192
|
+
worktrees.push({ repoName: repo.name, path: worktreePath, branch, resumed: true });
|
|
193
|
+
} else {
|
|
194
|
+
git(["worktree", "add", "-b", branch, worktreePath], repo.path);
|
|
195
|
+
ensureGitignore(worktreePath);
|
|
196
|
+
worktrees.push({ repoName: repo.name, path: worktreePath, branch, resumed: false });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { parentPath, worktrees };
|
|
201
|
+
}
|
|
202
|
+
|
|
120
203
|
/**
|
|
121
204
|
* Check if a branch exists in the repo.
|
|
122
205
|
*/
|