@calltelemetry/openclaw-linear 0.8.2 → 0.8.4
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 +37 -4
- package/package.json +1 -1
- package/src/__test__/fixtures/webhook-payloads.ts +93 -0
- package/src/__test__/smoke-linear-api.test.ts +352 -0
- package/src/__test__/webhook-scenarios.test.ts +631 -0
- package/src/agent/agent.ts +69 -5
- package/src/api/linear-api.test.ts +37 -0
- package/src/api/linear-api.ts +96 -5
- package/src/infra/cli.ts +150 -0
- package/src/infra/doctor.test.ts +17 -2
- package/src/infra/doctor.ts +70 -1
- package/src/infra/webhook-provision.test.ts +162 -0
- package/src/infra/webhook-provision.ts +152 -0
- package/src/pipeline/intent-classify.test.ts +43 -0
- package/src/pipeline/intent-classify.ts +10 -0
- package/src/pipeline/webhook-dedup.test.ts +466 -0
- package/src/pipeline/webhook.ts +372 -112
- package/src/tools/tools.test.ts +100 -0
package/src/agent/agent.ts
CHANGED
|
@@ -68,6 +68,12 @@ export async function runAgent(params: {
|
|
|
68
68
|
message: string;
|
|
69
69
|
timeoutMs?: number;
|
|
70
70
|
streaming?: AgentStreamCallbacks;
|
|
71
|
+
/**
|
|
72
|
+
* Read-only mode: agent keeps read tools (read, glob, grep, web_search,
|
|
73
|
+
* web_fetch) but all write-capable tools are denied via config policy.
|
|
74
|
+
* Subprocess fallback is blocked — only the embedded runner is safe.
|
|
75
|
+
*/
|
|
76
|
+
readOnly?: boolean;
|
|
71
77
|
}): Promise<AgentRunResult> {
|
|
72
78
|
const maxAttempts = 2;
|
|
73
79
|
|
|
@@ -126,8 +132,9 @@ async function runAgentOnce(params: {
|
|
|
126
132
|
message: string;
|
|
127
133
|
timeoutMs?: number;
|
|
128
134
|
streaming?: AgentStreamCallbacks;
|
|
135
|
+
readOnly?: boolean;
|
|
129
136
|
}): Promise<AgentRunResult> {
|
|
130
|
-
const { api, agentId, sessionId, streaming } = params;
|
|
137
|
+
const { api, agentId, sessionId, streaming, readOnly } = params;
|
|
131
138
|
|
|
132
139
|
// Inject current timestamp into every LLM request
|
|
133
140
|
const message = `${buildDateContext()}\n\n${params.message}`;
|
|
@@ -136,24 +143,60 @@ async function runAgentOnce(params: {
|
|
|
136
143
|
const wdConfig = resolveWatchdogConfig(agentId, pluginConfig);
|
|
137
144
|
const timeoutMs = params.timeoutMs ?? wdConfig.maxTotalMs;
|
|
138
145
|
|
|
139
|
-
api.logger.info(`Dispatching agent ${agentId} for session ${sessionId} (timeout=${Math.round(timeoutMs / 1000)}s, inactivity=${Math.round(wdConfig.inactivityMs / 1000)}s)`);
|
|
146
|
+
api.logger.info(`Dispatching agent ${agentId} for session ${sessionId} (timeout=${Math.round(timeoutMs / 1000)}s, inactivity=${Math.round(wdConfig.inactivityMs / 1000)}s${readOnly ? ", mode=READ_ONLY" : ""})`);
|
|
140
147
|
|
|
141
148
|
// Try embedded runner first (has streaming callbacks)
|
|
142
149
|
if (streaming) {
|
|
143
150
|
try {
|
|
144
|
-
return await runEmbedded(api, agentId, sessionId, message, timeoutMs, streaming, wdConfig.inactivityMs);
|
|
151
|
+
return await runEmbedded(api, agentId, sessionId, message, timeoutMs, streaming, wdConfig.inactivityMs, readOnly);
|
|
145
152
|
} catch (err) {
|
|
153
|
+
// Read-only mode MUST NOT fall back to subprocess — subprocess runs a
|
|
154
|
+
// full agent with no way to enforce the tool deny policy.
|
|
155
|
+
if (readOnly) {
|
|
156
|
+
api.logger.error(`Embedded runner failed in read-only mode, refusing subprocess fallback: ${err}`);
|
|
157
|
+
return { success: false, output: "Read-only agent run failed (embedded runner unavailable)." };
|
|
158
|
+
}
|
|
146
159
|
api.logger.warn(`Embedded runner failed, falling back to subprocess: ${err}`);
|
|
147
160
|
}
|
|
148
161
|
}
|
|
149
162
|
|
|
150
163
|
// Fallback: subprocess (no streaming)
|
|
164
|
+
if (readOnly) {
|
|
165
|
+
api.logger.error("Cannot run read-only agent via subprocess — no tool policy enforcement");
|
|
166
|
+
return { success: false, output: "Read-only agent run requires the embedded runner." };
|
|
167
|
+
}
|
|
151
168
|
return runSubprocess(api, agentId, sessionId, message, timeoutMs);
|
|
152
169
|
}
|
|
153
170
|
|
|
154
171
|
/**
|
|
155
172
|
* Embedded agent runner with real-time streaming to Linear and inactivity watchdog.
|
|
156
173
|
*/
|
|
174
|
+
// Tools denied in read-only mode. Uses OpenClaw group:* shorthands where
|
|
175
|
+
// possible (see https://docs.openclaw.ai/tools). Covers every built-in
|
|
176
|
+
// tool that can mutate the filesystem, execute commands, or produce
|
|
177
|
+
// side-effects beyond the Linear API calls the plugin makes after the run.
|
|
178
|
+
//
|
|
179
|
+
// NOT denied (read-only tools the triage agent keeps):
|
|
180
|
+
// read, glob, grep/search — codebase inspection
|
|
181
|
+
// group:web (web_search, web_fetch) — external context
|
|
182
|
+
// group:memory (memory_search/get) — knowledge retrieval
|
|
183
|
+
// sessions_list, sessions_history — read-only introspection
|
|
184
|
+
const READ_ONLY_DENY: string[] = [
|
|
185
|
+
// group:fs = read + write + edit + apply_patch — but we need read,
|
|
186
|
+
// so deny the write-capable members individually.
|
|
187
|
+
"write", "edit", "apply_patch",
|
|
188
|
+
// Full groups that are entirely write/side-effect oriented:
|
|
189
|
+
"group:runtime", // exec, bash, process
|
|
190
|
+
"group:messaging", // message
|
|
191
|
+
"group:ui", // browser, canvas
|
|
192
|
+
"group:automation", // cron, gateway
|
|
193
|
+
"group:nodes", // nodes
|
|
194
|
+
// Individual tools not covered by a group:
|
|
195
|
+
"sessions_spawn", "sessions_send", // agent orchestration
|
|
196
|
+
"tts", // audio file generation
|
|
197
|
+
"image", // image file generation
|
|
198
|
+
];
|
|
199
|
+
|
|
157
200
|
async function runEmbedded(
|
|
158
201
|
api: OpenClawPluginApi,
|
|
159
202
|
agentId: string,
|
|
@@ -162,12 +205,26 @@ async function runEmbedded(
|
|
|
162
205
|
timeoutMs: number,
|
|
163
206
|
streaming: AgentStreamCallbacks,
|
|
164
207
|
inactivityMs: number,
|
|
208
|
+
readOnly?: boolean,
|
|
165
209
|
): Promise<AgentRunResult> {
|
|
166
210
|
const ext = await getExtensionAPI();
|
|
167
211
|
|
|
168
212
|
// Load config so we can resolve agent dirs and providers correctly.
|
|
169
|
-
|
|
170
|
-
|
|
213
|
+
let config = await api.runtime.config.loadConfig();
|
|
214
|
+
let configAny = config as Record<string, any>;
|
|
215
|
+
|
|
216
|
+
// ── Read-only enforcement ──────────────────────────────────────────
|
|
217
|
+
// Clone the config and inject a tools.deny policy that strips every
|
|
218
|
+
// write-capable tool. The deny list is merged with any existing deny
|
|
219
|
+
// entries so we don't clobber operator-level restrictions.
|
|
220
|
+
if (readOnly) {
|
|
221
|
+
configAny = JSON.parse(JSON.stringify(configAny));
|
|
222
|
+
config = configAny as typeof config;
|
|
223
|
+
if (!configAny.tools) configAny.tools = {};
|
|
224
|
+
const existing: string[] = Array.isArray(configAny.tools.deny) ? configAny.tools.deny : [];
|
|
225
|
+
configAny.tools.deny = [...new Set([...existing, ...READ_ONLY_DENY])];
|
|
226
|
+
api.logger.info(`Read-only mode: tools.deny = [${configAny.tools.deny.join(", ")}]`);
|
|
227
|
+
}
|
|
171
228
|
|
|
172
229
|
// Resolve workspace and agent dirs from config (ext API ignores agentId).
|
|
173
230
|
const dirs = resolveAgentDirs(agentId, configAny);
|
|
@@ -233,6 +290,13 @@ async function runEmbedded(
|
|
|
233
290
|
abortSignal: controller.signal,
|
|
234
291
|
shouldEmitToolResult: () => true,
|
|
235
292
|
shouldEmitToolOutput: () => true,
|
|
293
|
+
...(readOnly ? {
|
|
294
|
+
extraSystemPrompt: [
|
|
295
|
+
"READ-ONLY MODE: You may read and search files but you MUST NOT",
|
|
296
|
+
"write, edit, create, or delete any files. Do not run shell commands.",
|
|
297
|
+
"Your only output is your text response.",
|
|
298
|
+
].join(" "),
|
|
299
|
+
} : {}),
|
|
236
300
|
|
|
237
301
|
// Stream reasoning/thinking to Linear
|
|
238
302
|
onReasoningStream: (payload) => {
|
|
@@ -232,6 +232,43 @@ describe("LinearAgentApi", () => {
|
|
|
232
232
|
);
|
|
233
233
|
});
|
|
234
234
|
|
|
235
|
+
it("returns data when GraphQL errors and data coexist (partial success)", async () => {
|
|
236
|
+
// Simulates createAsUser returning warnings alongside valid comment data.
|
|
237
|
+
// This is the root cause of Bug 2 in API-477: gql() used to throw on
|
|
238
|
+
// any errors, even when the mutation succeeded and data was present.
|
|
239
|
+
fetchMock.mockResolvedValueOnce({
|
|
240
|
+
ok: true,
|
|
241
|
+
status: 200,
|
|
242
|
+
json: () =>
|
|
243
|
+
Promise.resolve({
|
|
244
|
+
data: {
|
|
245
|
+
commentCreate: { success: true, comment: { id: "c-partial" } },
|
|
246
|
+
},
|
|
247
|
+
errors: [{ message: "createAsUser: user not found, using default" }],
|
|
248
|
+
}),
|
|
249
|
+
text: () => Promise.resolve(""),
|
|
250
|
+
headers: new Headers(),
|
|
251
|
+
} as unknown as Response);
|
|
252
|
+
|
|
253
|
+
const api = new LinearAgentApi(TOKEN);
|
|
254
|
+
const id = await api.createComment("issue-1", "test body", {
|
|
255
|
+
createAsUser: "NonexistentUser",
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
expect(id).toBe("c-partial");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("still throws on GraphQL errors when no data is present", async () => {
|
|
262
|
+
fetchMock.mockResolvedValueOnce(
|
|
263
|
+
gqlErrorResponse([{ message: "Totally broken" }]),
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const api = new LinearAgentApi(TOKEN);
|
|
267
|
+
await expect(
|
|
268
|
+
api.createComment("issue-1", "test body"),
|
|
269
|
+
).rejects.toThrow(/Linear GraphQL/);
|
|
270
|
+
});
|
|
271
|
+
|
|
235
272
|
it("retries on 401 when refresh token is available", async () => {
|
|
236
273
|
// First call (via withResilience): 401
|
|
237
274
|
fetchMock.mockResolvedValueOnce(errorResponse(401, "Unauthorized"));
|
package/src/api/linear-api.ts
CHANGED
|
@@ -193,7 +193,7 @@ export class LinearAgentApi {
|
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
const payload = await retryRes.json();
|
|
196
|
-
if (payload.errors?.length) {
|
|
196
|
+
if (payload.errors?.length && !payload.data) {
|
|
197
197
|
throw new Error(`Linear GraphQL: ${JSON.stringify(payload.errors)}`);
|
|
198
198
|
}
|
|
199
199
|
return payload.data as T;
|
|
@@ -205,7 +205,7 @@ export class LinearAgentApi {
|
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
const payload = await res.json();
|
|
208
|
-
if (payload.errors?.length) {
|
|
208
|
+
if (payload.errors?.length && !payload.data) {
|
|
209
209
|
throw new Error(`Linear GraphQL: ${JSON.stringify(payload.errors)}`);
|
|
210
210
|
}
|
|
211
211
|
|
|
@@ -306,7 +306,7 @@ export class LinearAgentApi {
|
|
|
306
306
|
title: string;
|
|
307
307
|
description: string | null;
|
|
308
308
|
estimate: number | null;
|
|
309
|
-
state: { name: string };
|
|
309
|
+
state: { name: string; type: string };
|
|
310
310
|
assignee: { name: string } | null;
|
|
311
311
|
labels: { nodes: Array<{ id: string; name: string }> };
|
|
312
312
|
team: { id: string; name: string; issueEstimationType: string };
|
|
@@ -323,7 +323,7 @@ export class LinearAgentApi {
|
|
|
323
323
|
title
|
|
324
324
|
description
|
|
325
325
|
estimate
|
|
326
|
-
state { name }
|
|
326
|
+
state { name type }
|
|
327
327
|
assignee { name }
|
|
328
328
|
labels { nodes { id name } }
|
|
329
329
|
team { id name issueEstimationType }
|
|
@@ -533,7 +533,7 @@ export class LinearAgentApi {
|
|
|
533
533
|
}>(
|
|
534
534
|
`query TeamStates($id: String!) {
|
|
535
535
|
team(id: $id) {
|
|
536
|
-
states
|
|
536
|
+
states { nodes { id name type } }
|
|
537
537
|
}
|
|
538
538
|
}`,
|
|
539
539
|
{ id: teamId },
|
|
@@ -591,4 +591,95 @@ export class LinearAgentApi {
|
|
|
591
591
|
);
|
|
592
592
|
return data.notifications.nodes as any;
|
|
593
593
|
}
|
|
594
|
+
|
|
595
|
+
// ---------------------------------------------------------------------------
|
|
596
|
+
// Webhook management
|
|
597
|
+
// ---------------------------------------------------------------------------
|
|
598
|
+
|
|
599
|
+
async listWebhooks(): Promise<Array<{
|
|
600
|
+
id: string;
|
|
601
|
+
label: string | null;
|
|
602
|
+
url: string;
|
|
603
|
+
enabled: boolean;
|
|
604
|
+
resourceTypes: string[];
|
|
605
|
+
allPublicTeams: boolean;
|
|
606
|
+
team: { id: string; name: string } | null;
|
|
607
|
+
createdAt: string;
|
|
608
|
+
}>> {
|
|
609
|
+
const data = await this.gql<{
|
|
610
|
+
webhooks: { nodes: unknown[] };
|
|
611
|
+
}>(
|
|
612
|
+
`query Webhooks {
|
|
613
|
+
webhooks {
|
|
614
|
+
nodes {
|
|
615
|
+
id
|
|
616
|
+
label
|
|
617
|
+
url
|
|
618
|
+
enabled
|
|
619
|
+
resourceTypes
|
|
620
|
+
allPublicTeams
|
|
621
|
+
team { id name }
|
|
622
|
+
createdAt
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}`,
|
|
626
|
+
);
|
|
627
|
+
return data.webhooks.nodes as any;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async createWebhook(input: {
|
|
631
|
+
url: string;
|
|
632
|
+
resourceTypes: string[];
|
|
633
|
+
label?: string;
|
|
634
|
+
teamId?: string;
|
|
635
|
+
allPublicTeams?: boolean;
|
|
636
|
+
enabled?: boolean;
|
|
637
|
+
secret?: string;
|
|
638
|
+
}): Promise<{ id: string; enabled: boolean }> {
|
|
639
|
+
const data = await this.gql<{
|
|
640
|
+
webhookCreate: { success: boolean; webhook: { id: string; enabled: boolean } };
|
|
641
|
+
}>(
|
|
642
|
+
`mutation WebhookCreate($input: WebhookCreateInput!) {
|
|
643
|
+
webhookCreate(input: $input) {
|
|
644
|
+
success
|
|
645
|
+
webhook { id enabled }
|
|
646
|
+
}
|
|
647
|
+
}`,
|
|
648
|
+
{ input },
|
|
649
|
+
);
|
|
650
|
+
return data.webhookCreate.webhook;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async updateWebhook(webhookId: string, input: {
|
|
654
|
+
url?: string;
|
|
655
|
+
resourceTypes?: string[];
|
|
656
|
+
label?: string;
|
|
657
|
+
enabled?: boolean;
|
|
658
|
+
}): Promise<boolean> {
|
|
659
|
+
const data = await this.gql<{
|
|
660
|
+
webhookUpdate: { success: boolean };
|
|
661
|
+
}>(
|
|
662
|
+
`mutation WebhookUpdate($id: String!, $input: WebhookUpdateInput!) {
|
|
663
|
+
webhookUpdate(id: $id, input: $input) {
|
|
664
|
+
success
|
|
665
|
+
}
|
|
666
|
+
}`,
|
|
667
|
+
{ id: webhookId, input },
|
|
668
|
+
);
|
|
669
|
+
return data.webhookUpdate.success;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async deleteWebhook(webhookId: string): Promise<boolean> {
|
|
673
|
+
const data = await this.gql<{
|
|
674
|
+
webhookDelete: { success: boolean };
|
|
675
|
+
}>(
|
|
676
|
+
`mutation WebhookDelete($id: String!) {
|
|
677
|
+
webhookDelete(id: $id) {
|
|
678
|
+
success
|
|
679
|
+
}
|
|
680
|
+
}`,
|
|
681
|
+
{ id: webhookId },
|
|
682
|
+
);
|
|
683
|
+
return data.webhookDelete.success;
|
|
684
|
+
}
|
|
594
685
|
}
|
package/src/infra/cli.ts
CHANGED
|
@@ -644,6 +644,156 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
|
|
|
644
644
|
process.exitCode = 1;
|
|
645
645
|
}
|
|
646
646
|
});
|
|
647
|
+
|
|
648
|
+
// --- openclaw openclaw-linear webhooks ---
|
|
649
|
+
const webhooksCmd = linear
|
|
650
|
+
.command("webhooks")
|
|
651
|
+
.description("Manage Linear webhook subscriptions");
|
|
652
|
+
|
|
653
|
+
webhooksCmd
|
|
654
|
+
.command("status")
|
|
655
|
+
.description("Show current webhook configuration in Linear")
|
|
656
|
+
.action(async () => {
|
|
657
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
658
|
+
const tokenInfo = resolveLinearToken(pluginConfig);
|
|
659
|
+
if (!tokenInfo.accessToken) {
|
|
660
|
+
console.error("\n No Linear token found. Run \"openclaw openclaw-linear auth\" first.\n");
|
|
661
|
+
process.exitCode = 1;
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const linearApi = new LinearAgentApi(tokenInfo.accessToken, {
|
|
666
|
+
refreshToken: tokenInfo.refreshToken,
|
|
667
|
+
expiresAt: tokenInfo.expiresAt,
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
console.log("\nLinear Webhooks");
|
|
671
|
+
console.log("─".repeat(50));
|
|
672
|
+
|
|
673
|
+
const webhooks = await linearApi.listWebhooks();
|
|
674
|
+
if (webhooks.length === 0) {
|
|
675
|
+
console.log("\n No webhooks found.");
|
|
676
|
+
console.log(" Run \"openclaw openclaw-linear webhooks setup\" to create one.\n");
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
for (const wh of webhooks) {
|
|
681
|
+
const status = wh.enabled ? "enabled" : "DISABLED";
|
|
682
|
+
const label = wh.label ?? "(no label)";
|
|
683
|
+
const team = wh.team ? ` (team: ${wh.team.name})` : " (all teams)";
|
|
684
|
+
console.log(`\n ${label}${team}`);
|
|
685
|
+
console.log(` ID: ${wh.id}`);
|
|
686
|
+
console.log(` URL: ${wh.url}`);
|
|
687
|
+
console.log(` Status: ${status}`);
|
|
688
|
+
console.log(` Events: ${wh.resourceTypes.join(", ")}`);
|
|
689
|
+
console.log(` Created: ${wh.createdAt}`);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
console.log();
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
webhooksCmd
|
|
696
|
+
.command("setup")
|
|
697
|
+
.description("Auto-provision or fix the workspace webhook (create/update as needed)")
|
|
698
|
+
.option("--url <url>", "Webhook URL (default: from Cloudflare tunnel config)")
|
|
699
|
+
.option("--team <id>", "Restrict to a specific team ID (default: all public teams)")
|
|
700
|
+
.option("--dry-run", "Show what would change without making changes")
|
|
701
|
+
.action(async (opts: { url?: string; team?: string; dryRun?: boolean }) => {
|
|
702
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
703
|
+
const { provisionWebhook, getWebhookStatus, REQUIRED_RESOURCE_TYPES } = await import("./webhook-provision.js");
|
|
704
|
+
|
|
705
|
+
const tokenInfo = resolveLinearToken(pluginConfig);
|
|
706
|
+
if (!tokenInfo.accessToken) {
|
|
707
|
+
console.error("\n No Linear token found. Run \"openclaw openclaw-linear auth\" first.\n");
|
|
708
|
+
process.exitCode = 1;
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const linearApi = new LinearAgentApi(tokenInfo.accessToken, {
|
|
713
|
+
refreshToken: tokenInfo.refreshToken,
|
|
714
|
+
expiresAt: tokenInfo.expiresAt,
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
const webhookUrl = opts.url
|
|
718
|
+
?? (pluginConfig?.webhookUrl as string)
|
|
719
|
+
?? "https://linear.calltelemetry.com/linear/webhook";
|
|
720
|
+
|
|
721
|
+
console.log("\nWebhook Provisioning");
|
|
722
|
+
console.log("─".repeat(50));
|
|
723
|
+
console.log(` URL: ${webhookUrl}`);
|
|
724
|
+
console.log(` Events: ${[...REQUIRED_RESOURCE_TYPES].join(", ")}`);
|
|
725
|
+
|
|
726
|
+
if (opts.dryRun) {
|
|
727
|
+
const status = await getWebhookStatus(linearApi, webhookUrl);
|
|
728
|
+
if (!status) {
|
|
729
|
+
console.log("\n Would CREATE a new webhook with the above config.");
|
|
730
|
+
} else if (status.issues.length === 0) {
|
|
731
|
+
console.log("\n Webhook already configured correctly. No changes needed.");
|
|
732
|
+
} else {
|
|
733
|
+
console.log("\n Would UPDATE existing webhook:");
|
|
734
|
+
for (const issue of status.issues) {
|
|
735
|
+
console.log(` - Fix: ${issue}`);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
console.log();
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const result = await provisionWebhook(linearApi, webhookUrl, {
|
|
743
|
+
teamId: opts.team,
|
|
744
|
+
allPublicTeams: !opts.team,
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
switch (result.action) {
|
|
748
|
+
case "created":
|
|
749
|
+
console.log(`\n Created webhook: ${result.webhookId}`);
|
|
750
|
+
break;
|
|
751
|
+
case "updated":
|
|
752
|
+
console.log(`\n Updated webhook: ${result.webhookId}`);
|
|
753
|
+
for (const change of result.changes ?? []) {
|
|
754
|
+
console.log(` - ${change}`);
|
|
755
|
+
}
|
|
756
|
+
break;
|
|
757
|
+
case "already_ok":
|
|
758
|
+
console.log(`\n Webhook already configured correctly (${result.webhookId}). No changes needed.`);
|
|
759
|
+
break;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
console.log();
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
webhooksCmd
|
|
766
|
+
.command("delete")
|
|
767
|
+
.description("Delete a webhook by ID")
|
|
768
|
+
.argument("<webhook-id>", "ID of the webhook to delete")
|
|
769
|
+
.action(async (webhookId: string) => {
|
|
770
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
771
|
+
const tokenInfo = resolveLinearToken(pluginConfig);
|
|
772
|
+
if (!tokenInfo.accessToken) {
|
|
773
|
+
console.error("\n No Linear token found.\n");
|
|
774
|
+
process.exitCode = 1;
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const linearApi = new LinearAgentApi(tokenInfo.accessToken, {
|
|
779
|
+
refreshToken: tokenInfo.refreshToken,
|
|
780
|
+
expiresAt: tokenInfo.expiresAt,
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
const confirmAnswer = await prompt(`Delete webhook ${webhookId}? [y/N]: `);
|
|
784
|
+
if (confirmAnswer.toLowerCase() !== "y") {
|
|
785
|
+
console.log(" Aborted.\n");
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const success = await linearApi.deleteWebhook(webhookId);
|
|
790
|
+
if (success) {
|
|
791
|
+
console.log(`\n Deleted webhook ${webhookId}\n`);
|
|
792
|
+
} else {
|
|
793
|
+
console.error(`\n Failed to delete webhook ${webhookId}\n`);
|
|
794
|
+
process.exitCode = 1;
|
|
795
|
+
}
|
|
796
|
+
});
|
|
647
797
|
}
|
|
648
798
|
|
|
649
799
|
// ---------------------------------------------------------------------------
|
package/src/infra/doctor.test.ts
CHANGED
|
@@ -11,6 +11,9 @@ vi.mock("../api/linear-api.js", () => ({
|
|
|
11
11
|
expiresAt: Date.now() + 24 * 3_600_000,
|
|
12
12
|
source: "profile" as const,
|
|
13
13
|
})),
|
|
14
|
+
LinearAgentApi: class MockLinearAgentApi {
|
|
15
|
+
constructor() {}
|
|
16
|
+
},
|
|
14
17
|
AUTH_PROFILES_PATH: "/tmp/test-auth-profiles.json",
|
|
15
18
|
LINEAR_GRAPHQL_URL: "https://api.linear.app/graphql",
|
|
16
19
|
}));
|
|
@@ -57,6 +60,16 @@ vi.mock("../tools/code-tool.js", () => ({
|
|
|
57
60
|
})),
|
|
58
61
|
}));
|
|
59
62
|
|
|
63
|
+
vi.mock("./webhook-provision.js", () => ({
|
|
64
|
+
getWebhookStatus: vi.fn(async () => null),
|
|
65
|
+
provisionWebhook: vi.fn(async () => ({
|
|
66
|
+
action: "created",
|
|
67
|
+
webhookId: "wh-test-1",
|
|
68
|
+
changes: ["created new webhook"],
|
|
69
|
+
})),
|
|
70
|
+
REQUIRED_RESOURCE_TYPES: ["Comment", "Issue"],
|
|
71
|
+
}));
|
|
72
|
+
|
|
60
73
|
import {
|
|
61
74
|
checkAuth,
|
|
62
75
|
checkAgentConfig,
|
|
@@ -64,6 +77,7 @@ import {
|
|
|
64
77
|
checkFilesAndDirs,
|
|
65
78
|
checkConnectivity,
|
|
66
79
|
checkDispatchHealth,
|
|
80
|
+
checkWebhooks,
|
|
67
81
|
runDoctor,
|
|
68
82
|
formatReport,
|
|
69
83
|
formatReportJson,
|
|
@@ -330,7 +344,7 @@ describe("checkDispatchHealth", () => {
|
|
|
330
344
|
// ---------------------------------------------------------------------------
|
|
331
345
|
|
|
332
346
|
describe("runDoctor", () => {
|
|
333
|
-
it("returns all
|
|
347
|
+
it("returns all 7 sections", async () => {
|
|
334
348
|
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
|
335
349
|
if (url.includes("linear.app")) {
|
|
336
350
|
return {
|
|
@@ -342,7 +356,7 @@ describe("runDoctor", () => {
|
|
|
342
356
|
}));
|
|
343
357
|
|
|
344
358
|
const report = await runDoctor({ fix: false, json: false });
|
|
345
|
-
expect(report.sections).toHaveLength(
|
|
359
|
+
expect(report.sections).toHaveLength(7);
|
|
346
360
|
expect(report.sections.map((s) => s.name)).toEqual([
|
|
347
361
|
"Authentication & Tokens",
|
|
348
362
|
"Agent Configuration",
|
|
@@ -350,6 +364,7 @@ describe("runDoctor", () => {
|
|
|
350
364
|
"Files & Directories",
|
|
351
365
|
"Connectivity",
|
|
352
366
|
"Dispatch Health",
|
|
367
|
+
"Webhook Configuration",
|
|
353
368
|
]);
|
|
354
369
|
expect(report.summary.passed + report.summary.warnings + report.summary.errors).toBeGreaterThan(0);
|
|
355
370
|
});
|
package/src/infra/doctor.ts
CHANGED
|
@@ -8,11 +8,12 @@ import { join } from "node:path";
|
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { execFileSync } from "node:child_process";
|
|
10
10
|
|
|
11
|
-
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
12
|
import { readDispatchState, listActiveDispatches, listStaleDispatches, pruneCompleted, type DispatchState } from "../pipeline/dispatch-state.js";
|
|
13
13
|
import { loadPrompts, clearPromptCache } from "../pipeline/pipeline.js";
|
|
14
14
|
import { listWorktrees } from "./codex-worktree.js";
|
|
15
15
|
import { loadCodingConfig, type CodingBackend } from "../tools/code-tool.js";
|
|
16
|
+
import { getWebhookStatus, provisionWebhook, REQUIRED_RESOURCE_TYPES } from "./webhook-provision.js";
|
|
16
17
|
|
|
17
18
|
// ---------------------------------------------------------------------------
|
|
18
19
|
// Types
|
|
@@ -650,6 +651,68 @@ export async function checkDispatchHealth(pluginConfig?: Record<string, unknown>
|
|
|
650
651
|
return checks;
|
|
651
652
|
}
|
|
652
653
|
|
|
654
|
+
// ---------------------------------------------------------------------------
|
|
655
|
+
// Section 7: Webhook Configuration
|
|
656
|
+
// ---------------------------------------------------------------------------
|
|
657
|
+
|
|
658
|
+
export async function checkWebhooks(pluginConfig?: Record<string, unknown>, fix = false): Promise<CheckResult[]> {
|
|
659
|
+
const checks: CheckResult[] = [];
|
|
660
|
+
|
|
661
|
+
const tokenInfo = resolveLinearToken(pluginConfig);
|
|
662
|
+
if (!tokenInfo.accessToken) {
|
|
663
|
+
checks.push(warn("Webhook check skipped (no Linear token)"));
|
|
664
|
+
return checks;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const linearApi = new LinearAgentApi(tokenInfo.accessToken, {
|
|
668
|
+
refreshToken: tokenInfo.refreshToken,
|
|
669
|
+
expiresAt: tokenInfo.expiresAt,
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
const webhookUrl = (pluginConfig?.webhookUrl as string)
|
|
673
|
+
?? "https://linear.calltelemetry.com/linear/webhook";
|
|
674
|
+
|
|
675
|
+
try {
|
|
676
|
+
const status = await getWebhookStatus(linearApi, webhookUrl);
|
|
677
|
+
|
|
678
|
+
if (!status) {
|
|
679
|
+
if (fix) {
|
|
680
|
+
const result = await provisionWebhook(linearApi, webhookUrl, { allPublicTeams: true });
|
|
681
|
+
checks.push(pass(`Workspace webhook created (${result.webhookId}) (--fix)`));
|
|
682
|
+
} else {
|
|
683
|
+
checks.push(fail(
|
|
684
|
+
`No workspace webhook found for ${webhookUrl}`,
|
|
685
|
+
undefined,
|
|
686
|
+
'Run: openclaw openclaw-linear webhooks setup',
|
|
687
|
+
));
|
|
688
|
+
}
|
|
689
|
+
return checks;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (status.issues.length === 0) {
|
|
693
|
+
checks.push(pass(`Workspace webhook OK (${[...REQUIRED_RESOURCE_TYPES].join(", ")})`));
|
|
694
|
+
} else {
|
|
695
|
+
if (fix) {
|
|
696
|
+
const result = await provisionWebhook(linearApi, webhookUrl);
|
|
697
|
+
const changes = result.changes?.join(", ") ?? "fixed";
|
|
698
|
+
checks.push(pass(`Workspace webhook fixed: ${changes} (--fix)`));
|
|
699
|
+
} else {
|
|
700
|
+
for (const issue of status.issues) {
|
|
701
|
+
checks.push(warn(
|
|
702
|
+
`Webhook issue: ${issue}`,
|
|
703
|
+
undefined,
|
|
704
|
+
{ fixable: true, fix: 'Run: openclaw openclaw-linear webhooks setup' },
|
|
705
|
+
));
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
} catch (err) {
|
|
710
|
+
checks.push(warn(`Webhook check failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return checks;
|
|
714
|
+
}
|
|
715
|
+
|
|
653
716
|
// ---------------------------------------------------------------------------
|
|
654
717
|
// Main entry point
|
|
655
718
|
// ---------------------------------------------------------------------------
|
|
@@ -685,6 +748,12 @@ export async function runDoctor(opts: DoctorOptions): Promise<DoctorReport> {
|
|
|
685
748
|
checks: await checkDispatchHealth(opts.pluginConfig, opts.fix),
|
|
686
749
|
});
|
|
687
750
|
|
|
751
|
+
// 7. Webhook configuration (auto-fix if --fix)
|
|
752
|
+
sections.push({
|
|
753
|
+
name: "Webhook Configuration",
|
|
754
|
+
checks: await checkWebhooks(opts.pluginConfig, opts.fix),
|
|
755
|
+
});
|
|
756
|
+
|
|
688
757
|
// Fix: chmod auth-profiles.json if needed
|
|
689
758
|
if (opts.fix) {
|
|
690
759
|
const permCheck = auth.checks.find((c) => c.fixable && c.label.includes("permissions"));
|