@calltelemetry/openclaw-linear 0.9.14 → 0.9.16
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/README.md +104 -48
- package/index.ts +57 -0
- package/openclaw.plugin.json +44 -2
- package/package.json +1 -1
- package/prompts.yaml +3 -1
- package/src/__test__/fixtures/recorded-sub-issue-flow.ts +37 -50
- package/src/agent/agent.test.ts +1 -1
- package/src/agent/agent.ts +39 -6
- package/src/api/linear-api.test.ts +188 -1
- package/src/api/linear-api.ts +114 -5
- package/src/infra/multi-repo.test.ts +127 -1
- package/src/infra/multi-repo.ts +74 -6
- package/src/infra/tmux-runner.ts +599 -0
- package/src/infra/tmux.ts +158 -0
- package/src/infra/token-refresh-timer.ts +44 -0
- package/src/pipeline/active-session.ts +19 -1
- package/src/pipeline/artifacts.ts +42 -0
- package/src/pipeline/dispatch-state.ts +3 -0
- package/src/pipeline/guidance.test.ts +53 -0
- package/src/pipeline/guidance.ts +38 -0
- package/src/pipeline/memory-search.ts +40 -0
- package/src/pipeline/pipeline.ts +184 -17
- package/src/pipeline/retro.ts +231 -0
- package/src/pipeline/webhook.test.ts +8 -8
- package/src/pipeline/webhook.ts +408 -29
- package/src/tools/claude-tool.ts +68 -10
- package/src/tools/cli-shared.ts +50 -2
- package/src/tools/code-tool.ts +230 -150
- package/src/tools/codex-tool.ts +61 -9
- package/src/tools/gemini-tool.ts +61 -10
- package/src/tools/steering-tools.ts +176 -0
- package/src/tools/tools.test.ts +47 -15
- package/src/tools/tools.ts +17 -4
- package/src/__test__/smoke-linear-api.test.ts +0 -847
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { resolveRepos, isMultiRepo, validateRepoPath, type RepoResolution } from "./multi-repo.ts";
|
|
4
|
+
import { resolveRepos, isMultiRepo, validateRepoPath, getRepoEntries, buildCandidateRepositories, type RepoResolution } from "./multi-repo.ts";
|
|
5
5
|
|
|
6
6
|
vi.mock("node:fs", async (importOriginal) => {
|
|
7
7
|
const actual = await importOriginal<typeof import("node:fs")>();
|
|
@@ -131,6 +131,132 @@ describe("isMultiRepo", () => {
|
|
|
131
131
|
});
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
+
describe("getRepoEntries", () => {
|
|
135
|
+
it("normalizes string values to RepoEntry objects", () => {
|
|
136
|
+
const config = { repos: { api: "/tmp/api", frontend: "/tmp/frontend" } };
|
|
137
|
+
const entries = getRepoEntries(config);
|
|
138
|
+
expect(entries.api).toEqual({ path: "/tmp/api" });
|
|
139
|
+
expect(entries.frontend).toEqual({ path: "/tmp/frontend" });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("passes through object values with github and hostname", () => {
|
|
143
|
+
const config = {
|
|
144
|
+
repos: {
|
|
145
|
+
api: { path: "/tmp/api", github: "org/api", hostname: "github.example.com" },
|
|
146
|
+
frontend: { path: "/tmp/frontend", github: "org/frontend" },
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
const entries = getRepoEntries(config);
|
|
150
|
+
expect(entries.api).toEqual({ path: "/tmp/api", github: "org/api", hostname: "github.example.com" });
|
|
151
|
+
expect(entries.frontend).toEqual({ path: "/tmp/frontend", github: "org/frontend", hostname: undefined });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("handles mixed string and object repos", () => {
|
|
155
|
+
const config = {
|
|
156
|
+
repos: {
|
|
157
|
+
api: { path: "/tmp/api", github: "org/api" },
|
|
158
|
+
legacy: "/tmp/legacy",
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
const entries = getRepoEntries(config);
|
|
162
|
+
expect(entries.api.github).toBe("org/api");
|
|
163
|
+
expect(entries.legacy).toEqual({ path: "/tmp/legacy" });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("returns empty object when no repos config", () => {
|
|
167
|
+
expect(getRepoEntries({})).toEqual({});
|
|
168
|
+
expect(getRepoEntries(undefined)).toEqual({});
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("buildCandidateRepositories", () => {
|
|
173
|
+
it("builds candidates from repos with github field", () => {
|
|
174
|
+
const config = {
|
|
175
|
+
repos: {
|
|
176
|
+
api: { path: "/tmp/api", github: "calltelemetry/cisco-cdr" },
|
|
177
|
+
frontend: { path: "/tmp/frontend", github: "calltelemetry/ct-quasar" },
|
|
178
|
+
legacy: "/tmp/legacy",
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
const candidates = buildCandidateRepositories(config);
|
|
182
|
+
expect(candidates).toHaveLength(2);
|
|
183
|
+
expect(candidates[0]).toEqual({ hostname: "github.com", repositoryFullName: "calltelemetry/cisco-cdr" });
|
|
184
|
+
expect(candidates[1]).toEqual({ hostname: "github.com", repositoryFullName: "calltelemetry/ct-quasar" });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("uses custom hostname when provided", () => {
|
|
188
|
+
const config = {
|
|
189
|
+
repos: {
|
|
190
|
+
api: { path: "/tmp/api", github: "org/api", hostname: "git.corp.com" },
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
const candidates = buildCandidateRepositories(config);
|
|
194
|
+
expect(candidates[0].hostname).toBe("git.corp.com");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("returns empty array when no repos have github", () => {
|
|
198
|
+
const config = { repos: { api: "/tmp/api" } };
|
|
199
|
+
expect(buildCandidateRepositories(config)).toEqual([]);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("resolveRepos with team mapping", () => {
|
|
204
|
+
const config = {
|
|
205
|
+
repos: {
|
|
206
|
+
api: { path: "/tmp/api", github: "org/api" },
|
|
207
|
+
frontend: { path: "/tmp/frontend", github: "org/frontend" },
|
|
208
|
+
},
|
|
209
|
+
teamMappings: {
|
|
210
|
+
API: { repos: ["api"], defaultAgent: "kaylee" },
|
|
211
|
+
UAT: { repos: ["api", "frontend"] },
|
|
212
|
+
MED: { context: "Media team" },
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
it("uses team mapping when no body markers or labels", () => {
|
|
217
|
+
const result = resolveRepos("Plain description", [], config, "API");
|
|
218
|
+
expect(result.source).toBe("team_mapping");
|
|
219
|
+
expect(result.repos).toHaveLength(1);
|
|
220
|
+
expect(result.repos[0].name).toBe("api");
|
|
221
|
+
expect(result.repos[0].path).toBe("/tmp/api");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("team mapping resolves multi-repo teams", () => {
|
|
225
|
+
const result = resolveRepos("Plain description", [], config, "UAT");
|
|
226
|
+
expect(result.source).toBe("team_mapping");
|
|
227
|
+
expect(result.repos).toHaveLength(2);
|
|
228
|
+
expect(result.repos[0].name).toBe("api");
|
|
229
|
+
expect(result.repos[1].name).toBe("frontend");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("body markers take priority over team mapping", () => {
|
|
233
|
+
const result = resolveRepos("<!-- repos: frontend -->", [], config, "API");
|
|
234
|
+
expect(result.source).toBe("issue_body");
|
|
235
|
+
expect(result.repos[0].name).toBe("frontend");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("labels take priority over team mapping", () => {
|
|
239
|
+
const result = resolveRepos("No markers", ["repo:frontend"], config, "API");
|
|
240
|
+
expect(result.source).toBe("labels");
|
|
241
|
+
expect(result.repos[0].name).toBe("frontend");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("falls back to config_default when team has no repos", () => {
|
|
245
|
+
const result = resolveRepos("Plain description", [], config, "MED");
|
|
246
|
+
expect(result.source).toBe("config_default");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("falls back to config_default when teamKey is unknown", () => {
|
|
250
|
+
const result = resolveRepos("Plain description", [], config, "UNKNOWN");
|
|
251
|
+
expect(result.source).toBe("config_default");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("falls back to config_default when no teamKey provided", () => {
|
|
255
|
+
const result = resolveRepos("Plain description", [], config);
|
|
256
|
+
expect(result.source).toBe("config_default");
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
134
260
|
describe("validateRepoPath", () => {
|
|
135
261
|
beforeEach(() => {
|
|
136
262
|
vi.restoreAllMocks();
|
package/src/infra/multi-repo.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* multi-repo.ts — Multi-repo resolution for dispatches spanning multiple git repos.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Four-tier resolution:
|
|
5
5
|
* 1. Issue body markers: <!-- repos: api, frontend --> or [repos: api, frontend]
|
|
6
6
|
* 2. Linear labels: repo:api, repo:frontend
|
|
7
|
-
* 3.
|
|
7
|
+
* 3. Team mapping: teamMappings[teamKey].repos from plugin config
|
|
8
|
+
* 4. Config default: Falls back to single codexBaseRepo
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import { existsSync, statSync } from "node:fs";
|
|
@@ -18,7 +19,55 @@ export interface RepoConfig {
|
|
|
18
19
|
|
|
19
20
|
export interface RepoResolution {
|
|
20
21
|
repos: RepoConfig[];
|
|
21
|
-
source: "issue_body" | "labels" | "config_default";
|
|
22
|
+
source: "issue_body" | "labels" | "team_mapping" | "config_default";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Enriched repo entry — filesystem path + optional GitHub identity.
|
|
27
|
+
* Supports both plain string paths (backward compat) and objects.
|
|
28
|
+
*/
|
|
29
|
+
export interface RepoEntry {
|
|
30
|
+
path: string;
|
|
31
|
+
github?: string; // "owner/repo" format
|
|
32
|
+
hostname?: string; // defaults to "github.com"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse the repos config, normalizing both string and object formats.
|
|
37
|
+
* String values become { path: value }, objects pass through.
|
|
38
|
+
*/
|
|
39
|
+
export function getRepoEntries(pluginConfig?: Record<string, unknown>): Record<string, RepoEntry> {
|
|
40
|
+
const repos = pluginConfig?.repos as Record<string, string | Record<string, unknown>> | undefined;
|
|
41
|
+
if (!repos) return {};
|
|
42
|
+
const result: Record<string, RepoEntry> = {};
|
|
43
|
+
for (const [name, value] of Object.entries(repos)) {
|
|
44
|
+
if (typeof value === "string") {
|
|
45
|
+
result[name] = { path: value };
|
|
46
|
+
} else if (value && typeof value === "object") {
|
|
47
|
+
result[name] = {
|
|
48
|
+
path: (value as any).path as string,
|
|
49
|
+
github: (value as any).github as string | undefined,
|
|
50
|
+
hostname: (value as any).hostname as string | undefined,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build candidate repositories for Linear's issueRepositorySuggestions API.
|
|
59
|
+
* Extracts GitHub identity from enriched repo entries.
|
|
60
|
+
*/
|
|
61
|
+
export function buildCandidateRepositories(
|
|
62
|
+
pluginConfig?: Record<string, unknown>,
|
|
63
|
+
): Array<{ hostname: string; repositoryFullName: string }> {
|
|
64
|
+
const entries = getRepoEntries(pluginConfig);
|
|
65
|
+
return Object.values(entries)
|
|
66
|
+
.filter(e => e.github)
|
|
67
|
+
.map(e => ({
|
|
68
|
+
hostname: e.hostname ?? "github.com",
|
|
69
|
+
repositoryFullName: e.github!,
|
|
70
|
+
}));
|
|
22
71
|
}
|
|
23
72
|
|
|
24
73
|
/**
|
|
@@ -28,6 +77,7 @@ export function resolveRepos(
|
|
|
28
77
|
description: string | null | undefined,
|
|
29
78
|
labels: string[],
|
|
30
79
|
pluginConfig?: Record<string, unknown>,
|
|
80
|
+
teamKey?: string,
|
|
31
81
|
): RepoResolution {
|
|
32
82
|
// 1. Check issue body for repo markers
|
|
33
83
|
// Match: <!-- repos: name1, name2 --> or [repos: name1, name2]
|
|
@@ -62,7 +112,21 @@ export function resolveRepos(
|
|
|
62
112
|
return { repos, source: "labels" };
|
|
63
113
|
}
|
|
64
114
|
|
|
65
|
-
// 3.
|
|
115
|
+
// 3. Team mapping: teamMappings[teamKey].repos
|
|
116
|
+
if (teamKey) {
|
|
117
|
+
const teamMappings = pluginConfig?.teamMappings as Record<string, Record<string, unknown>> | undefined;
|
|
118
|
+
const teamRepoNames = teamMappings?.[teamKey]?.repos as string[] | undefined;
|
|
119
|
+
if (teamRepoNames && teamRepoNames.length > 0) {
|
|
120
|
+
const repoMap = getRepoMap(pluginConfig);
|
|
121
|
+
const repos = teamRepoNames.map(name => ({
|
|
122
|
+
name,
|
|
123
|
+
path: repoMap[name] ?? resolveRepoPath(name, pluginConfig),
|
|
124
|
+
}));
|
|
125
|
+
return { repos, source: "team_mapping" };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 4. Config default: single repo
|
|
66
130
|
const baseRepo = (pluginConfig?.codexBaseRepo as string) ?? path.join(homedir(), "ai-workspace");
|
|
67
131
|
return {
|
|
68
132
|
repos: [{ name: "default", path: baseRepo }],
|
|
@@ -71,8 +135,12 @@ export function resolveRepos(
|
|
|
71
135
|
}
|
|
72
136
|
|
|
73
137
|
function getRepoMap(pluginConfig?: Record<string, unknown>): Record<string, string> {
|
|
74
|
-
const
|
|
75
|
-
|
|
138
|
+
const entries = getRepoEntries(pluginConfig);
|
|
139
|
+
const result: Record<string, string> = {};
|
|
140
|
+
for (const [name, entry] of Object.entries(entries)) {
|
|
141
|
+
result[name] = entry.path;
|
|
142
|
+
}
|
|
143
|
+
return result;
|
|
76
144
|
}
|
|
77
145
|
|
|
78
146
|
function resolveRepoPath(name: string, pluginConfig?: Record<string, unknown>): string {
|