santree 0.5.3 → 0.5.5
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 +156 -46
- package/dist/commands/dashboard.d.ts +1 -1
- package/dist/commands/dashboard.js +22 -18
- package/dist/commands/doctor.js +97 -76
- package/dist/commands/github/auth.d.ts +2 -0
- package/dist/commands/github/auth.js +56 -0
- package/dist/commands/github/index.d.ts +1 -0
- package/dist/commands/github/index.js +1 -0
- package/dist/commands/helpers/english-tutor/index.d.ts +1 -0
- package/dist/commands/helpers/english-tutor/index.js +1 -0
- package/dist/commands/helpers/english-tutor/install.d.ts +8 -0
- package/dist/commands/helpers/english-tutor/install.js +24 -0
- package/dist/commands/helpers/english-tutor/prompt.d.ts +2 -0
- package/dist/commands/helpers/english-tutor/prompt.js +16 -0
- package/dist/commands/helpers/english-tutor/session-start.d.ts +2 -0
- package/dist/commands/helpers/english-tutor/session-start.js +34 -0
- package/dist/commands/helpers/english-tutor/uninstall.d.ts +2 -0
- package/dist/commands/helpers/english-tutor/uninstall.js +15 -0
- package/dist/commands/helpers/template.d.ts +1 -0
- package/dist/commands/helpers/template.js +13 -10
- package/dist/commands/issue/index.d.ts +1 -0
- package/dist/commands/issue/index.js +1 -0
- package/dist/commands/issue/open.d.ts +2 -0
- package/dist/commands/{linear → issue}/open.js +13 -11
- package/dist/commands/issue/switch.d.ts +11 -0
- package/dist/commands/issue/switch.js +38 -0
- package/dist/commands/linear/auth.js +23 -10
- package/dist/commands/linear/switch.js +7 -3
- package/dist/commands/pr/create.js +7 -5
- package/dist/commands/worktree/create.js +4 -6
- package/dist/commands/worktree/work.js +1 -1
- package/dist/lib/ai.d.ts +8 -6
- package/dist/lib/ai.js +29 -15
- package/dist/lib/dashboard/DetailPanel.d.ts +5 -2
- package/dist/lib/dashboard/DetailPanel.js +6 -3
- package/dist/lib/dashboard/data.js +17 -9
- package/dist/lib/dashboard/types.d.ts +3 -16
- package/dist/lib/english-tutor.d.ts +13 -0
- package/dist/lib/english-tutor.js +125 -0
- package/dist/lib/git.d.ts +16 -33
- package/dist/lib/git.js +20 -74
- package/dist/lib/metadata.d.ts +3 -0
- package/dist/lib/metadata.js +27 -0
- package/dist/lib/multiplexer/cmux.js +1 -1
- package/dist/lib/multiplexer/index.js +5 -12
- package/dist/lib/multiplexer/types.d.ts +1 -1
- package/dist/lib/prompts.d.ts +4 -3
- package/dist/lib/prompts.js +4 -3
- package/dist/lib/session-signal.d.ts +2 -3
- package/dist/lib/session-signal.js +3 -29
- package/dist/lib/trackers/auth-store.d.ts +16 -0
- package/dist/lib/trackers/auth-store.js +57 -0
- package/dist/lib/trackers/config.d.ts +8 -0
- package/dist/lib/trackers/config.js +21 -0
- package/dist/lib/trackers/github/api.d.ts +3 -0
- package/dist/lib/trackers/github/api.js +90 -0
- package/dist/lib/trackers/github/auth.d.ts +5 -0
- package/dist/lib/trackers/github/auth.js +27 -0
- package/dist/lib/trackers/github/images.d.ts +2 -0
- package/dist/lib/trackers/github/images.js +42 -0
- package/dist/lib/trackers/github/index.d.ts +2 -0
- package/dist/lib/trackers/github/index.js +78 -0
- package/dist/lib/trackers/index.d.ts +12 -0
- package/dist/lib/trackers/index.js +34 -0
- package/dist/lib/trackers/linear/api.d.ts +4 -0
- package/dist/lib/trackers/linear/api.js +128 -0
- package/dist/lib/trackers/linear/auth.d.ts +11 -0
- package/dist/lib/trackers/linear/auth.js +206 -0
- package/dist/lib/trackers/linear/images.d.ts +2 -0
- package/dist/lib/trackers/linear/images.js +44 -0
- package/dist/lib/trackers/linear/index.d.ts +3 -0
- package/dist/lib/trackers/linear/index.js +100 -0
- package/dist/lib/trackers/types.d.ts +52 -0
- package/dist/lib/trackers/types.js +1 -0
- package/package.json +1 -1
- package/prompts/english-tutor-prompt.njk +15 -0
- package/prompts/ticket.njk +3 -3
- package/dist/commands/linear/open.d.ts +0 -2
- package/dist/lib/linear.d.ts +0 -83
- package/dist/lib/linear.js +0 -482
package/dist/lib/linear.js
DELETED
|
@@ -1,482 +0,0 @@
|
|
|
1
|
-
import * as http from "http";
|
|
2
|
-
import * as crypto from "crypto";
|
|
3
|
-
import * as fs from "fs";
|
|
4
|
-
import * as path from "path";
|
|
5
|
-
import * as os from "os";
|
|
6
|
-
import { exec } from "child_process";
|
|
7
|
-
import { getRepoLinearOrg } from "./git.js";
|
|
8
|
-
// ── Constants ──────────────────────────────────────────────────────────
|
|
9
|
-
const CLIENT_ID = "4be2738749371d7d3401061aabe2d11b";
|
|
10
|
-
const LINEAR_AUTHORIZE_URL = "https://linear.app/oauth/authorize";
|
|
11
|
-
const LINEAR_TOKEN_URL = "https://api.linear.app/oauth/token";
|
|
12
|
-
const LINEAR_REVOKE_URL = "https://api.linear.app/oauth/revoke";
|
|
13
|
-
const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql";
|
|
14
|
-
const OAUTH_PORT = 8420;
|
|
15
|
-
const REDIRECT_URI = `http://localhost:${OAUTH_PORT}`;
|
|
16
|
-
const CONFIG_DIR = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
17
|
-
const AUTH_FILE_PATH = path.join(CONFIG_DIR, "santree", "auth.json");
|
|
18
|
-
// ── Auth Store ─────────────────────────────────────────────────────────
|
|
19
|
-
export function readAuthStore() {
|
|
20
|
-
if (!fs.existsSync(AUTH_FILE_PATH))
|
|
21
|
-
return {};
|
|
22
|
-
try {
|
|
23
|
-
return JSON.parse(fs.readFileSync(AUTH_FILE_PATH, "utf-8"));
|
|
24
|
-
}
|
|
25
|
-
catch {
|
|
26
|
-
return {};
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
function writeAuthStore(store) {
|
|
30
|
-
const dir = path.dirname(AUTH_FILE_PATH);
|
|
31
|
-
if (!fs.existsSync(dir)) {
|
|
32
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
33
|
-
}
|
|
34
|
-
fs.writeFileSync(AUTH_FILE_PATH, JSON.stringify(store, null, 2) + "\n", {
|
|
35
|
-
mode: 0o600,
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
// ── PKCE Helpers ───────────────────────────────────────────────────────
|
|
39
|
-
function generateCodeVerifier() {
|
|
40
|
-
return crypto.randomBytes(32).toString("base64url");
|
|
41
|
-
}
|
|
42
|
-
function generateCodeChallenge(verifier) {
|
|
43
|
-
return crypto.createHash("sha256").update(verifier).digest("base64url");
|
|
44
|
-
}
|
|
45
|
-
// ── OAuth Flow ─────────────────────────────────────────────────────────
|
|
46
|
-
/**
|
|
47
|
-
* Run the full OAuth PKCE flow:
|
|
48
|
-
* 1. Start a temp HTTP server on an ephemeral port
|
|
49
|
-
* 2. Open browser to Linear authorize URL
|
|
50
|
-
* 3. Wait for callback with auth code
|
|
51
|
-
* 4. Exchange code for tokens
|
|
52
|
-
* 5. Fetch org info
|
|
53
|
-
* 6. Store tokens
|
|
54
|
-
* Returns the org slug on success, null on failure.
|
|
55
|
-
*/
|
|
56
|
-
export async function startOAuthFlow() {
|
|
57
|
-
const codeVerifier = generateCodeVerifier();
|
|
58
|
-
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
59
|
-
const state = crypto.randomBytes(16).toString("hex");
|
|
60
|
-
return new Promise((resolve) => {
|
|
61
|
-
let handled = false;
|
|
62
|
-
const server = http.createServer(async (req, res) => {
|
|
63
|
-
const url = new URL(req.url, `http://localhost`);
|
|
64
|
-
const code = url.searchParams.get("code");
|
|
65
|
-
const returnedState = url.searchParams.get("state");
|
|
66
|
-
if (!code || returnedState !== state) {
|
|
67
|
-
// Ignore spurious requests (favicon, etc.)
|
|
68
|
-
res.writeHead(404);
|
|
69
|
-
res.end();
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
72
|
-
if (handled) {
|
|
73
|
-
res.writeHead(200);
|
|
74
|
-
res.end();
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
handled = true;
|
|
78
|
-
// Send success page immediately
|
|
79
|
-
res.writeHead(200, { "Content-Type": "text/html" });
|
|
80
|
-
res.end("<html><body><h2>Authentication successful!</h2><p>You can close this tab.</p></body></html>");
|
|
81
|
-
try {
|
|
82
|
-
// Exchange code for tokens
|
|
83
|
-
const tokens = await exchangeCode(code, REDIRECT_URI, codeVerifier);
|
|
84
|
-
// Fetch org info
|
|
85
|
-
const orgInfo = await fetchViewerOrg(tokens.access_token);
|
|
86
|
-
if (!orgInfo) {
|
|
87
|
-
server.close();
|
|
88
|
-
resolve(null);
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
// Store tokens
|
|
92
|
-
const store = readAuthStore();
|
|
93
|
-
store[orgInfo.urlKey] = {
|
|
94
|
-
access_token: tokens.access_token,
|
|
95
|
-
refresh_token: tokens.refresh_token,
|
|
96
|
-
expires_at: tokens.expires_at,
|
|
97
|
-
org_name: orgInfo.name,
|
|
98
|
-
};
|
|
99
|
-
writeAuthStore(store);
|
|
100
|
-
server.close();
|
|
101
|
-
resolve({ orgSlug: orgInfo.urlKey, orgName: orgInfo.name });
|
|
102
|
-
}
|
|
103
|
-
catch {
|
|
104
|
-
server.close();
|
|
105
|
-
resolve(null);
|
|
106
|
-
}
|
|
107
|
-
});
|
|
108
|
-
server.listen(OAUTH_PORT, () => {
|
|
109
|
-
const params = new URLSearchParams({
|
|
110
|
-
client_id: CLIENT_ID,
|
|
111
|
-
redirect_uri: REDIRECT_URI,
|
|
112
|
-
response_type: "code",
|
|
113
|
-
scope: "read",
|
|
114
|
-
state,
|
|
115
|
-
code_challenge: codeChallenge,
|
|
116
|
-
code_challenge_method: "S256",
|
|
117
|
-
});
|
|
118
|
-
const authUrl = `${LINEAR_AUTHORIZE_URL}?${params.toString()}`;
|
|
119
|
-
// Try to open browser, fall back to printing URL
|
|
120
|
-
const openCmd = process.platform === "darwin"
|
|
121
|
-
? "open"
|
|
122
|
-
: process.platform === "win32"
|
|
123
|
-
? "start"
|
|
124
|
-
: "xdg-open";
|
|
125
|
-
exec(`${openCmd} "${authUrl}"`, (err) => {
|
|
126
|
-
if (err) {
|
|
127
|
-
console.error(`\nCouldn't open browser automatically. Open this URL manually:\n${authUrl}\n`);
|
|
128
|
-
}
|
|
129
|
-
});
|
|
130
|
-
});
|
|
131
|
-
// Timeout after 2 minutes
|
|
132
|
-
setTimeout(() => {
|
|
133
|
-
server.close();
|
|
134
|
-
resolve(null);
|
|
135
|
-
}, 120_000);
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
async function exchangeCode(code, redirectUri, codeVerifier) {
|
|
139
|
-
const res = await fetch(LINEAR_TOKEN_URL, {
|
|
140
|
-
method: "POST",
|
|
141
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
142
|
-
body: new URLSearchParams({
|
|
143
|
-
grant_type: "authorization_code",
|
|
144
|
-
client_id: CLIENT_ID,
|
|
145
|
-
code,
|
|
146
|
-
redirect_uri: redirectUri,
|
|
147
|
-
code_verifier: codeVerifier,
|
|
148
|
-
}),
|
|
149
|
-
});
|
|
150
|
-
if (!res.ok) {
|
|
151
|
-
throw new Error(`Token exchange failed: ${res.status}`);
|
|
152
|
-
}
|
|
153
|
-
const data = (await res.json());
|
|
154
|
-
return {
|
|
155
|
-
access_token: data.access_token,
|
|
156
|
-
refresh_token: data.refresh_token,
|
|
157
|
-
expires_at: Date.now() + data.expires_in * 1000,
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
async function fetchViewerOrg(accessToken) {
|
|
161
|
-
const result = await graphqlQuery(`query { viewer { organization { urlKey name } } }`, {}, accessToken);
|
|
162
|
-
if (!result?.viewer?.organization)
|
|
163
|
-
return null;
|
|
164
|
-
return result.viewer.organization;
|
|
165
|
-
}
|
|
166
|
-
// ── Token Management ───────────────────────────────────────────────────
|
|
167
|
-
function isTokenExpired(tokens) {
|
|
168
|
-
// 5-minute buffer
|
|
169
|
-
return Date.now() >= tokens.expires_at - 5 * 60 * 1000;
|
|
170
|
-
}
|
|
171
|
-
async function refreshTokens(orgSlug, tokens) {
|
|
172
|
-
try {
|
|
173
|
-
const res = await fetch(LINEAR_TOKEN_URL, {
|
|
174
|
-
method: "POST",
|
|
175
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
176
|
-
body: new URLSearchParams({
|
|
177
|
-
grant_type: "refresh_token",
|
|
178
|
-
client_id: CLIENT_ID,
|
|
179
|
-
refresh_token: tokens.refresh_token,
|
|
180
|
-
}),
|
|
181
|
-
});
|
|
182
|
-
if (!res.ok)
|
|
183
|
-
return null;
|
|
184
|
-
const data = (await res.json());
|
|
185
|
-
const updated = {
|
|
186
|
-
access_token: data.access_token,
|
|
187
|
-
refresh_token: data.refresh_token,
|
|
188
|
-
expires_at: Date.now() + data.expires_in * 1000,
|
|
189
|
-
org_name: tokens.org_name,
|
|
190
|
-
};
|
|
191
|
-
// Persist refreshed tokens
|
|
192
|
-
const store = readAuthStore();
|
|
193
|
-
store[orgSlug] = updated;
|
|
194
|
-
writeAuthStore(store);
|
|
195
|
-
return updated;
|
|
196
|
-
}
|
|
197
|
-
catch {
|
|
198
|
-
return null;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
export async function revokeTokens(orgSlug) {
|
|
202
|
-
const store = readAuthStore();
|
|
203
|
-
const tokens = store[orgSlug];
|
|
204
|
-
if (!tokens)
|
|
205
|
-
return false;
|
|
206
|
-
try {
|
|
207
|
-
await fetch(LINEAR_REVOKE_URL, {
|
|
208
|
-
method: "POST",
|
|
209
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
210
|
-
body: new URLSearchParams({
|
|
211
|
-
client_id: CLIENT_ID,
|
|
212
|
-
token: tokens.access_token,
|
|
213
|
-
}),
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
catch {
|
|
217
|
-
// Best effort revocation
|
|
218
|
-
}
|
|
219
|
-
delete store[orgSlug];
|
|
220
|
-
writeAuthStore(store);
|
|
221
|
-
return true;
|
|
222
|
-
}
|
|
223
|
-
/**
|
|
224
|
-
* Get valid tokens for an org, auto-refreshing if expired.
|
|
225
|
-
* Returns null if no tokens found or refresh fails.
|
|
226
|
-
*/
|
|
227
|
-
export async function getValidTokens(orgSlug) {
|
|
228
|
-
const store = readAuthStore();
|
|
229
|
-
const tokens = store[orgSlug];
|
|
230
|
-
if (!tokens)
|
|
231
|
-
return null;
|
|
232
|
-
if (isTokenExpired(tokens)) {
|
|
233
|
-
return refreshTokens(orgSlug, tokens);
|
|
234
|
-
}
|
|
235
|
-
return tokens;
|
|
236
|
-
}
|
|
237
|
-
// ── GraphQL ────────────────────────────────────────────────────────────
|
|
238
|
-
async function graphqlQuery(query, variables, accessToken) {
|
|
239
|
-
const res = await fetch(LINEAR_GRAPHQL_URL, {
|
|
240
|
-
method: "POST",
|
|
241
|
-
headers: {
|
|
242
|
-
"Content-Type": "application/json",
|
|
243
|
-
Authorization: `Bearer ${accessToken}`,
|
|
244
|
-
},
|
|
245
|
-
body: JSON.stringify({ query, variables }),
|
|
246
|
-
});
|
|
247
|
-
if (!res.ok)
|
|
248
|
-
return null;
|
|
249
|
-
const json = (await res.json());
|
|
250
|
-
if (json.errors) {
|
|
251
|
-
console.error("Linear GraphQL errors:", JSON.stringify(json.errors, null, 2));
|
|
252
|
-
}
|
|
253
|
-
return json.data ?? null;
|
|
254
|
-
}
|
|
255
|
-
const ISSUE_QUERY = `
|
|
256
|
-
query GetIssue($id: String!) {
|
|
257
|
-
issue(id: $id) {
|
|
258
|
-
identifier
|
|
259
|
-
title
|
|
260
|
-
description
|
|
261
|
-
url
|
|
262
|
-
state { name }
|
|
263
|
-
priority
|
|
264
|
-
labels { nodes { name } }
|
|
265
|
-
comments {
|
|
266
|
-
nodes {
|
|
267
|
-
body
|
|
268
|
-
createdAt
|
|
269
|
-
parent { id }
|
|
270
|
-
user { displayName }
|
|
271
|
-
children {
|
|
272
|
-
nodes {
|
|
273
|
-
body
|
|
274
|
-
createdAt
|
|
275
|
-
user { displayName }
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
`;
|
|
283
|
-
const PRIORITY_MAP = {
|
|
284
|
-
0: "No priority",
|
|
285
|
-
1: "Urgent",
|
|
286
|
-
2: "High",
|
|
287
|
-
3: "Medium",
|
|
288
|
-
4: "Low",
|
|
289
|
-
};
|
|
290
|
-
async function fetchIssue(ticketId, accessToken) {
|
|
291
|
-
const data = await graphqlQuery(ISSUE_QUERY, { id: ticketId }, accessToken);
|
|
292
|
-
if (!data?.issue)
|
|
293
|
-
return null;
|
|
294
|
-
const issue = data.issue;
|
|
295
|
-
return {
|
|
296
|
-
identifier: issue.identifier,
|
|
297
|
-
title: issue.title,
|
|
298
|
-
description: issue.description ?? null,
|
|
299
|
-
status: issue.state?.name ?? null,
|
|
300
|
-
priority: PRIORITY_MAP[issue.priority] ?? null,
|
|
301
|
-
labels: (issue.labels?.nodes ?? []).map((l) => l.name),
|
|
302
|
-
url: issue.url,
|
|
303
|
-
comments: (issue.comments?.nodes ?? [])
|
|
304
|
-
.filter((c) => !c.parent)
|
|
305
|
-
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
|
306
|
-
.map((c) => ({
|
|
307
|
-
author: c.user?.displayName ?? "Unknown",
|
|
308
|
-
body: c.body,
|
|
309
|
-
createdAt: c.createdAt,
|
|
310
|
-
children: (c.children?.nodes ?? [])
|
|
311
|
-
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
|
312
|
-
.map((r) => ({
|
|
313
|
-
author: r.user?.displayName ?? "Unknown",
|
|
314
|
-
body: r.body,
|
|
315
|
-
createdAt: r.createdAt,
|
|
316
|
-
children: [],
|
|
317
|
-
})),
|
|
318
|
-
})),
|
|
319
|
-
};
|
|
320
|
-
}
|
|
321
|
-
// ── Image Handling ─────────────────────────────────────────────────────
|
|
322
|
-
function getTempImageDir(ticketId) {
|
|
323
|
-
return path.join(os.tmpdir(), `santree-images-${ticketId}`);
|
|
324
|
-
}
|
|
325
|
-
async function downloadImages(markdown, ticketId, accessToken) {
|
|
326
|
-
const imageRegex = /!\[([^\]]*)\]\((https:\/\/uploads\.linear\.app[^)]+)\)/g;
|
|
327
|
-
const matches = [...markdown.matchAll(imageRegex)];
|
|
328
|
-
if (matches.length === 0)
|
|
329
|
-
return markdown;
|
|
330
|
-
const tempDir = getTempImageDir(ticketId);
|
|
331
|
-
if (!fs.existsSync(tempDir)) {
|
|
332
|
-
fs.mkdirSync(tempDir, { recursive: true });
|
|
333
|
-
}
|
|
334
|
-
let result = markdown;
|
|
335
|
-
for (let i = 0; i < matches.length; i++) {
|
|
336
|
-
const match = matches[i];
|
|
337
|
-
const [fullMatch, altText, url] = match;
|
|
338
|
-
try {
|
|
339
|
-
const res = await fetch(url, {
|
|
340
|
-
headers: { Authorization: `Bearer ${accessToken}` },
|
|
341
|
-
});
|
|
342
|
-
if (!res.ok)
|
|
343
|
-
continue;
|
|
344
|
-
const buffer = Buffer.from(await res.arrayBuffer());
|
|
345
|
-
const ext = path.extname(new URL(url).pathname) || ".png";
|
|
346
|
-
const filename = `image-${i}${ext}`;
|
|
347
|
-
const filePath = path.join(tempDir, filename);
|
|
348
|
-
fs.writeFileSync(filePath, buffer);
|
|
349
|
-
result = result.replace(fullMatch, ``);
|
|
350
|
-
}
|
|
351
|
-
catch {
|
|
352
|
-
// Keep original URL on failure
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
return result;
|
|
356
|
-
}
|
|
357
|
-
export function cleanupImages(ticketId) {
|
|
358
|
-
const tempDir = getTempImageDir(ticketId);
|
|
359
|
-
if (fs.existsSync(tempDir)) {
|
|
360
|
-
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
/**
|
|
364
|
-
* Get auth status for the current repo's Linear org (or any stored org).
|
|
365
|
-
*/
|
|
366
|
-
export function getAuthStatus(repoRoot) {
|
|
367
|
-
const store = readAuthStore();
|
|
368
|
-
const orgs = Object.keys(store);
|
|
369
|
-
if (orgs.length === 0) {
|
|
370
|
-
return { authenticated: false };
|
|
371
|
-
}
|
|
372
|
-
// Check repo-specific org first
|
|
373
|
-
if (repoRoot) {
|
|
374
|
-
const repoOrg = getRepoLinearOrg(repoRoot);
|
|
375
|
-
if (repoOrg && store[repoOrg]) {
|
|
376
|
-
const tokens = store[repoOrg];
|
|
377
|
-
return {
|
|
378
|
-
authenticated: true,
|
|
379
|
-
orgSlug: repoOrg,
|
|
380
|
-
orgName: tokens.org_name,
|
|
381
|
-
expiresAt: tokens.expires_at,
|
|
382
|
-
repoLinked: true,
|
|
383
|
-
};
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
// Fall back to first stored org
|
|
387
|
-
const orgSlug = orgs[0];
|
|
388
|
-
const tokens = store[orgSlug];
|
|
389
|
-
return {
|
|
390
|
-
authenticated: true,
|
|
391
|
-
orgSlug,
|
|
392
|
-
orgName: tokens.org_name,
|
|
393
|
-
expiresAt: tokens.expires_at,
|
|
394
|
-
repoLinked: false,
|
|
395
|
-
};
|
|
396
|
-
}
|
|
397
|
-
// ── Assigned Issues Query ──────────────────────────────────────────────
|
|
398
|
-
const ASSIGNED_ISSUES_QUERY = `
|
|
399
|
-
query AssignedIssues {
|
|
400
|
-
viewer {
|
|
401
|
-
assignedIssues(
|
|
402
|
-
filter: { state: { type: { nin: ["completed", "canceled"] } } }
|
|
403
|
-
orderBy: updatedAt
|
|
404
|
-
first: 100
|
|
405
|
-
) {
|
|
406
|
-
nodes {
|
|
407
|
-
identifier
|
|
408
|
-
title
|
|
409
|
-
description
|
|
410
|
-
url
|
|
411
|
-
priority
|
|
412
|
-
state { name type }
|
|
413
|
-
labels { nodes { name } }
|
|
414
|
-
project { id name }
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
`;
|
|
420
|
-
/**
|
|
421
|
-
* Fetch all active issues assigned to the current user.
|
|
422
|
-
* Returns null if not authenticated or fetch fails.
|
|
423
|
-
*/
|
|
424
|
-
export async function fetchAssignedIssues(repoRoot) {
|
|
425
|
-
const orgSlug = getRepoLinearOrg(repoRoot);
|
|
426
|
-
if (!orgSlug)
|
|
427
|
-
return null;
|
|
428
|
-
const tokens = await getValidTokens(orgSlug);
|
|
429
|
-
if (!tokens)
|
|
430
|
-
return null;
|
|
431
|
-
const data = await graphqlQuery(ASSIGNED_ISSUES_QUERY, {}, tokens.access_token);
|
|
432
|
-
if (!data?.viewer?.assignedIssues?.nodes)
|
|
433
|
-
return null;
|
|
434
|
-
return data.viewer.assignedIssues.nodes.map((issue) => ({
|
|
435
|
-
identifier: issue.identifier,
|
|
436
|
-
title: issue.title,
|
|
437
|
-
description: issue.description ?? null,
|
|
438
|
-
url: issue.url,
|
|
439
|
-
priority: issue.priority,
|
|
440
|
-
priorityLabel: PRIORITY_MAP[issue.priority] ?? "No priority",
|
|
441
|
-
state: {
|
|
442
|
-
name: issue.state?.name ?? "Unknown",
|
|
443
|
-
type: issue.state?.type ?? "unstarted",
|
|
444
|
-
},
|
|
445
|
-
labels: (issue.labels?.nodes ?? []).map((l) => l.name),
|
|
446
|
-
projectId: issue.project?.id ?? null,
|
|
447
|
-
projectName: issue.project?.name ?? null,
|
|
448
|
-
}));
|
|
449
|
-
}
|
|
450
|
-
// ── High-Level Entry Point ─────────────────────────────────────────────
|
|
451
|
-
/**
|
|
452
|
-
* Fetch full ticket content for a given ticket ID.
|
|
453
|
-
* Looks up the repo's Linear org, gets valid tokens, fetches issue, downloads images.
|
|
454
|
-
* Returns null if not authenticated or fetch fails.
|
|
455
|
-
*/
|
|
456
|
-
export async function getTicketContent(ticketId, repoRoot) {
|
|
457
|
-
const orgSlug = getRepoLinearOrg(repoRoot);
|
|
458
|
-
if (!orgSlug)
|
|
459
|
-
return null;
|
|
460
|
-
const tokens = await getValidTokens(orgSlug);
|
|
461
|
-
if (!tokens)
|
|
462
|
-
return null;
|
|
463
|
-
const issue = await fetchIssue(ticketId, tokens.access_token);
|
|
464
|
-
if (!issue)
|
|
465
|
-
return null;
|
|
466
|
-
// Download images from description
|
|
467
|
-
if (issue.description) {
|
|
468
|
-
issue.description = await downloadImages(issue.description, ticketId, tokens.access_token);
|
|
469
|
-
}
|
|
470
|
-
// Download images from comments and replies
|
|
471
|
-
for (const comment of issue.comments) {
|
|
472
|
-
if (comment.body) {
|
|
473
|
-
comment.body = await downloadImages(comment.body, ticketId, tokens.access_token);
|
|
474
|
-
}
|
|
475
|
-
for (const child of comment.children) {
|
|
476
|
-
if (child.body) {
|
|
477
|
-
child.body = await downloadImages(child.body, ticketId, tokens.access_token);
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
return issue;
|
|
482
|
-
}
|