@calltelemetry/openclaw-linear 0.2.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/README.md +468 -0
- package/index.ts +56 -0
- package/openclaw.plugin.json +17 -0
- package/package.json +38 -0
- package/src/agent.ts +57 -0
- package/src/auth.ts +130 -0
- package/src/client.ts +93 -0
- package/src/linear-api.ts +384 -0
- package/src/oauth-callback.ts +113 -0
- package/src/pipeline.ts +212 -0
- package/src/tools.ts +84 -0
- package/src/webhook.test.ts +191 -0
- package/src/webhook.ts +852 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
3
|
+
import { writeFileSync, readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const LINEAR_OAUTH_TOKEN_URL = "https://api.linear.app/oauth/token";
|
|
7
|
+
const AUTH_PROFILES_PATH = join(
|
|
8
|
+
process.env.HOME ?? "/home/claw",
|
|
9
|
+
".openclaw",
|
|
10
|
+
"auth-profiles.json",
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
export async function handleOAuthCallback(
|
|
14
|
+
api: OpenClawPluginApi,
|
|
15
|
+
req: IncomingMessage,
|
|
16
|
+
res: ServerResponse,
|
|
17
|
+
): Promise<void> {
|
|
18
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
19
|
+
const code = url.searchParams.get("code");
|
|
20
|
+
const error = url.searchParams.get("error");
|
|
21
|
+
|
|
22
|
+
if (error) {
|
|
23
|
+
res.statusCode = 400;
|
|
24
|
+
res.end(`OAuth error: ${error} — ${url.searchParams.get("error_description") ?? ""}`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!code) {
|
|
29
|
+
res.statusCode = 400;
|
|
30
|
+
res.end("Missing authorization code");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const clientId = process.env.LINEAR_CLIENT_ID;
|
|
35
|
+
const clientSecret = process.env.LINEAR_CLIENT_SECRET;
|
|
36
|
+
const redirectUri = process.env.LINEAR_REDIRECT_URI ?? `${req.headers["x-forwarded-proto"] ?? "https"}://${req.headers.host}/linear/oauth/callback`;
|
|
37
|
+
|
|
38
|
+
if (!clientId || !clientSecret) {
|
|
39
|
+
res.statusCode = 500;
|
|
40
|
+
res.end("LINEAR_CLIENT_ID and LINEAR_CLIENT_SECRET must be set");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
api.logger.info("Linear OAuth: exchanging authorization code for token...");
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const tokenRes = await fetch(LINEAR_OAUTH_TOKEN_URL, {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: {
|
|
50
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
51
|
+
Accept: "application/json",
|
|
52
|
+
},
|
|
53
|
+
body: new URLSearchParams({
|
|
54
|
+
grant_type: "authorization_code",
|
|
55
|
+
code,
|
|
56
|
+
client_id: clientId,
|
|
57
|
+
client_secret: clientSecret,
|
|
58
|
+
redirect_uri: redirectUri,
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (!tokenRes.ok) {
|
|
63
|
+
const errText = await tokenRes.text();
|
|
64
|
+
api.logger.error(`Linear OAuth token exchange failed: ${errText}`);
|
|
65
|
+
res.statusCode = 502;
|
|
66
|
+
res.end(`Token exchange failed: ${errText}`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const tokens = await tokenRes.json();
|
|
71
|
+
api.logger.info(`Linear OAuth: token received (expires_in: ${tokens.expires_in}s, scopes: ${tokens.scope})`);
|
|
72
|
+
|
|
73
|
+
// Store in auth profile store
|
|
74
|
+
let store: any = { version: 1, profiles: {} };
|
|
75
|
+
try {
|
|
76
|
+
const raw = readFileSync(AUTH_PROFILES_PATH, "utf8");
|
|
77
|
+
store = JSON.parse(raw);
|
|
78
|
+
} catch {
|
|
79
|
+
// Fresh store
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
store.profiles = store.profiles ?? {};
|
|
83
|
+
store.profiles["linear:default"] = {
|
|
84
|
+
type: "oauth",
|
|
85
|
+
provider: "linear",
|
|
86
|
+
accessToken: tokens.access_token,
|
|
87
|
+
access: tokens.access_token,
|
|
88
|
+
refreshToken: tokens.refresh_token ?? null,
|
|
89
|
+
refresh: tokens.refresh_token ?? null,
|
|
90
|
+
expiresAt: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : null,
|
|
91
|
+
expires: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : null,
|
|
92
|
+
scope: tokens.scope,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
writeFileSync(AUTH_PROFILES_PATH, JSON.stringify(store, null, 2), "utf8");
|
|
96
|
+
api.logger.info("Linear OAuth: token stored in auth profile store");
|
|
97
|
+
|
|
98
|
+
res.statusCode = 200;
|
|
99
|
+
res.setHeader("Content-Type", "text/html");
|
|
100
|
+
res.end(`
|
|
101
|
+
<html><body style="font-family: system-ui; max-width: 600px; margin: 80px auto; text-align: center;">
|
|
102
|
+
<h1>Linear OAuth Complete</h1>
|
|
103
|
+
<p>Access token stored. Scopes: <code>${tokens.scope ?? "unknown"}</code></p>
|
|
104
|
+
<p>The Linear agent pipeline is now active. You can close this tab.</p>
|
|
105
|
+
<p style="color: #888; font-size: 0.9em;">Restart the gateway to pick up the new token.</p>
|
|
106
|
+
</body></html>
|
|
107
|
+
`);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
api.logger.error(`Linear OAuth error: ${err}`);
|
|
110
|
+
res.statusCode = 500;
|
|
111
|
+
res.end(`OAuth error: ${String(err)}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
package/src/pipeline.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { LinearAgentApi, ActivityContent } from "./linear-api.js";
|
|
3
|
+
import { runAgent } from "./agent.js";
|
|
4
|
+
|
|
5
|
+
export interface PipelineContext {
|
|
6
|
+
api: OpenClawPluginApi;
|
|
7
|
+
linearApi: LinearAgentApi;
|
|
8
|
+
agentSessionId: string;
|
|
9
|
+
agentId: string;
|
|
10
|
+
issue: {
|
|
11
|
+
id: string;
|
|
12
|
+
identifier: string;
|
|
13
|
+
title: string;
|
|
14
|
+
description?: string | null;
|
|
15
|
+
};
|
|
16
|
+
promptContext?: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function emit(ctx: PipelineContext, content: ActivityContent): Promise<void> {
|
|
20
|
+
return ctx.linearApi.emitActivity(ctx.agentSessionId, content).catch((err) => {
|
|
21
|
+
ctx.api.logger.error(`Failed to emit activity: ${err}`);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── Stage 1: Planner ───────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export async function runPlannerStage(ctx: PipelineContext): Promise<string | null> {
|
|
28
|
+
await emit(ctx, { type: "thought", body: `Analyzing issue ${ctx.issue.identifier}...` });
|
|
29
|
+
|
|
30
|
+
const issueDetails = await ctx.linearApi.getIssueDetails(ctx.issue.id).catch(() => null);
|
|
31
|
+
|
|
32
|
+
const description = issueDetails?.description ?? ctx.issue.description ?? "(no description)";
|
|
33
|
+
const comments = issueDetails?.comments?.nodes ?? [];
|
|
34
|
+
const commentSummary = comments
|
|
35
|
+
.slice(-5)
|
|
36
|
+
.map((c) => `${c.user?.name ?? "Unknown"}: ${c.body}`)
|
|
37
|
+
.join("\n");
|
|
38
|
+
|
|
39
|
+
const message = `You are a planner agent. Analyze this Linear issue and create an implementation plan.
|
|
40
|
+
|
|
41
|
+
## Issue: ${ctx.issue.identifier} — ${ctx.issue.title}
|
|
42
|
+
|
|
43
|
+
**Description:**
|
|
44
|
+
${description}
|
|
45
|
+
|
|
46
|
+
${commentSummary ? `**Recent comments:**\n${commentSummary}` : ""}
|
|
47
|
+
|
|
48
|
+
${ctx.promptContext ? `**Additional context:**\n${JSON.stringify(ctx.promptContext)}` : ""}
|
|
49
|
+
|
|
50
|
+
## Instructions
|
|
51
|
+
1. Analyze the issue thoroughly
|
|
52
|
+
2. Break it into concrete implementation steps
|
|
53
|
+
3. Identify files that need to change
|
|
54
|
+
4. Note any risks or dependencies
|
|
55
|
+
5. Output your plan in markdown format
|
|
56
|
+
|
|
57
|
+
Output ONLY the plan, nothing else.`;
|
|
58
|
+
|
|
59
|
+
await emit(ctx, { type: "action", action: "Planning", parameter: ctx.issue.identifier });
|
|
60
|
+
|
|
61
|
+
const result = await runAgent({
|
|
62
|
+
api: ctx.api,
|
|
63
|
+
agentId: ctx.agentId,
|
|
64
|
+
sessionId: `linear-plan-${ctx.agentSessionId}`,
|
|
65
|
+
message,
|
|
66
|
+
timeoutMs: 5 * 60_000,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (!result.success) {
|
|
70
|
+
await emit(ctx, { type: "error", body: `Planning failed: ${result.output.slice(0, 500)}` });
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const plan = result.output;
|
|
75
|
+
|
|
76
|
+
// Post plan as a Linear comment
|
|
77
|
+
await ctx.linearApi.createComment(
|
|
78
|
+
ctx.issue.id,
|
|
79
|
+
`## Implementation Plan\n\n${plan}\n\n---\n*Reply to this comment to approve the plan and begin implementation.*`,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
await emit(ctx, {
|
|
83
|
+
type: "elicitation",
|
|
84
|
+
body: "I've posted an implementation plan as a comment. Please review and reply to approve.",
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return plan;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Stage 2: Implementor ──────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
export async function runImplementorStage(
|
|
93
|
+
ctx: PipelineContext,
|
|
94
|
+
plan: string,
|
|
95
|
+
): Promise<string | null> {
|
|
96
|
+
await emit(ctx, { type: "thought", body: "Plan approved. Starting implementation..." });
|
|
97
|
+
|
|
98
|
+
const message = `You are an implementor agent. Execute this plan for issue ${ctx.issue.identifier}.
|
|
99
|
+
|
|
100
|
+
## Issue: ${ctx.issue.identifier} — ${ctx.issue.title}
|
|
101
|
+
|
|
102
|
+
## Approved Plan:
|
|
103
|
+
${plan}
|
|
104
|
+
|
|
105
|
+
## Instructions
|
|
106
|
+
1. Follow the plan step by step
|
|
107
|
+
2. Write the code changes
|
|
108
|
+
3. Create commits for each logical change
|
|
109
|
+
4. If the plan involves creating a PR, do so
|
|
110
|
+
5. Report what you did and any files changed
|
|
111
|
+
|
|
112
|
+
Be thorough but stay within scope of the plan.`;
|
|
113
|
+
|
|
114
|
+
await emit(ctx, { type: "action", action: "Implementing", parameter: ctx.issue.identifier });
|
|
115
|
+
|
|
116
|
+
const result = await runAgent({
|
|
117
|
+
api: ctx.api,
|
|
118
|
+
agentId: ctx.agentId,
|
|
119
|
+
sessionId: `linear-impl-${ctx.agentSessionId}`,
|
|
120
|
+
message,
|
|
121
|
+
timeoutMs: 10 * 60_000,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (!result.success) {
|
|
125
|
+
await emit(ctx, { type: "error", body: `Implementation failed: ${result.output.slice(0, 500)}` });
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
await emit(ctx, { type: "action", action: "Implementation complete", result: "Proceeding to audit" });
|
|
130
|
+
return result.output;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Stage 3: Auditor ──────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
export async function runAuditorStage(
|
|
136
|
+
ctx: PipelineContext,
|
|
137
|
+
plan: string,
|
|
138
|
+
implResult: string,
|
|
139
|
+
): Promise<void> {
|
|
140
|
+
await emit(ctx, { type: "thought", body: "Auditing implementation against the plan..." });
|
|
141
|
+
|
|
142
|
+
const message = `You are an auditor. Review this implementation against the original plan.
|
|
143
|
+
|
|
144
|
+
## Issue: ${ctx.issue.identifier} — ${ctx.issue.title}
|
|
145
|
+
|
|
146
|
+
## Original Plan:
|
|
147
|
+
${plan}
|
|
148
|
+
|
|
149
|
+
## Implementation Result:
|
|
150
|
+
${implResult}
|
|
151
|
+
|
|
152
|
+
## Instructions
|
|
153
|
+
1. Verify each plan step was completed
|
|
154
|
+
2. Check for any missed items
|
|
155
|
+
3. Note any concerns or improvements needed
|
|
156
|
+
4. Provide a pass/fail verdict with reasoning
|
|
157
|
+
5. Output a concise audit summary in markdown
|
|
158
|
+
|
|
159
|
+
Output ONLY the audit summary.`;
|
|
160
|
+
|
|
161
|
+
await emit(ctx, { type: "action", action: "Auditing", parameter: ctx.issue.identifier });
|
|
162
|
+
|
|
163
|
+
const result = await runAgent({
|
|
164
|
+
api: ctx.api,
|
|
165
|
+
agentId: ctx.agentId,
|
|
166
|
+
sessionId: `linear-audit-${ctx.agentSessionId}`,
|
|
167
|
+
message,
|
|
168
|
+
timeoutMs: 5 * 60_000,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const auditSummary = result.success ? result.output : `Audit failed: ${result.output.slice(0, 500)}`;
|
|
172
|
+
|
|
173
|
+
await ctx.linearApi.createComment(
|
|
174
|
+
ctx.issue.id,
|
|
175
|
+
`## Audit Report\n\n${auditSummary}`,
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
await emit(ctx, {
|
|
179
|
+
type: "response",
|
|
180
|
+
body: `Completed work on ${ctx.issue.identifier}. Plan, implementation, and audit posted as comments.`,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Full Pipeline ─────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
export async function runFullPipeline(ctx: PipelineContext): Promise<void> {
|
|
187
|
+
try {
|
|
188
|
+
// Stage 1: Plan
|
|
189
|
+
const plan = await runPlannerStage(ctx);
|
|
190
|
+
if (!plan) return;
|
|
191
|
+
|
|
192
|
+
// Pipeline pauses here — user must reply to approve.
|
|
193
|
+
// The "prompted" webhook will call resumePipeline().
|
|
194
|
+
} catch (err) {
|
|
195
|
+
ctx.api.logger.error(`Pipeline error: ${err}`);
|
|
196
|
+
await emit(ctx, { type: "error", body: `Pipeline failed: ${String(err).slice(0, 500)}` });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function resumePipeline(ctx: PipelineContext, plan: string): Promise<void> {
|
|
201
|
+
try {
|
|
202
|
+
// Stage 2: Implement
|
|
203
|
+
const implResult = await runImplementorStage(ctx, plan);
|
|
204
|
+
if (!implResult) return;
|
|
205
|
+
|
|
206
|
+
// Stage 3: Audit
|
|
207
|
+
await runAuditorStage(ctx, plan, implResult);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
ctx.api.logger.error(`Pipeline error: ${err}`);
|
|
210
|
+
await emit(ctx, { type: "error", body: `Pipeline failed: ${String(err).slice(0, 500)}` });
|
|
211
|
+
}
|
|
212
|
+
}
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { AnyAgentTool, OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
|
|
2
|
+
import { jsonResult } from "openclaw/plugin-sdk";
|
|
3
|
+
import { LinearClient } from "./client.js";
|
|
4
|
+
|
|
5
|
+
export function createLinearTools(api: OpenClawPluginApi, ctx: OpenClawPluginToolContext): AnyAgentTool[] {
|
|
6
|
+
const getClient = () => {
|
|
7
|
+
// In a real implementation, we would resolve the token from auth profiles
|
|
8
|
+
// For now, we'll try to get it from environment or a known profile
|
|
9
|
+
const token = process.env.LINEAR_ACCESS_TOKEN;
|
|
10
|
+
if (!token) {
|
|
11
|
+
throw new Error("Linear access token not found. Please authenticate first.");
|
|
12
|
+
}
|
|
13
|
+
return new LinearClient(token);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
return [
|
|
17
|
+
{
|
|
18
|
+
name: "linear_list_issues",
|
|
19
|
+
description: "List issues from a Linear workspace",
|
|
20
|
+
parameters: {
|
|
21
|
+
type: "object",
|
|
22
|
+
properties: {
|
|
23
|
+
limit: { type: "number", description: "Max issues to return", default: 10 },
|
|
24
|
+
teamId: { type: "string", description: "Filter by team ID" }
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
execute: async ({ limit, teamId }) => {
|
|
28
|
+
const client = getClient();
|
|
29
|
+
const data = await client.listIssues({ limit, teamId });
|
|
30
|
+
return jsonResult({
|
|
31
|
+
message: `Found ${data.issues.nodes.length} issues`,
|
|
32
|
+
issues: data.issues.nodes
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: "linear_create_issue",
|
|
38
|
+
description: "Create a new issue in Linear",
|
|
39
|
+
parameters: {
|
|
40
|
+
type: "object",
|
|
41
|
+
properties: {
|
|
42
|
+
title: { type: "string", description: "Issue title" },
|
|
43
|
+
description: { type: "string", description: "Issue description" },
|
|
44
|
+
teamId: { type: "string", description: "Team ID" }
|
|
45
|
+
},
|
|
46
|
+
required: ["title", "teamId"]
|
|
47
|
+
},
|
|
48
|
+
execute: async ({ title, description, teamId }) => {
|
|
49
|
+
const client = getClient();
|
|
50
|
+
const data = await client.createIssue({ title, description, teamId });
|
|
51
|
+
if (data.issueCreate.success) {
|
|
52
|
+
return jsonResult({
|
|
53
|
+
message: "Created issue successfully",
|
|
54
|
+
issue: data.issueCreate.issue
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return jsonResult({ message: "Failed to create issue" });
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "linear_add_comment",
|
|
62
|
+
description: "Add a comment to a Linear issue",
|
|
63
|
+
parameters: {
|
|
64
|
+
type: "object",
|
|
65
|
+
properties: {
|
|
66
|
+
issueId: { type: "string", description: "Issue ID" },
|
|
67
|
+
body: { type: "string", description: "Comment body" }
|
|
68
|
+
},
|
|
69
|
+
required: ["issueId", "body"]
|
|
70
|
+
},
|
|
71
|
+
execute: async ({ issueId, body }) => {
|
|
72
|
+
const client = getClient();
|
|
73
|
+
const data = await client.addComment({ issueId, body });
|
|
74
|
+
if (data.commentCreate.success) {
|
|
75
|
+
return jsonResult({
|
|
76
|
+
message: "Added comment successfully",
|
|
77
|
+
commentId: data.commentCreate.comment.id
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return jsonResult({ message: "Failed to add comment" });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
];
|
|
84
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { AddressInfo } from "node:net";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
// Mock the pipeline module
|
|
7
|
+
const { runPlannerStageMock, runFullPipelineMock, resumePipelineMock } = vi.hoisted(() => ({
|
|
8
|
+
runPlannerStageMock: vi.fn().mockResolvedValue("mock plan"),
|
|
9
|
+
runFullPipelineMock: vi.fn().mockResolvedValue(undefined),
|
|
10
|
+
resumePipelineMock: vi.fn().mockResolvedValue(undefined),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock("./pipeline.js", () => ({
|
|
14
|
+
runPlannerStage: runPlannerStageMock,
|
|
15
|
+
runFullPipeline: runFullPipelineMock,
|
|
16
|
+
resumePipeline: resumePipelineMock,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
// Mock the linear-api module
|
|
20
|
+
vi.mock("./linear-api.js", () => ({
|
|
21
|
+
LinearAgentApi: class MockLinearAgentApi {
|
|
22
|
+
emitActivity = vi.fn().mockResolvedValue(undefined);
|
|
23
|
+
createComment = vi.fn().mockResolvedValue("comment-id");
|
|
24
|
+
getIssueDetails = vi.fn().mockResolvedValue(null);
|
|
25
|
+
updateSession = vi.fn().mockResolvedValue(undefined);
|
|
26
|
+
},
|
|
27
|
+
resolveLinearToken: vi.fn().mockReturnValue({
|
|
28
|
+
accessToken: "test-token",
|
|
29
|
+
source: "env",
|
|
30
|
+
}),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
import { handleLinearWebhook } from "./webhook.js";
|
|
34
|
+
|
|
35
|
+
function createApi(): OpenClawPluginApi {
|
|
36
|
+
return {
|
|
37
|
+
logger: {
|
|
38
|
+
info: vi.fn(),
|
|
39
|
+
warn: vi.fn(),
|
|
40
|
+
error: vi.fn(),
|
|
41
|
+
debug: vi.fn(),
|
|
42
|
+
},
|
|
43
|
+
runtime: {},
|
|
44
|
+
pluginConfig: {},
|
|
45
|
+
} as unknown as OpenClawPluginApi;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function withServer(
|
|
49
|
+
handler: Parameters<typeof createServer>[0],
|
|
50
|
+
fn: (baseUrl: string) => Promise<void>,
|
|
51
|
+
) {
|
|
52
|
+
const server = createServer(handler);
|
|
53
|
+
await new Promise<void>((resolve) => {
|
|
54
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
55
|
+
});
|
|
56
|
+
const address = server.address() as AddressInfo | null;
|
|
57
|
+
if (!address) {
|
|
58
|
+
throw new Error("missing server address");
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
await fn(`http://127.0.0.1:${address.port}`);
|
|
62
|
+
} finally {
|
|
63
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function postWebhook(payload: unknown, path = "/linear/webhook") {
|
|
68
|
+
const api = createApi();
|
|
69
|
+
let status = 0;
|
|
70
|
+
let body = "";
|
|
71
|
+
|
|
72
|
+
await withServer(
|
|
73
|
+
async (req, res) => {
|
|
74
|
+
const handled = await handleLinearWebhook(api, req, res);
|
|
75
|
+
if (!handled) {
|
|
76
|
+
res.statusCode = 404;
|
|
77
|
+
res.end("not found");
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
async (baseUrl) => {
|
|
81
|
+
const response = await fetch(`${baseUrl}${path}`, {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: {
|
|
84
|
+
"content-type": "application/json",
|
|
85
|
+
},
|
|
86
|
+
body: JSON.stringify(payload),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
status = response.status;
|
|
90
|
+
body = await response.text();
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return { api, status, body };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
afterEach(() => {
|
|
98
|
+
runPlannerStageMock.mockReset().mockResolvedValue("mock plan");
|
|
99
|
+
runFullPipelineMock.mockReset().mockResolvedValue(undefined);
|
|
100
|
+
resumePipelineMock.mockReset().mockResolvedValue(undefined);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("handleLinearWebhook", () => {
|
|
104
|
+
it("responds 200 to AgentSession create within time limit", async () => {
|
|
105
|
+
const payload = {
|
|
106
|
+
type: "AgentSession",
|
|
107
|
+
action: "create",
|
|
108
|
+
data: {
|
|
109
|
+
id: "sess-1",
|
|
110
|
+
context: { commentBody: "Please investigate this issue" },
|
|
111
|
+
},
|
|
112
|
+
issue: {
|
|
113
|
+
id: "issue-1",
|
|
114
|
+
identifier: "ENG-123",
|
|
115
|
+
title: "Fix webhook routing",
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const result = await postWebhook(payload);
|
|
120
|
+
|
|
121
|
+
expect(result.status).toBe(200);
|
|
122
|
+
expect(result.body).toBe("ok");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("logs error when session or issue data is missing", async () => {
|
|
126
|
+
const payload = {
|
|
127
|
+
type: "AgentSession",
|
|
128
|
+
action: "create",
|
|
129
|
+
data: { id: null },
|
|
130
|
+
issue: null,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const result = await postWebhook(payload);
|
|
134
|
+
|
|
135
|
+
expect(result.status).toBe(200);
|
|
136
|
+
expect((result.api.logger.error as any).mock.calls.length).toBeGreaterThan(0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("responds 200 to AgentSession prompted", async () => {
|
|
140
|
+
const payload = {
|
|
141
|
+
type: "AgentSession",
|
|
142
|
+
action: "prompted",
|
|
143
|
+
data: {
|
|
144
|
+
id: "sess-prompted",
|
|
145
|
+
context: { prompt: "Looks good, approved!" },
|
|
146
|
+
},
|
|
147
|
+
issue: {
|
|
148
|
+
id: "issue-2",
|
|
149
|
+
identifier: "ENG-124",
|
|
150
|
+
title: "Approved issue",
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const result = await postWebhook(payload);
|
|
155
|
+
|
|
156
|
+
expect(result.status).toBe(200);
|
|
157
|
+
expect(result.body).toBe("ok");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("responds 200 to unknown webhook types", async () => {
|
|
161
|
+
const payload = {
|
|
162
|
+
type: "Issue",
|
|
163
|
+
action: "update",
|
|
164
|
+
data: { id: "issue-5" },
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const result = await postWebhook(payload);
|
|
168
|
+
|
|
169
|
+
expect(result.status).toBe(200);
|
|
170
|
+
expect(result.body).toBe("ok");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("returns 405 for non-POST methods", async () => {
|
|
174
|
+
const api = createApi();
|
|
175
|
+
let status = 0;
|
|
176
|
+
|
|
177
|
+
await withServer(
|
|
178
|
+
async (req, res) => {
|
|
179
|
+
await handleLinearWebhook(api, req, res);
|
|
180
|
+
},
|
|
181
|
+
async (baseUrl) => {
|
|
182
|
+
const response = await fetch(`${baseUrl}/linear/webhook`, {
|
|
183
|
+
method: "GET",
|
|
184
|
+
});
|
|
185
|
+
status = response.status;
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
expect(status).toBe(405);
|
|
190
|
+
});
|
|
191
|
+
});
|