@calltelemetry/openclaw-linear 0.6.1 → 0.7.1

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 (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +115 -17
  3. package/index.ts +57 -22
  4. package/openclaw.plugin.json +37 -4
  5. package/package.json +2 -1
  6. package/prompts.yaml +47 -0
  7. package/src/api/linear-api.test.ts +494 -0
  8. package/src/api/linear-api.ts +193 -19
  9. package/src/gateway/dispatch-methods.ts +243 -0
  10. package/src/infra/cli.ts +284 -29
  11. package/src/infra/codex-worktree.ts +83 -0
  12. package/src/infra/commands.ts +156 -0
  13. package/src/infra/doctor.test.ts +4 -4
  14. package/src/infra/doctor.ts +7 -29
  15. package/src/infra/file-lock.test.ts +61 -0
  16. package/src/infra/file-lock.ts +49 -0
  17. package/src/infra/multi-repo.ts +85 -0
  18. package/src/infra/notify.test.ts +357 -108
  19. package/src/infra/notify.ts +222 -43
  20. package/src/infra/observability.ts +48 -0
  21. package/src/infra/resilience.test.ts +94 -0
  22. package/src/infra/resilience.ts +101 -0
  23. package/src/pipeline/artifacts.ts +38 -2
  24. package/src/pipeline/dag-dispatch.test.ts +553 -0
  25. package/src/pipeline/dag-dispatch.ts +390 -0
  26. package/src/pipeline/dispatch-service.ts +48 -1
  27. package/src/pipeline/dispatch-state.ts +2 -42
  28. package/src/pipeline/pipeline.ts +91 -17
  29. package/src/pipeline/planner.test.ts +334 -0
  30. package/src/pipeline/planner.ts +287 -0
  31. package/src/pipeline/planning-state.test.ts +236 -0
  32. package/src/pipeline/planning-state.ts +178 -0
  33. package/src/pipeline/tier-assess.test.ts +175 -0
  34. package/src/pipeline/webhook.ts +90 -17
  35. package/src/tools/dispatch-history-tool.ts +201 -0
  36. package/src/tools/orchestration-tools.test.ts +158 -0
  37. package/src/tools/planner-tools.test.ts +535 -0
  38. package/src/tools/planner-tools.ts +450 -0
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { acquireLock, releaseLock } from "./file-lock.js";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+
7
+ const tmpDir = os.tmpdir();
8
+ const testState = path.join(tmpDir, `file-lock-test-${process.pid}.json`);
9
+ const lockFile = testState + ".lock";
10
+
11
+ afterEach(async () => {
12
+ try { await fs.unlink(lockFile); } catch {}
13
+ try { await fs.unlink(testState); } catch {}
14
+ });
15
+
16
+ describe("acquireLock / releaseLock", () => {
17
+ it("creates and removes a lock file", async () => {
18
+ await acquireLock(testState);
19
+ const stat = await fs.stat(lockFile);
20
+ expect(stat.isFile()).toBe(true);
21
+
22
+ await releaseLock(testState);
23
+ await expect(fs.stat(lockFile)).rejects.toThrow();
24
+ });
25
+
26
+ it("blocks concurrent acquires until released", async () => {
27
+ await acquireLock(testState);
28
+
29
+ let secondAcquired = false;
30
+ const secondLock = acquireLock(testState).then(() => {
31
+ secondAcquired = true;
32
+ });
33
+
34
+ // Give the second acquire a moment to spin
35
+ await new Promise((r) => setTimeout(r, 120));
36
+ expect(secondAcquired).toBe(false);
37
+
38
+ await releaseLock(testState);
39
+ await secondLock;
40
+ expect(secondAcquired).toBe(true);
41
+
42
+ await releaseLock(testState);
43
+ });
44
+
45
+ it("releaseLock is safe to call when no lock exists", async () => {
46
+ await expect(releaseLock(testState)).resolves.toBeUndefined();
47
+ });
48
+
49
+ it("recovers from stale lock", async () => {
50
+ // Write a lock file with an old timestamp (> 30s ago)
51
+ await fs.writeFile(lockFile, String(Date.now() - 60_000), { flag: "w" });
52
+
53
+ // Should succeed by detecting stale lock
54
+ await acquireLock(testState);
55
+ const content = await fs.readFile(lockFile, "utf-8");
56
+ const lockTime = Number(content);
57
+ expect(Date.now() - lockTime).toBeLessThan(5000);
58
+
59
+ await releaseLock(testState);
60
+ });
61
+ });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * file-lock.ts — Shared file-level locking for state files.
3
+ *
4
+ * Used by dispatch-state.ts and planning-state.ts to prevent
5
+ * concurrent read-modify-write races on JSON state files.
6
+ */
7
+ import fs from "node:fs/promises";
8
+
9
+ const LOCK_STALE_MS = 30_000;
10
+ const LOCK_RETRY_MS = 50;
11
+ const LOCK_TIMEOUT_MS = 10_000;
12
+
13
+ function lockPath(statePath: string): string {
14
+ return statePath + ".lock";
15
+ }
16
+
17
+ export async function acquireLock(statePath: string): Promise<void> {
18
+ const lock = lockPath(statePath);
19
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
20
+
21
+ while (Date.now() < deadline) {
22
+ try {
23
+ await fs.writeFile(lock, String(Date.now()), { flag: "wx" });
24
+ return;
25
+ } catch (err: any) {
26
+ if (err.code !== "EEXIST") throw err;
27
+
28
+ // Check for stale lock
29
+ try {
30
+ const content = await fs.readFile(lock, "utf-8");
31
+ const lockTime = Number(content);
32
+ if (Date.now() - lockTime > LOCK_STALE_MS) {
33
+ try { await fs.unlink(lock); } catch { /* race */ }
34
+ continue;
35
+ }
36
+ } catch { /* lock disappeared — retry */ }
37
+
38
+ await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
39
+ }
40
+ }
41
+
42
+ // Last resort: force remove potentially stale lock
43
+ try { await fs.unlink(lockPath(statePath)); } catch { /* ignore */ }
44
+ await fs.writeFile(lock, String(Date.now()), { flag: "wx" });
45
+ }
46
+
47
+ export async function releaseLock(statePath: string): Promise<void> {
48
+ try { await fs.unlink(lockPath(statePath)); } catch { /* already removed */ }
49
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * multi-repo.ts — Multi-repo resolution for dispatches spanning multiple git repos.
3
+ *
4
+ * Three-tier resolution:
5
+ * 1. Issue body markers: <!-- repos: api, frontend --> or [repos: api, frontend]
6
+ * 2. Linear labels: repo:api, repo:frontend
7
+ * 3. Config default: Falls back to single codexBaseRepo
8
+ */
9
+
10
+ import path from "node:path";
11
+
12
+ export interface RepoConfig {
13
+ name: string;
14
+ path: string;
15
+ }
16
+
17
+ export interface RepoResolution {
18
+ repos: RepoConfig[];
19
+ source: "issue_body" | "labels" | "config_default";
20
+ }
21
+
22
+ /**
23
+ * Resolve which repos a dispatch should work with.
24
+ */
25
+ export function resolveRepos(
26
+ description: string | null | undefined,
27
+ labels: string[],
28
+ pluginConfig?: Record<string, unknown>,
29
+ ): RepoResolution {
30
+ // 1. Check issue body for repo markers
31
+ // Match: <!-- repos: name1, name2 --> or [repos: name1, name2]
32
+ const htmlComment = description?.match(/<!--\s*repos:\s*([^>]+?)\s*-->/i);
33
+ const bracketMatch = description?.match(/\[repos:\s*([^\]]+)\]/i);
34
+ const bodyMatch = htmlComment?.[1] ?? bracketMatch?.[1];
35
+
36
+ if (bodyMatch) {
37
+ const names = bodyMatch.split(",").map(s => s.trim()).filter(Boolean);
38
+ if (names.length > 0) {
39
+ const repoMap = getRepoMap(pluginConfig);
40
+ const repos = names.map(name => ({
41
+ name,
42
+ path: repoMap[name] ?? resolveRepoPath(name, pluginConfig),
43
+ }));
44
+ return { repos, source: "issue_body" };
45
+ }
46
+ }
47
+
48
+ // 2. Check labels for repo: prefix
49
+ const repoLabels = labels
50
+ .filter(l => l.startsWith("repo:"))
51
+ .map(l => l.slice(5).trim())
52
+ .filter(Boolean);
53
+
54
+ if (repoLabels.length > 0) {
55
+ const repoMap = getRepoMap(pluginConfig);
56
+ const repos = repoLabels.map(name => ({
57
+ name,
58
+ path: repoMap[name] ?? resolveRepoPath(name, pluginConfig),
59
+ }));
60
+ return { repos, source: "labels" };
61
+ }
62
+
63
+ // 3. Config default: single repo
64
+ const baseRepo = (pluginConfig?.codexBaseRepo as string) ?? "/home/claw/ai-workspace";
65
+ return {
66
+ repos: [{ name: "default", path: baseRepo }],
67
+ source: "config_default",
68
+ };
69
+ }
70
+
71
+ function getRepoMap(pluginConfig?: Record<string, unknown>): Record<string, string> {
72
+ const repos = pluginConfig?.repos as Record<string, string> | undefined;
73
+ return repos ?? {};
74
+ }
75
+
76
+ function resolveRepoPath(name: string, pluginConfig?: Record<string, unknown>): string {
77
+ // Convention: {parentDir}/{name}
78
+ const baseRepo = (pluginConfig?.codexBaseRepo as string) ?? "/home/claw/ai-workspace";
79
+ const parentDir = path.dirname(baseRepo);
80
+ return path.join(parentDir, name);
81
+ }
82
+
83
+ export function isMultiRepo(resolution: RepoResolution): boolean {
84
+ return resolution.repos.length > 1;
85
+ }