@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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +719 -539
  3. package/index.ts +40 -1
  4. package/openclaw.plugin.json +4 -4
  5. package/package.json +2 -1
  6. package/prompts.yaml +19 -5
  7. package/src/__test__/fixtures/linear-responses.ts +75 -0
  8. package/src/__test__/fixtures/webhook-payloads.ts +113 -0
  9. package/src/__test__/helpers.ts +133 -0
  10. package/src/agent/agent.test.ts +143 -0
  11. package/src/api/linear-api.test.ts +586 -0
  12. package/src/api/linear-api.ts +50 -11
  13. package/src/gateway/dispatch-methods.test.ts +409 -0
  14. package/src/gateway/dispatch-methods.ts +243 -0
  15. package/src/infra/cli.ts +273 -30
  16. package/src/infra/codex-worktree.ts +83 -0
  17. package/src/infra/commands.test.ts +276 -0
  18. package/src/infra/commands.ts +156 -0
  19. package/src/infra/doctor.test.ts +19 -0
  20. package/src/infra/doctor.ts +28 -23
  21. package/src/infra/file-lock.test.ts +61 -0
  22. package/src/infra/file-lock.ts +49 -0
  23. package/src/infra/multi-repo.test.ts +163 -0
  24. package/src/infra/multi-repo.ts +114 -0
  25. package/src/infra/notify.test.ts +155 -16
  26. package/src/infra/notify.ts +137 -26
  27. package/src/infra/observability.test.ts +85 -0
  28. package/src/infra/observability.ts +48 -0
  29. package/src/infra/resilience.test.ts +94 -0
  30. package/src/infra/resilience.ts +101 -0
  31. package/src/pipeline/artifacts.test.ts +26 -3
  32. package/src/pipeline/artifacts.ts +38 -2
  33. package/src/pipeline/dag-dispatch.test.ts +553 -0
  34. package/src/pipeline/dag-dispatch.ts +390 -0
  35. package/src/pipeline/dispatch-service.ts +48 -1
  36. package/src/pipeline/dispatch-state.ts +3 -42
  37. package/src/pipeline/e2e-dispatch.test.ts +584 -0
  38. package/src/pipeline/e2e-planning.test.ts +455 -0
  39. package/src/pipeline/pipeline.test.ts +69 -0
  40. package/src/pipeline/pipeline.ts +132 -29
  41. package/src/pipeline/planner.test.ts +1 -1
  42. package/src/pipeline/planner.ts +18 -31
  43. package/src/pipeline/planning-state.ts +2 -40
  44. package/src/pipeline/tier-assess.test.ts +264 -0
  45. package/src/pipeline/webhook.ts +134 -36
  46. package/src/tools/cli-shared.test.ts +155 -0
  47. package/src/tools/code-tool.test.ts +210 -0
  48. package/src/tools/dispatch-history-tool.test.ts +315 -0
  49. package/src/tools/dispatch-history-tool.ts +201 -0
  50. 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 current prompts.yaml content")
262
- .action(async () => {
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
- const customPath = pluginConfig?.promptsPath as string | undefined;
284
+ clearPromptCache();
285
+ const loaded = loadPrompts(pluginConfig, opts.worktree);
265
286
 
266
- let resolvedPath: string;
267
- if (customPath) {
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
- const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
273
- resolvedPath = join(pluginRoot, "prompts.yaml");
290
+ console.log(`\nGlobal resolved prompts\n`);
274
291
  }
275
292
 
276
- console.log(`\nPrompts file: ${resolvedPath}\n`);
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
- .action(async () => {
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("\nValidation FAILED:\n");
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("\nValidation PASSED — all sections and template variables present.\n");
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
  */