@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/src/auth.ts ADDED
@@ -0,0 +1,130 @@
1
+ import type {
2
+ OpenClawPluginApi,
3
+ ProviderAuthContext,
4
+ ProviderAuthResult
5
+ } from "openclaw/plugin-sdk";
6
+
7
+ const LINEAR_OAUTH_AUTH_URL = "https://linear.app/oauth/authorize";
8
+ const LINEAR_OAUTH_TOKEN_URL = "https://api.linear.app/oauth/token";
9
+
10
+ // Agent scopes: read/write + assignable (appear in assignment menus) + mentionable (respond to @mentions)
11
+ const LINEAR_AGENT_SCOPES = "read,write,app:assignable,app:mentionable";
12
+
13
+ // Token refresh helper — Linear tokens expire; refresh before they do
14
+ export async function refreshLinearToken(
15
+ clientId: string,
16
+ clientSecret: string,
17
+ refreshToken: string,
18
+ ): Promise<{ access_token: string; refresh_token?: string; expires_in: number }> {
19
+ const response = await fetch(LINEAR_OAUTH_TOKEN_URL, {
20
+ method: "POST",
21
+ headers: {
22
+ "Content-Type": "application/x-www-form-urlencoded",
23
+ Accept: "application/json",
24
+ },
25
+ body: new URLSearchParams({
26
+ grant_type: "refresh_token",
27
+ client_id: clientId,
28
+ client_secret: clientSecret,
29
+ refresh_token: refreshToken,
30
+ }),
31
+ });
32
+
33
+ if (!response.ok) {
34
+ const error = await response.text();
35
+ throw new Error(`Linear token refresh failed (${response.status}): ${error}`);
36
+ }
37
+
38
+ return response.json();
39
+ }
40
+
41
+ export function registerLinearProvider(api: OpenClawPluginApi) {
42
+ const provider = {
43
+ id: "linear",
44
+ label: "Linear",
45
+ auth: [
46
+ {
47
+ id: "oauth",
48
+ label: "OAuth",
49
+ kind: "oauth",
50
+ run: async (ctx: ProviderAuthContext): Promise<ProviderAuthResult> => {
51
+ // This is a placeholder for the actual OAuth flow.
52
+ // In a real implementation, we would use ctx.oauth.createVpsAwareHandlers
53
+ // and perform the Linear OAuth2 flow.
54
+
55
+ const pluginConfig = api.pluginConfig as { clientId?: string; clientSecret?: string; redirectUri?: string } | undefined;
56
+ const clientId = pluginConfig?.clientId ?? process.env.LINEAR_CLIENT_ID;
57
+ const clientSecret = pluginConfig?.clientSecret ?? process.env.LINEAR_CLIENT_SECRET;
58
+
59
+ if (!clientId || !clientSecret) {
60
+ throw new Error("Linear client ID and secret must be configured in plugin config or environment.");
61
+ }
62
+
63
+ const prompter = ctx.prompter;
64
+ const spin = prompter.progress("Starting Linear OAuth flow…");
65
+
66
+ const handlers = ctx.oauth.createVpsAwareHandlers({
67
+ isRemote: ctx.isRemote,
68
+ prompter,
69
+ runtime: ctx.runtime,
70
+ spin,
71
+ openUrl: ctx.openUrl,
72
+ localBrowserMessage: "Waiting for Linear authorization…",
73
+ });
74
+
75
+ // Linear OAuth requires a redirect_uri.
76
+ const gatewayPort = process.env.OPENCLAW_GATEWAY_PORT ?? "18789";
77
+ const redirectUri = pluginConfig?.redirectUri ?? process.env.LINEAR_REDIRECT_URI ?? `http://localhost:${gatewayPort}/linear/oauth/callback`;
78
+
79
+ const state = Math.random().toString(36).substring(7);
80
+ const authUrl = `${LINEAR_OAUTH_AUTH_URL}?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(LINEAR_AGENT_SCOPES)}&state=${state}&actor=app`;
81
+
82
+ await handlers.onAuth({ url: authUrl });
83
+
84
+ const code = await handlers.onPrompt({
85
+ message: "Enter the code from Linear",
86
+ });
87
+
88
+ spin.update("Exchanging code for token…");
89
+
90
+ const response = await fetch(LINEAR_OAUTH_TOKEN_URL, {
91
+ method: "POST",
92
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
93
+ body: new URLSearchParams({
94
+ grant_type: "authorization_code",
95
+ code,
96
+ client_id: clientId,
97
+ client_secret: clientSecret,
98
+ redirect_uri: redirectUri,
99
+ }),
100
+ });
101
+
102
+ if (!response.ok) {
103
+ const error = await response.text();
104
+ throw new Error(`Linear OAuth failed: ${error}`);
105
+ }
106
+
107
+ const tokens = await response.json();
108
+ spin.stop("Linear authorized!");
109
+
110
+ return {
111
+ profiles: [
112
+ {
113
+ profileId: "linear:default",
114
+ credential: {
115
+ type: "oauth",
116
+ provider: "linear",
117
+ accessToken: tokens.access_token,
118
+ refreshToken: tokens.refresh_token,
119
+ expiresAt: Date.now() + (tokens.expires_in * 1000),
120
+ },
121
+ },
122
+ ],
123
+ };
124
+ },
125
+ },
126
+ ],
127
+ };
128
+
129
+ api.registerProvider(provider);
130
+ }
package/src/client.ts ADDED
@@ -0,0 +1,93 @@
1
+ const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql";
2
+
3
+ export class LinearClient {
4
+ constructor(private accessToken: string) {}
5
+
6
+ async request<T = any>(query: string, variables?: Record<string, any>): Promise<T> {
7
+ const res = await fetch(LINEAR_GRAPHQL_URL, {
8
+ method: "POST",
9
+ headers: {
10
+ "Content-Type": "application/json",
11
+ "Authorization": `Bearer ${this.accessToken}`,
12
+ },
13
+ body: JSON.stringify({ query, variables }),
14
+ });
15
+
16
+ if (!res.ok) {
17
+ const text = await res.text();
18
+ throw new Error(`Linear API request failed: ${res.status} ${text}`);
19
+ }
20
+
21
+ const payload = await res.json();
22
+ if (payload.errors) {
23
+ throw new Error(`Linear API returned errors: ${JSON.stringify(payload.errors)}`);
24
+ }
25
+
26
+ return payload.data as T;
27
+ }
28
+
29
+ async getViewer() {
30
+ const query = `
31
+ query {
32
+ viewer {
33
+ id
34
+ name
35
+ email
36
+ }
37
+ }
38
+ `;
39
+ return this.request(query);
40
+ }
41
+
42
+ async listIssues(params: { limit: number; teamId?: string }) {
43
+ const query = `
44
+ query ListIssues($limit: Int, $teamId: String) {
45
+ issues(first: $limit, filter: { team: { id: { eq: $teamId } } }) {
46
+ nodes {
47
+ id
48
+ identifier
49
+ title
50
+ description
51
+ state {
52
+ name
53
+ }
54
+ assignee {
55
+ name
56
+ }
57
+ }
58
+ }
59
+ }
60
+ `;
61
+ return this.request(query, params);
62
+ }
63
+
64
+ async createIssue(params: { title: string; description?: string; teamId: string }) {
65
+ const query = `
66
+ mutation CreateIssue($title: String!, $description: String, $teamId: String!) {
67
+ issueCreate(input: { title: $title, description: $description, teamId: $teamId }) {
68
+ success
69
+ issue {
70
+ id
71
+ identifier
72
+ title
73
+ }
74
+ }
75
+ }
76
+ `;
77
+ return this.request(query, params);
78
+ }
79
+
80
+ async addComment(params: { issueId: string; body: string }) {
81
+ const query = `
82
+ mutation AddComment($issueId: String!, $body: String!) {
83
+ commentCreate(input: { issueId: $issueId, body: $body }) {
84
+ success
85
+ comment {
86
+ id
87
+ }
88
+ }
89
+ }
90
+ `;
91
+ return this.request(query, params);
92
+ }
93
+ }
@@ -0,0 +1,384 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { refreshLinearToken } from "./auth.js";
4
+
5
+ const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql";
6
+ const AUTH_PROFILES_PATH = join(
7
+ process.env.HOME ?? "/home/claw",
8
+ ".openclaw",
9
+ "auth-profiles.json",
10
+ );
11
+
12
+ export type ActivityContent =
13
+ | { type: "thought"; body: string }
14
+ | { type: "action"; action: string; parameter?: string; result?: string }
15
+ | { type: "response"; body: string }
16
+ | { type: "elicitation"; body: string }
17
+ | { type: "error"; body: string };
18
+
19
+ export interface ExternalUrl {
20
+ label: string;
21
+ url: string;
22
+ }
23
+
24
+ /**
25
+ * Resolve a Linear access token from multiple sources in priority order:
26
+ * 1. pluginConfig.accessToken (static config)
27
+ * 2. LINEAR_ACCESS_TOKEN env var
28
+ * 3. Auth profile store (~/.openclaw/auth-profiles.json) — from OAuth flow
29
+ */
30
+ export function resolveLinearToken(pluginConfig?: Record<string, unknown>): {
31
+ accessToken: string | null;
32
+ refreshToken?: string;
33
+ expiresAt?: number;
34
+ source: "config" | "env" | "profile" | "none";
35
+ } {
36
+ // 1. Static config
37
+ const fromConfig = pluginConfig?.accessToken;
38
+ if (typeof fromConfig === "string" && fromConfig) {
39
+ return { accessToken: fromConfig, source: "config" };
40
+ }
41
+
42
+ // 2. Auth profile store (from OAuth flow) — preferred because OAuth tokens
43
+ // carry app:assignable/app:mentionable scopes needed for Agent Sessions
44
+ try {
45
+ const raw = readFileSync(AUTH_PROFILES_PATH, "utf8");
46
+ const store = JSON.parse(raw);
47
+ const profile = store?.profiles?.["linear:default"];
48
+ if (profile?.accessToken || profile?.access) {
49
+ return {
50
+ accessToken: profile.accessToken ?? profile.access,
51
+ refreshToken: profile.refreshToken ?? profile.refresh,
52
+ expiresAt: profile.expiresAt ?? profile.expires,
53
+ source: "profile",
54
+ };
55
+ }
56
+ } catch {
57
+ // Profile store doesn't exist or is unreadable
58
+ }
59
+
60
+ // 3. Env var fallback (personal API key — works for comments but not Agent Sessions)
61
+ const fromEnv = process.env.LINEAR_ACCESS_TOKEN ?? process.env.LINEAR_API_KEY;
62
+ if (fromEnv) {
63
+ return { accessToken: fromEnv, source: "env" };
64
+ }
65
+
66
+ return { accessToken: null, source: "none" };
67
+ }
68
+
69
+ export class LinearAgentApi {
70
+ private accessToken: string;
71
+ private refreshToken?: string;
72
+ private expiresAt?: number;
73
+ private clientId?: string;
74
+ private clientSecret?: string;
75
+ private viewerId?: string;
76
+
77
+ constructor(
78
+ accessToken: string,
79
+ opts?: {
80
+ refreshToken?: string;
81
+ expiresAt?: number;
82
+ clientId?: string;
83
+ clientSecret?: string;
84
+ },
85
+ ) {
86
+ this.accessToken = accessToken;
87
+ this.refreshToken = opts?.refreshToken;
88
+ this.expiresAt = opts?.expiresAt;
89
+ this.clientId = opts?.clientId;
90
+ this.clientSecret = opts?.clientSecret;
91
+ }
92
+
93
+ async getViewerId(): Promise<string | null> {
94
+ if (this.viewerId) return this.viewerId;
95
+ try {
96
+ const data = await this.gql<{ viewer: { id: string } }>(
97
+ `query { viewer { id } }`,
98
+ );
99
+ this.viewerId = data.viewer.id;
100
+ return this.viewerId;
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ /** Refresh the token if it's expired (or about to expire in 60s) */
107
+ private async ensureValidToken(): Promise<void> {
108
+ if (!this.refreshToken || !this.clientId || !this.clientSecret) return;
109
+ if (!this.expiresAt) return;
110
+
111
+ const bufferMs = 60_000; // refresh 60s before expiry
112
+ if (Date.now() < this.expiresAt - bufferMs) return;
113
+
114
+ const result = await refreshLinearToken(this.clientId, this.clientSecret, this.refreshToken);
115
+ this.accessToken = result.access_token;
116
+ if (result.refresh_token) this.refreshToken = result.refresh_token;
117
+ this.expiresAt = Date.now() + result.expires_in * 1000;
118
+
119
+ // Persist refreshed token back to auth profile store
120
+ this.persistToken();
121
+ }
122
+
123
+ private persistToken(): void {
124
+ try {
125
+ const raw = readFileSync(AUTH_PROFILES_PATH, "utf8");
126
+ const store = JSON.parse(raw);
127
+ if (store.profiles?.["linear:default"]) {
128
+ store.profiles["linear:default"].accessToken = this.accessToken;
129
+ store.profiles["linear:default"].access = this.accessToken;
130
+ if (this.refreshToken) {
131
+ store.profiles["linear:default"].refreshToken = this.refreshToken;
132
+ store.profiles["linear:default"].refresh = this.refreshToken;
133
+ }
134
+ if (this.expiresAt) {
135
+ store.profiles["linear:default"].expiresAt = this.expiresAt;
136
+ store.profiles["linear:default"].expires = this.expiresAt;
137
+ }
138
+ writeFileSync(AUTH_PROFILES_PATH, JSON.stringify(store, null, 2), "utf8");
139
+ }
140
+ } catch {
141
+ // Best-effort persistence
142
+ }
143
+ }
144
+
145
+ private authHeader(): string {
146
+ // OAuth tokens (which have a refreshToken) require Bearer prefix;
147
+ // personal API keys do not.
148
+ return this.refreshToken ? `Bearer ${this.accessToken}` : this.accessToken;
149
+ }
150
+
151
+ private async gql<T = unknown>(query: string, variables?: Record<string, unknown>): Promise<T> {
152
+ await this.ensureValidToken();
153
+
154
+ const res = await fetch(LINEAR_GRAPHQL_URL, {
155
+ method: "POST",
156
+ headers: {
157
+ "Content-Type": "application/json",
158
+ Authorization: this.authHeader(),
159
+ },
160
+ body: JSON.stringify({ query, variables }),
161
+ });
162
+
163
+ // If 401, try refreshing token once
164
+ if (res.status === 401 && this.refreshToken && this.clientId && this.clientSecret) {
165
+ this.expiresAt = 0; // force refresh
166
+ await this.ensureValidToken();
167
+
168
+ const retry = await fetch(LINEAR_GRAPHQL_URL, {
169
+ method: "POST",
170
+ headers: {
171
+ "Content-Type": "application/json",
172
+ Authorization: this.authHeader(),
173
+ },
174
+ body: JSON.stringify({ query, variables }),
175
+ });
176
+
177
+ if (!retry.ok) {
178
+ const text = await retry.text();
179
+ throw new Error(`Linear API ${retry.status} (after refresh): ${text}`);
180
+ }
181
+
182
+ const payload = await retry.json();
183
+ if (payload.errors?.length) {
184
+ throw new Error(`Linear GraphQL: ${JSON.stringify(payload.errors)}`);
185
+ }
186
+ return payload.data as T;
187
+ }
188
+
189
+ if (!res.ok) {
190
+ const text = await res.text();
191
+ throw new Error(`Linear API ${res.status}: ${text}`);
192
+ }
193
+
194
+ const payload = await res.json();
195
+ if (payload.errors?.length) {
196
+ throw new Error(`Linear GraphQL: ${JSON.stringify(payload.errors)}`);
197
+ }
198
+
199
+ return payload.data as T;
200
+ }
201
+
202
+ async emitActivity(agentSessionId: string, content: ActivityContent): Promise<void> {
203
+ await this.gql(
204
+ `mutation AgentActivityCreate($input: AgentActivityCreateInput!) {
205
+ agentActivityCreate(input: $input) {
206
+ success
207
+ }
208
+ }`,
209
+ { input: { agentSessionId, content } },
210
+ );
211
+ }
212
+
213
+ async updateSession(
214
+ agentSessionId: string,
215
+ input: { externalUrls?: ExternalUrl[]; addedExternalUrls?: ExternalUrl[]; plan?: string },
216
+ ): Promise<void> {
217
+ await this.gql(
218
+ `mutation AgentSessionUpdate($id: String!, $input: AgentSessionUpdateInput!) {
219
+ agentSessionUpdate(id: $id, input: $input) {
220
+ success
221
+ }
222
+ }`,
223
+ { id: agentSessionId, input },
224
+ );
225
+ }
226
+
227
+ async createReaction(commentId: string, emoji: string): Promise<boolean> {
228
+ try {
229
+ const data = await this.gql<{
230
+ reactionCreate: { success: boolean };
231
+ }>(
232
+ `mutation ReactionCreate($input: ReactionCreateInput!) {
233
+ reactionCreate(input: $input) {
234
+ success
235
+ }
236
+ }`,
237
+ { input: { commentId, emoji } },
238
+ );
239
+ return data.reactionCreate.success;
240
+ } catch {
241
+ return false;
242
+ }
243
+ }
244
+
245
+ async createSessionOnIssue(issueId: string): Promise<{ sessionId: string | null; error?: string }> {
246
+ try {
247
+ const data = await this.gql<{
248
+ agentSessionCreateOnIssue: { success: boolean; agentSession?: { id: string } };
249
+ }>(
250
+ `mutation AgentSessionCreateOnIssue($input: AgentSessionCreateOnIssue!) {
251
+ agentSessionCreateOnIssue(input: $input) {
252
+ success
253
+ agentSession { id }
254
+ }
255
+ }`,
256
+ { input: { issueId } },
257
+ );
258
+ const id = data.agentSessionCreateOnIssue.agentSession?.id ?? null;
259
+ if (!id) return { sessionId: null, error: `success=${data.agentSessionCreateOnIssue.success} but no session ID` };
260
+ return { sessionId: id };
261
+ } catch (err) {
262
+ const msg = err instanceof Error ? err.message : String(err);
263
+ return { sessionId: null, error: msg };
264
+ }
265
+ }
266
+
267
+ async createComment(
268
+ issueId: string,
269
+ body: string,
270
+ opts?: { createAsUser?: string; displayIconUrl?: string },
271
+ ): Promise<string> {
272
+ const input: Record<string, unknown> = { issueId, body };
273
+ if (opts?.createAsUser) input.createAsUser = opts.createAsUser;
274
+ if (opts?.displayIconUrl) input.displayIconUrl = opts.displayIconUrl;
275
+
276
+ const data = await this.gql<{
277
+ commentCreate: { success: boolean; comment: { id: string } };
278
+ }>(
279
+ `mutation CommentCreate($input: CommentCreateInput!) {
280
+ commentCreate(input: $input) {
281
+ success
282
+ comment { id }
283
+ }
284
+ }`,
285
+ { input },
286
+ );
287
+ return data.commentCreate.comment.id;
288
+ }
289
+
290
+ async getIssueDetails(issueId: string): Promise<{
291
+ id: string;
292
+ identifier: string;
293
+ title: string;
294
+ description: string | null;
295
+ estimate: number | null;
296
+ state: { name: string };
297
+ assignee: { name: string } | null;
298
+ labels: { nodes: Array<{ id: string; name: string }> };
299
+ team: { id: string; name: string; issueEstimationType: string };
300
+ comments: { nodes: Array<{ body: string; user: { name: string } | null; createdAt: string }> };
301
+ }> {
302
+ const data = await this.gql<{ issue: unknown }>(
303
+ `query Issue($id: String!) {
304
+ issue(id: $id) {
305
+ id
306
+ identifier
307
+ title
308
+ description
309
+ estimate
310
+ state { name }
311
+ assignee { name }
312
+ labels { nodes { id name } }
313
+ team { id name issueEstimationType }
314
+ comments(last: 10) {
315
+ nodes {
316
+ body
317
+ user { name }
318
+ createdAt
319
+ }
320
+ }
321
+ }
322
+ }`,
323
+ { id: issueId },
324
+ );
325
+ return data.issue as any;
326
+ }
327
+
328
+ async updateIssue(issueId: string, input: {
329
+ estimate?: number;
330
+ labelIds?: string[];
331
+ stateId?: string;
332
+ priority?: number;
333
+ }): Promise<boolean> {
334
+ const data = await this.gql<{
335
+ issueUpdate: { success: boolean };
336
+ }>(
337
+ `mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) {
338
+ issueUpdate(id: $id, input: $input) {
339
+ success
340
+ }
341
+ }`,
342
+ { id: issueId, input },
343
+ );
344
+ return data.issueUpdate.success;
345
+ }
346
+
347
+ async getTeamLabels(teamId: string): Promise<Array<{ id: string; name: string }>> {
348
+ const data = await this.gql<{ team: { labels: { nodes: Array<{ id: string; name: string }> } } }>(
349
+ `query TeamLabels($id: String!) {
350
+ team(id: $id) {
351
+ labels { nodes { id name } }
352
+ }
353
+ }`,
354
+ { id: teamId },
355
+ );
356
+ return data.team.labels.nodes;
357
+ }
358
+
359
+ async getAppNotifications(count: number = 5): Promise<Array<{
360
+ id: string;
361
+ type: string;
362
+ createdAt: string;
363
+ issue?: { id: string; identifier: string; title: string };
364
+ comment?: { id: string; body: string; userId?: string };
365
+ }>> {
366
+ const data = await this.gql<{ notifications: { nodes: unknown[] } }>(
367
+ `query Notifications($first: Int!) {
368
+ notifications(first: $first, orderBy: createdAt) {
369
+ nodes {
370
+ id
371
+ type
372
+ createdAt
373
+ ... on IssueNotification {
374
+ issue { id identifier title }
375
+ comment { id body }
376
+ }
377
+ }
378
+ }
379
+ }`,
380
+ { first: count },
381
+ );
382
+ return data.notifications.nodes as any;
383
+ }
384
+ }