@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.
@@ -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
+ }
@@ -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
+ });