@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
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
|
+
}
|