@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
|
@@ -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,163 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { resolveRepos, isMultiRepo, validateRepoPath, type RepoResolution } from "./multi-repo.ts";
|
|
3
|
+
|
|
4
|
+
vi.mock("node:fs", async (importOriginal) => {
|
|
5
|
+
const actual = await importOriginal<typeof import("node:fs")>();
|
|
6
|
+
return {
|
|
7
|
+
...actual,
|
|
8
|
+
existsSync: vi.fn(),
|
|
9
|
+
statSync: vi.fn(),
|
|
10
|
+
};
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
import { existsSync, statSync } from "node:fs";
|
|
14
|
+
const mockExistsSync = existsSync as ReturnType<typeof vi.fn>;
|
|
15
|
+
const mockStatSync = statSync as ReturnType<typeof vi.fn>;
|
|
16
|
+
|
|
17
|
+
describe("resolveRepos", () => {
|
|
18
|
+
it("parses <!-- repos: api, frontend --> from description", () => {
|
|
19
|
+
const result = resolveRepos(
|
|
20
|
+
"Fix the bug\n<!-- repos: api, frontend -->",
|
|
21
|
+
[],
|
|
22
|
+
);
|
|
23
|
+
expect(result.source).toBe("issue_body");
|
|
24
|
+
expect(result.repos).toHaveLength(2);
|
|
25
|
+
expect(result.repos[0].name).toBe("api");
|
|
26
|
+
expect(result.repos[1].name).toBe("frontend");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("parses [repos: web, worker] from description", () => {
|
|
30
|
+
const result = resolveRepos(
|
|
31
|
+
"Some issue\n[repos: web, worker]",
|
|
32
|
+
[],
|
|
33
|
+
);
|
|
34
|
+
expect(result.source).toBe("issue_body");
|
|
35
|
+
expect(result.repos).toHaveLength(2);
|
|
36
|
+
expect(result.repos[0].name).toBe("web");
|
|
37
|
+
expect(result.repos[1].name).toBe("worker");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("extracts from repo:api and repo:frontend labels", () => {
|
|
41
|
+
const result = resolveRepos("No markers here", ["repo:api", "repo:frontend"]);
|
|
42
|
+
expect(result.source).toBe("labels");
|
|
43
|
+
expect(result.repos).toHaveLength(2);
|
|
44
|
+
expect(result.repos[0].name).toBe("api");
|
|
45
|
+
expect(result.repos[1].name).toBe("frontend");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("falls back to config repos when no markers/labels", () => {
|
|
49
|
+
const config = { codexBaseRepo: "/home/claw/myproject" };
|
|
50
|
+
const result = resolveRepos("Plain description", [], config);
|
|
51
|
+
expect(result.source).toBe("config_default");
|
|
52
|
+
expect(result.repos).toHaveLength(1);
|
|
53
|
+
expect(result.repos[0].name).toBe("default");
|
|
54
|
+
expect(result.repos[0].path).toBe("/home/claw/myproject");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("body markers take priority over labels", () => {
|
|
58
|
+
const result = resolveRepos(
|
|
59
|
+
"<!-- repos: api -->",
|
|
60
|
+
["repo:frontend"],
|
|
61
|
+
);
|
|
62
|
+
expect(result.source).toBe("issue_body");
|
|
63
|
+
expect(result.repos).toHaveLength(1);
|
|
64
|
+
expect(result.repos[0].name).toBe("api");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns single repo from codexBaseRepo when no repos config", () => {
|
|
68
|
+
const result = resolveRepos("Nothing special", []);
|
|
69
|
+
expect(result.source).toBe("config_default");
|
|
70
|
+
expect(result.repos).toHaveLength(1);
|
|
71
|
+
expect(result.repos[0].name).toBe("default");
|
|
72
|
+
expect(result.repos[0].path).toBe("/home/claw/ai-workspace");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("handles empty description + no labels (single repo fallback)", () => {
|
|
76
|
+
const result = resolveRepos("", []);
|
|
77
|
+
expect(result.source).toBe("config_default");
|
|
78
|
+
expect(result.repos).toHaveLength(1);
|
|
79
|
+
expect(result.repos[0].name).toBe("default");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("trims whitespace in repo names from markers", () => {
|
|
83
|
+
const result = resolveRepos(
|
|
84
|
+
"<!-- repos: api , frontend -->",
|
|
85
|
+
[],
|
|
86
|
+
);
|
|
87
|
+
expect(result.source).toBe("issue_body");
|
|
88
|
+
expect(result.repos[0].name).toBe("api");
|
|
89
|
+
expect(result.repos[1].name).toBe("frontend");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("handles null/undefined description", () => {
|
|
93
|
+
const resultNull = resolveRepos(null, []);
|
|
94
|
+
expect(resultNull.source).toBe("config_default");
|
|
95
|
+
expect(resultNull.repos).toHaveLength(1);
|
|
96
|
+
|
|
97
|
+
const resultUndefined = resolveRepos(undefined, []);
|
|
98
|
+
expect(resultUndefined.source).toBe("config_default");
|
|
99
|
+
expect(resultUndefined.repos).toHaveLength(1);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("isMultiRepo", () => {
|
|
104
|
+
it("returns true for 2+ repos", () => {
|
|
105
|
+
const resolution: RepoResolution = {
|
|
106
|
+
repos: [
|
|
107
|
+
{ name: "api", path: "/home/claw/api" },
|
|
108
|
+
{ name: "frontend", path: "/home/claw/frontend" },
|
|
109
|
+
],
|
|
110
|
+
source: "issue_body",
|
|
111
|
+
};
|
|
112
|
+
expect(isMultiRepo(resolution)).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("returns false for 1 repo", () => {
|
|
116
|
+
const resolution: RepoResolution = {
|
|
117
|
+
repos: [{ name: "default", path: "/home/claw/ai-workspace" }],
|
|
118
|
+
source: "config_default",
|
|
119
|
+
};
|
|
120
|
+
expect(isMultiRepo(resolution)).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("returns false for empty result", () => {
|
|
124
|
+
const resolution: RepoResolution = {
|
|
125
|
+
repos: [],
|
|
126
|
+
source: "config_default",
|
|
127
|
+
};
|
|
128
|
+
expect(isMultiRepo(resolution)).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("validateRepoPath", () => {
|
|
133
|
+
beforeEach(() => {
|
|
134
|
+
vi.restoreAllMocks();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("returns exists:false for missing path", () => {
|
|
138
|
+
mockExistsSync.mockReturnValue(false);
|
|
139
|
+
const result = validateRepoPath("/no/such/path");
|
|
140
|
+
expect(result).toEqual({ exists: false, isGitRepo: false, isSubmodule: false });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("returns isGitRepo:true, isSubmodule:false for normal repo (.git is directory)", () => {
|
|
144
|
+
mockExistsSync.mockReturnValue(true);
|
|
145
|
+
mockStatSync.mockReturnValue({ isFile: () => false, isDirectory: () => true });
|
|
146
|
+
const result = validateRepoPath("/home/claw/repos/api");
|
|
147
|
+
expect(result).toEqual({ exists: true, isGitRepo: true, isSubmodule: false });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("returns isGitRepo:true, isSubmodule:true for submodule (.git is file)", () => {
|
|
151
|
+
mockExistsSync.mockReturnValue(true);
|
|
152
|
+
mockStatSync.mockReturnValue({ isFile: () => true, isDirectory: () => false });
|
|
153
|
+
const result = validateRepoPath("/home/claw/workspace/submod");
|
|
154
|
+
expect(result).toEqual({ exists: true, isGitRepo: true, isSubmodule: true });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("returns isGitRepo:false for directory without .git", () => {
|
|
158
|
+
// First call: path exists. Second call: .git does not exist
|
|
159
|
+
mockExistsSync.mockImplementation((p: string) => !String(p).endsWith(".git"));
|
|
160
|
+
const result = validateRepoPath("/home/claw/not-a-repo");
|
|
161
|
+
expect(result).toEqual({ exists: true, isGitRepo: false, isSubmodule: false });
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
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 { existsSync, statSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
export interface RepoConfig {
|
|
14
|
+
name: string;
|
|
15
|
+
path: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RepoResolution {
|
|
19
|
+
repos: RepoConfig[];
|
|
20
|
+
source: "issue_body" | "labels" | "config_default";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve which repos a dispatch should work with.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveRepos(
|
|
27
|
+
description: string | null | undefined,
|
|
28
|
+
labels: string[],
|
|
29
|
+
pluginConfig?: Record<string, unknown>,
|
|
30
|
+
): RepoResolution {
|
|
31
|
+
// 1. Check issue body for repo markers
|
|
32
|
+
// Match: <!-- repos: name1, name2 --> or [repos: name1, name2]
|
|
33
|
+
const htmlComment = description?.match(/<!--\s*repos:\s*([^>]+?)\s*-->/i);
|
|
34
|
+
const bracketMatch = description?.match(/\[repos:\s*([^\]]+)\]/i);
|
|
35
|
+
const bodyMatch = htmlComment?.[1] ?? bracketMatch?.[1];
|
|
36
|
+
|
|
37
|
+
if (bodyMatch) {
|
|
38
|
+
const names = bodyMatch.split(",").map(s => s.trim()).filter(Boolean);
|
|
39
|
+
if (names.length > 0) {
|
|
40
|
+
const repoMap = getRepoMap(pluginConfig);
|
|
41
|
+
const repos = names.map(name => ({
|
|
42
|
+
name,
|
|
43
|
+
path: repoMap[name] ?? resolveRepoPath(name, pluginConfig),
|
|
44
|
+
}));
|
|
45
|
+
return { repos, source: "issue_body" };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 2. Check labels for repo: prefix
|
|
50
|
+
const repoLabels = labels
|
|
51
|
+
.filter(l => l.startsWith("repo:"))
|
|
52
|
+
.map(l => l.slice(5).trim())
|
|
53
|
+
.filter(Boolean);
|
|
54
|
+
|
|
55
|
+
if (repoLabels.length > 0) {
|
|
56
|
+
const repoMap = getRepoMap(pluginConfig);
|
|
57
|
+
const repos = repoLabels.map(name => ({
|
|
58
|
+
name,
|
|
59
|
+
path: repoMap[name] ?? resolveRepoPath(name, pluginConfig),
|
|
60
|
+
}));
|
|
61
|
+
return { repos, source: "labels" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 3. Config default: single repo
|
|
65
|
+
const baseRepo = (pluginConfig?.codexBaseRepo as string) ?? "/home/claw/ai-workspace";
|
|
66
|
+
return {
|
|
67
|
+
repos: [{ name: "default", path: baseRepo }],
|
|
68
|
+
source: "config_default",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getRepoMap(pluginConfig?: Record<string, unknown>): Record<string, string> {
|
|
73
|
+
const repos = pluginConfig?.repos as Record<string, string> | undefined;
|
|
74
|
+
return repos ?? {};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resolveRepoPath(name: string, pluginConfig?: Record<string, unknown>): string {
|
|
78
|
+
// Convention: {parentDir}/{name}
|
|
79
|
+
const baseRepo = (pluginConfig?.codexBaseRepo as string) ?? "/home/claw/ai-workspace";
|
|
80
|
+
const parentDir = path.dirname(baseRepo);
|
|
81
|
+
return path.join(parentDir, name);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function isMultiRepo(resolution: RepoResolution): boolean {
|
|
85
|
+
return resolution.repos.length > 1;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Validate a repo path: exists, is a git repo, and whether it's a submodule.
|
|
90
|
+
* Submodules have a `.git` *file* (not directory) that points to the parent's
|
|
91
|
+
* `.git/modules/` — `git worktree add` won't work on them.
|
|
92
|
+
*/
|
|
93
|
+
export function validateRepoPath(repoPath: string): {
|
|
94
|
+
exists: boolean;
|
|
95
|
+
isGitRepo: boolean;
|
|
96
|
+
isSubmodule: boolean;
|
|
97
|
+
} {
|
|
98
|
+
if (!existsSync(repoPath)) {
|
|
99
|
+
return { exists: false, isGitRepo: false, isSubmodule: false };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const gitPath = path.join(repoPath, ".git");
|
|
103
|
+
if (!existsSync(gitPath)) {
|
|
104
|
+
return { exists: true, isGitRepo: false, isSubmodule: false };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const stat = statSync(gitPath);
|
|
108
|
+
if (stat.isFile()) {
|
|
109
|
+
// .git is a file → submodule (points to parent's .git/modules/)
|
|
110
|
+
return { exists: true, isGitRepo: true, isSubmodule: true };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { exists: true, isGitRepo: true, isSubmodule: false };
|
|
114
|
+
}
|
package/src/infra/notify.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
createNoopNotifier,
|
|
4
4
|
createNotifierFromConfig,
|
|
5
5
|
formatMessage,
|
|
6
|
+
formatRichMessage,
|
|
6
7
|
sendToTarget,
|
|
7
8
|
parseNotificationsConfig,
|
|
8
9
|
type NotifyKind,
|
|
@@ -23,24 +24,24 @@ describe("formatMessage", () => {
|
|
|
23
24
|
|
|
24
25
|
it("formats dispatch message", () => {
|
|
25
26
|
const msg = formatMessage("dispatch", basePayload);
|
|
26
|
-
expect(msg).toBe("API-42
|
|
27
|
+
expect(msg).toBe("API-42 started — Fix auth");
|
|
27
28
|
});
|
|
28
29
|
|
|
29
30
|
it("formats working message with attempt", () => {
|
|
30
31
|
const msg = formatMessage("working", { ...basePayload, attempt: 1 });
|
|
31
|
-
expect(msg).toContain("
|
|
32
|
-
expect(msg).toContain("attempt
|
|
32
|
+
expect(msg).toContain("working on it");
|
|
33
|
+
expect(msg).toContain("attempt 2"); // 1-based for humans
|
|
33
34
|
});
|
|
34
35
|
|
|
35
36
|
it("formats auditing message", () => {
|
|
36
37
|
const msg = formatMessage("auditing", basePayload);
|
|
37
|
-
expect(msg).toContain("
|
|
38
|
+
expect(msg).toContain("checking the work");
|
|
38
39
|
});
|
|
39
40
|
|
|
40
41
|
it("formats audit_pass message", () => {
|
|
41
42
|
const msg = formatMessage("audit_pass", basePayload);
|
|
42
|
-
expect(msg).toContain("
|
|
43
|
-
expect(msg).toContain("
|
|
43
|
+
expect(msg).toContain("done!");
|
|
44
|
+
expect(msg).toContain("Ready for review");
|
|
44
45
|
});
|
|
45
46
|
|
|
46
47
|
it("formats audit_fail message with gaps", () => {
|
|
@@ -49,8 +50,8 @@ describe("formatMessage", () => {
|
|
|
49
50
|
attempt: 1,
|
|
50
51
|
verdict: { pass: false, gaps: ["no tests", "missing validation"] },
|
|
51
52
|
});
|
|
52
|
-
expect(msg).toContain("
|
|
53
|
-
expect(msg).toContain("attempt
|
|
53
|
+
expect(msg).toContain("needs more work");
|
|
54
|
+
expect(msg).toContain("attempt 2"); // 1-based for humans
|
|
54
55
|
expect(msg).toContain("no tests");
|
|
55
56
|
expect(msg).toContain("missing validation");
|
|
56
57
|
});
|
|
@@ -67,10 +68,10 @@ describe("formatMessage", () => {
|
|
|
67
68
|
it("formats escalation message with reason", () => {
|
|
68
69
|
const msg = formatMessage("escalation", {
|
|
69
70
|
...basePayload,
|
|
70
|
-
|
|
71
|
+
attempt: 2,
|
|
71
72
|
});
|
|
72
|
-
expect(msg).toContain("needs
|
|
73
|
-
expect(msg).toContain("
|
|
73
|
+
expect(msg).toContain("needs your help");
|
|
74
|
+
expect(msg).toContain("3 tries"); // 1-based
|
|
74
75
|
});
|
|
75
76
|
|
|
76
77
|
it("formats stuck message", () => {
|
|
@@ -86,11 +87,11 @@ describe("formatMessage", () => {
|
|
|
86
87
|
const msg = formatMessage("watchdog_kill", {
|
|
87
88
|
...basePayload,
|
|
88
89
|
attempt: 0,
|
|
89
|
-
reason: "no
|
|
90
|
+
reason: "no activity for 120s",
|
|
90
91
|
});
|
|
91
|
-
expect(msg).toContain("
|
|
92
|
-
expect(msg).toContain("no
|
|
93
|
-
expect(msg).toContain("Retrying (attempt
|
|
92
|
+
expect(msg).toContain("timed out");
|
|
93
|
+
expect(msg).toContain("no activity for 120s");
|
|
94
|
+
expect(msg).toContain("Retrying (attempt 1)"); // 1-based
|
|
94
95
|
});
|
|
95
96
|
|
|
96
97
|
it("formats watchdog_kill without attempt", () => {
|
|
@@ -325,7 +326,7 @@ describe("createNotifierFromConfig", () => {
|
|
|
325
326
|
expect(runtime.channel.telegram.sendMessageTelegram).toHaveBeenCalledOnce();
|
|
326
327
|
expect(runtime.channel.telegram.sendMessageTelegram).toHaveBeenCalledWith(
|
|
327
328
|
"-100388",
|
|
328
|
-
expect.stringContaining("
|
|
329
|
+
expect.stringContaining("working on it"),
|
|
329
330
|
{ silent: true },
|
|
330
331
|
);
|
|
331
332
|
});
|
|
@@ -402,6 +403,144 @@ describe("createNotifierFromConfig", () => {
|
|
|
402
403
|
});
|
|
403
404
|
});
|
|
404
405
|
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
// formatRichMessage
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
describe("formatRichMessage", () => {
|
|
411
|
+
const basePayload: NotifyPayload = {
|
|
412
|
+
identifier: "CT-10",
|
|
413
|
+
title: "Add caching",
|
|
414
|
+
status: "dispatched",
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
it("returns Discord embed with correct color for dispatch (blue)", () => {
|
|
418
|
+
const msg = formatRichMessage("dispatch", basePayload);
|
|
419
|
+
expect(msg.discord?.embeds).toHaveLength(1);
|
|
420
|
+
expect(msg.discord!.embeds[0].color).toBe(0x3498db);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("returns Discord embed with green for audit_pass", () => {
|
|
424
|
+
const msg = formatRichMessage("audit_pass", basePayload);
|
|
425
|
+
expect(msg.discord!.embeds[0].color).toBe(0x2ecc71);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("returns Discord embed with red for audit_fail", () => {
|
|
429
|
+
const msg = formatRichMessage("audit_fail", { ...basePayload, attempt: 1, verdict: { pass: false, gaps: ["no tests"] } });
|
|
430
|
+
expect(msg.discord!.embeds[0].color).toBe(0xe74c3c);
|
|
431
|
+
expect(msg.discord!.embeds[0].fields).toEqual(
|
|
432
|
+
expect.arrayContaining([expect.objectContaining({ name: "Issues to fix", value: "no tests" })]),
|
|
433
|
+
);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("returns Discord embed with orange for stuck", () => {
|
|
437
|
+
const msg = formatRichMessage("stuck", { ...basePayload, reason: "stale 2h" });
|
|
438
|
+
expect(msg.discord!.embeds[0].color).toBe(0xe67e22);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("returns Telegram HTML with bold identifier", () => {
|
|
442
|
+
const msg = formatRichMessage("dispatch", basePayload);
|
|
443
|
+
expect(msg.telegram?.html).toContain("<b>CT-10</b>");
|
|
444
|
+
expect(msg.telegram?.html).toContain("<i>Add caching</i>");
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("includes plain text fallback", () => {
|
|
448
|
+
const msg = formatRichMessage("dispatch", basePayload);
|
|
449
|
+
expect(msg.text).toBe("CT-10 started — Add caching");
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// ---------------------------------------------------------------------------
|
|
454
|
+
// sendToTarget with RichMessage
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
describe("sendToTarget (RichMessage)", () => {
|
|
458
|
+
function mockRuntime(): any {
|
|
459
|
+
return {
|
|
460
|
+
channel: {
|
|
461
|
+
discord: { sendMessageDiscord: vi.fn(async () => {}) },
|
|
462
|
+
slack: { sendMessageSlack: vi.fn(async () => ({})) },
|
|
463
|
+
telegram: { sendMessageTelegram: vi.fn(async () => {}) },
|
|
464
|
+
signal: { sendMessageSignal: vi.fn(async () => {}) },
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
470
|
+
|
|
471
|
+
it("passes embeds to Discord when RichMessage provided", async () => {
|
|
472
|
+
const runtime = mockRuntime();
|
|
473
|
+
const target: NotifyTarget = { channel: "discord", target: "D-1" };
|
|
474
|
+
const rich = {
|
|
475
|
+
text: "plain",
|
|
476
|
+
discord: { embeds: [{ title: "test", color: 0x3498db }] },
|
|
477
|
+
};
|
|
478
|
+
await sendToTarget(target, rich, runtime);
|
|
479
|
+
expect(runtime.channel.discord.sendMessageDiscord).toHaveBeenCalledWith(
|
|
480
|
+
"D-1", "plain", { embeds: [{ title: "test", color: 0x3498db }] },
|
|
481
|
+
);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("passes textMode html to Telegram when RichMessage provided", async () => {
|
|
485
|
+
const runtime = mockRuntime();
|
|
486
|
+
const target: NotifyTarget = { channel: "telegram", target: "-999" };
|
|
487
|
+
const rich = {
|
|
488
|
+
text: "plain",
|
|
489
|
+
telegram: { html: "<b>CT-10</b> dispatched" },
|
|
490
|
+
};
|
|
491
|
+
await sendToTarget(target, rich, runtime);
|
|
492
|
+
expect(runtime.channel.telegram.sendMessageTelegram).toHaveBeenCalledWith(
|
|
493
|
+
"-999", "<b>CT-10</b> dispatched", { silent: true, textMode: "html" },
|
|
494
|
+
);
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// ---------------------------------------------------------------------------
|
|
499
|
+
// createNotifierFromConfig (richFormat)
|
|
500
|
+
// ---------------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
describe("createNotifierFromConfig (richFormat)", () => {
|
|
503
|
+
function mockRuntime(): any {
|
|
504
|
+
return {
|
|
505
|
+
channel: {
|
|
506
|
+
discord: { sendMessageDiscord: vi.fn(async () => {}) },
|
|
507
|
+
slack: { sendMessageSlack: vi.fn(async () => ({})) },
|
|
508
|
+
telegram: { sendMessageTelegram: vi.fn(async () => {}) },
|
|
509
|
+
signal: { sendMessageSignal: vi.fn(async () => {}) },
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
515
|
+
|
|
516
|
+
it("sends Discord embeds when richFormat is true", async () => {
|
|
517
|
+
const runtime = mockRuntime();
|
|
518
|
+
const notify = createNotifierFromConfig({
|
|
519
|
+
notifications: {
|
|
520
|
+
richFormat: true,
|
|
521
|
+
targets: [{ channel: "discord", target: "D-1" }],
|
|
522
|
+
},
|
|
523
|
+
}, runtime);
|
|
524
|
+
await notify("dispatch", { identifier: "CT-1", title: "Test", status: "dispatched" });
|
|
525
|
+
const [, , opts] = runtime.channel.discord.sendMessageDiscord.mock.calls[0];
|
|
526
|
+
expect(opts?.embeds).toBeDefined();
|
|
527
|
+
expect(opts.embeds).toHaveLength(1);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("sends plain text when richFormat is false", async () => {
|
|
531
|
+
const runtime = mockRuntime();
|
|
532
|
+
const notify = createNotifierFromConfig({
|
|
533
|
+
notifications: {
|
|
534
|
+
richFormat: false,
|
|
535
|
+
targets: [{ channel: "discord", target: "D-1" }],
|
|
536
|
+
},
|
|
537
|
+
}, runtime);
|
|
538
|
+
await notify("dispatch", { identifier: "CT-1", title: "Test", status: "dispatched" });
|
|
539
|
+
const call = runtime.channel.discord.sendMessageDiscord.mock.calls[0];
|
|
540
|
+
expect(call).toHaveLength(2); // no third arg with embeds
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
|
|
405
544
|
// ---------------------------------------------------------------------------
|
|
406
545
|
// createNoopNotifier
|
|
407
546
|
// ---------------------------------------------------------------------------
|