@calltelemetry/openclaw-linear 0.8.1 → 0.8.3
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 +28 -2
- package/openclaw.plugin.json +1 -1
- 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 +570 -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/webhook-dedup.test.ts +466 -0
- package/src/pipeline/webhook.ts +218 -264
- package/src/tools/tools.test.ts +100 -0
|
@@ -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"));
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* webhook-provision.test.ts — Unit tests for webhook auto-provisioning.
|
|
3
|
+
*
|
|
4
|
+
* Tests getWebhookStatus() and provisionWebhook() with inline mock objects.
|
|
5
|
+
* No vi.mock needed — both functions accept linearApi as a parameter.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it, vi } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
getWebhookStatus,
|
|
10
|
+
provisionWebhook,
|
|
11
|
+
REQUIRED_RESOURCE_TYPES,
|
|
12
|
+
WEBHOOK_LABEL,
|
|
13
|
+
} from "./webhook-provision.js";
|
|
14
|
+
|
|
15
|
+
// ── Helpers ────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
const TEST_URL = "https://example.com/linear/webhook";
|
|
18
|
+
|
|
19
|
+
function makeWebhook(overrides?: Record<string, unknown>) {
|
|
20
|
+
return {
|
|
21
|
+
id: "wh-1",
|
|
22
|
+
label: WEBHOOK_LABEL,
|
|
23
|
+
url: TEST_URL,
|
|
24
|
+
enabled: true,
|
|
25
|
+
resourceTypes: ["Comment", "Issue"],
|
|
26
|
+
allPublicTeams: true,
|
|
27
|
+
team: null,
|
|
28
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
29
|
+
...overrides,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeMockApi(overrides?: Record<string, unknown>) {
|
|
34
|
+
return {
|
|
35
|
+
listWebhooks: vi.fn().mockResolvedValue([]),
|
|
36
|
+
createWebhook: vi.fn().mockResolvedValue({ id: "new-wh", enabled: true }),
|
|
37
|
+
updateWebhook: vi.fn().mockResolvedValue(true),
|
|
38
|
+
deleteWebhook: vi.fn().mockResolvedValue(true),
|
|
39
|
+
...overrides,
|
|
40
|
+
} as any;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Tests ──────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
describe("REQUIRED_RESOURCE_TYPES", () => {
|
|
46
|
+
it("contains exactly Comment and Issue", () => {
|
|
47
|
+
expect([...REQUIRED_RESOURCE_TYPES]).toEqual(["Comment", "Issue"]);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("getWebhookStatus", () => {
|
|
52
|
+
it("returns null when no webhook matches URL", async () => {
|
|
53
|
+
const api = makeMockApi({
|
|
54
|
+
listWebhooks: vi.fn().mockResolvedValue([
|
|
55
|
+
makeWebhook({ url: "https://other.com/webhook" }),
|
|
56
|
+
]),
|
|
57
|
+
});
|
|
58
|
+
const status = await getWebhookStatus(api, TEST_URL);
|
|
59
|
+
expect(status).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("returns status with no issues when webhook is correctly configured", async () => {
|
|
63
|
+
const api = makeMockApi({
|
|
64
|
+
listWebhooks: vi.fn().mockResolvedValue([makeWebhook()]),
|
|
65
|
+
});
|
|
66
|
+
const status = await getWebhookStatus(api, TEST_URL);
|
|
67
|
+
expect(status).not.toBeNull();
|
|
68
|
+
expect(status!.id).toBe("wh-1");
|
|
69
|
+
expect(status!.issues).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("reports disabled webhook", async () => {
|
|
73
|
+
const api = makeMockApi({
|
|
74
|
+
listWebhooks: vi.fn().mockResolvedValue([
|
|
75
|
+
makeWebhook({ enabled: false }),
|
|
76
|
+
]),
|
|
77
|
+
});
|
|
78
|
+
const status = await getWebhookStatus(api, TEST_URL);
|
|
79
|
+
expect(status!.issues).toContain("disabled");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("reports missing event types", async () => {
|
|
83
|
+
const api = makeMockApi({
|
|
84
|
+
listWebhooks: vi.fn().mockResolvedValue([
|
|
85
|
+
makeWebhook({ resourceTypes: ["Comment"] }),
|
|
86
|
+
]),
|
|
87
|
+
});
|
|
88
|
+
const status = await getWebhookStatus(api, TEST_URL);
|
|
89
|
+
expect(status!.issues.some((i) => i.includes("missing event type: Issue"))).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("reports unnecessary event types", async () => {
|
|
93
|
+
const api = makeMockApi({
|
|
94
|
+
listWebhooks: vi.fn().mockResolvedValue([
|
|
95
|
+
makeWebhook({ resourceTypes: ["Comment", "Issue", "User"] }),
|
|
96
|
+
]),
|
|
97
|
+
});
|
|
98
|
+
const status = await getWebhookStatus(api, TEST_URL);
|
|
99
|
+
expect(status!.issues.some((i) => i.includes("unnecessary event types: User"))).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("provisionWebhook", () => {
|
|
104
|
+
it("creates new webhook when none exists", async () => {
|
|
105
|
+
const api = makeMockApi();
|
|
106
|
+
const result = await provisionWebhook(api, TEST_URL);
|
|
107
|
+
|
|
108
|
+
expect(result.action).toBe("created");
|
|
109
|
+
expect(result.webhookId).toBe("new-wh");
|
|
110
|
+
expect(result.changes).toContain("created new webhook");
|
|
111
|
+
expect(api.createWebhook).toHaveBeenCalledWith(
|
|
112
|
+
expect.objectContaining({
|
|
113
|
+
url: TEST_URL,
|
|
114
|
+
resourceTypes: [...REQUIRED_RESOURCE_TYPES],
|
|
115
|
+
enabled: true,
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("returns already_ok when webhook is correct", async () => {
|
|
121
|
+
const api = makeMockApi({
|
|
122
|
+
listWebhooks: vi.fn().mockResolvedValue([makeWebhook()]),
|
|
123
|
+
});
|
|
124
|
+
const result = await provisionWebhook(api, TEST_URL);
|
|
125
|
+
|
|
126
|
+
expect(result.action).toBe("already_ok");
|
|
127
|
+
expect(result.webhookId).toBe("wh-1");
|
|
128
|
+
expect(api.createWebhook).not.toHaveBeenCalled();
|
|
129
|
+
expect(api.updateWebhook).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("updates webhook to fix issues", async () => {
|
|
133
|
+
const api = makeMockApi({
|
|
134
|
+
listWebhooks: vi.fn().mockResolvedValue([
|
|
135
|
+
makeWebhook({
|
|
136
|
+
enabled: false,
|
|
137
|
+
resourceTypes: ["Comment", "Issue", "User", "Customer"],
|
|
138
|
+
}),
|
|
139
|
+
]),
|
|
140
|
+
});
|
|
141
|
+
const result = await provisionWebhook(api, TEST_URL);
|
|
142
|
+
|
|
143
|
+
expect(result.action).toBe("updated");
|
|
144
|
+
expect(result.webhookId).toBe("wh-1");
|
|
145
|
+
expect(result.changes).toBeDefined();
|
|
146
|
+
expect(result.changes!.some((c) => c.includes("enabled"))).toBe(true);
|
|
147
|
+
expect(result.changes!.some((c) => c.includes("removed event types"))).toBe(true);
|
|
148
|
+
expect(api.updateWebhook).toHaveBeenCalledWith("wh-1", expect.objectContaining({
|
|
149
|
+
enabled: true,
|
|
150
|
+
resourceTypes: [...REQUIRED_RESOURCE_TYPES],
|
|
151
|
+
}));
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("passes teamId option to createWebhook", async () => {
|
|
155
|
+
const api = makeMockApi();
|
|
156
|
+
await provisionWebhook(api, TEST_URL, { teamId: "team-1" });
|
|
157
|
+
|
|
158
|
+
expect(api.createWebhook).toHaveBeenCalledWith(
|
|
159
|
+
expect.objectContaining({ teamId: "team-1" }),
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
});
|