@fink-andreas/pi-linear-tools 0.5.1 → 0.7.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/CHANGELOG.md +38 -0
- package/README.md +44 -16
- package/extensions/pi-linear-tools.js +210 -27
- package/package.json +2 -2
- package/settings.json.example +6 -2
- package/src/cli.js +101 -8
- package/src/handlers.js +405 -18
- package/src/linear-client.js +120 -14
- package/src/linear.js +319 -26
- package/src/settings.js +22 -0
package/src/linear.js
CHANGED
|
@@ -235,6 +235,45 @@ const PROJECT_UPDATE_MUTATION = `
|
|
|
235
235
|
}
|
|
236
236
|
`;
|
|
237
237
|
|
|
238
|
+
|
|
239
|
+
const MILESTONE_DETAILS_QUERY = `
|
|
240
|
+
query MilestoneDetails($id: String!, $issueLimit: Int!) {
|
|
241
|
+
projectMilestone(id: $id) {
|
|
242
|
+
id
|
|
243
|
+
name
|
|
244
|
+
description
|
|
245
|
+
progress
|
|
246
|
+
sortOrder
|
|
247
|
+
targetDate
|
|
248
|
+
status
|
|
249
|
+
project {
|
|
250
|
+
id
|
|
251
|
+
name
|
|
252
|
+
}
|
|
253
|
+
issues(first: $issueLimit) {
|
|
254
|
+
nodes {
|
|
255
|
+
id
|
|
256
|
+
identifier
|
|
257
|
+
title
|
|
258
|
+
priority
|
|
259
|
+
estimate
|
|
260
|
+
state {
|
|
261
|
+
id
|
|
262
|
+
name
|
|
263
|
+
color
|
|
264
|
+
type
|
|
265
|
+
}
|
|
266
|
+
assignee {
|
|
267
|
+
id
|
|
268
|
+
name
|
|
269
|
+
displayName
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
`;
|
|
276
|
+
|
|
238
277
|
const PROJECT_DELETE_MUTATION = `
|
|
239
278
|
mutation ProjectDelete($id: String!) {
|
|
240
279
|
projectDelete(id: $id) {
|
|
@@ -249,7 +288,7 @@ const PROJECT_DELETE_MUTATION = `
|
|
|
249
288
|
|
|
250
289
|
const PROJECT_ARCHIVE_MUTATION = `
|
|
251
290
|
mutation ProjectArchive($id: String!) {
|
|
252
|
-
projectArchiveResult:
|
|
291
|
+
projectArchiveResult: projectArchive(id: $id) {
|
|
253
292
|
success
|
|
254
293
|
entity {
|
|
255
294
|
id
|
|
@@ -1253,7 +1292,11 @@ async function fetchProjectMilestonesByQuery(client, projectId, options = {}) {
|
|
|
1253
1292
|
* Track rate limit status from API responses
|
|
1254
1293
|
* Linear API returns headers: X-RateLimit-Requests-Remaining, X-RateLimit-Requests-Reset
|
|
1255
1294
|
*/
|
|
1295
|
+
const DEFAULT_REQUEST_LIMIT = 5000;
|
|
1296
|
+
const LOW_RATE_LIMIT_THRESHOLD = 0.10;
|
|
1297
|
+
|
|
1256
1298
|
const rateLimitState = {
|
|
1299
|
+
limit: DEFAULT_REQUEST_LIMIT,
|
|
1257
1300
|
remaining: null,
|
|
1258
1301
|
resetAt: null,
|
|
1259
1302
|
lastWarnAt: 0,
|
|
@@ -1268,14 +1311,21 @@ function updateRateLimitState(response) {
|
|
|
1268
1311
|
|
|
1269
1312
|
const headers = response.headers;
|
|
1270
1313
|
if (headers) {
|
|
1314
|
+
const limit = headers.get('X-RateLimit-Requests-Limit');
|
|
1271
1315
|
const remaining = headers.get('X-RateLimit-Requests-Remaining');
|
|
1272
1316
|
const resetAt = headers.get('X-RateLimit-Requests-Reset');
|
|
1273
1317
|
|
|
1318
|
+
if (limit !== null) {
|
|
1319
|
+
const parsedLimit = parseInt(limit, 10);
|
|
1320
|
+
if (Number.isFinite(parsedLimit)) rateLimitState.limit = parsedLimit;
|
|
1321
|
+
}
|
|
1274
1322
|
if (remaining !== null) {
|
|
1275
|
-
|
|
1323
|
+
const parsedRemaining = parseInt(remaining, 10);
|
|
1324
|
+
if (Number.isFinite(parsedRemaining)) rateLimitState.remaining = parsedRemaining;
|
|
1276
1325
|
}
|
|
1277
1326
|
if (resetAt !== null) {
|
|
1278
|
-
|
|
1327
|
+
const parsedResetAt = parseInt(resetAt, 10);
|
|
1328
|
+
if (Number.isFinite(parsedResetAt)) rateLimitState.resetAt = parsedResetAt;
|
|
1279
1329
|
}
|
|
1280
1330
|
}
|
|
1281
1331
|
}
|
|
@@ -1286,6 +1336,7 @@ function updateRateLimitState(response) {
|
|
|
1286
1336
|
*/
|
|
1287
1337
|
export function getRateLimitStatus() {
|
|
1288
1338
|
const result = {
|
|
1339
|
+
limit: rateLimitState.limit,
|
|
1289
1340
|
remaining: rateLimitState.remaining,
|
|
1290
1341
|
resetAt: rateLimitState.resetAt,
|
|
1291
1342
|
resetTime: null,
|
|
@@ -1296,9 +1347,10 @@ export function getRateLimitStatus() {
|
|
|
1296
1347
|
if (rateLimitState.resetAt) {
|
|
1297
1348
|
result.resetTime = new Date(rateLimitState.resetAt).toLocaleTimeString();
|
|
1298
1349
|
const remaining = rateLimitState.remaining;
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
result.
|
|
1350
|
+
const limit = rateLimitState.limit || DEFAULT_REQUEST_LIMIT;
|
|
1351
|
+
if (remaining !== null) {
|
|
1352
|
+
result.usagePercent = Math.round((Math.max(0, limit - remaining) / limit) * 100);
|
|
1353
|
+
result.shouldWarn = remaining <= Math.max(1, Math.floor(limit * LOW_RATE_LIMIT_THRESHOLD));
|
|
1302
1354
|
}
|
|
1303
1355
|
}
|
|
1304
1356
|
|
|
@@ -1317,6 +1369,7 @@ function checkRateLimitWarning() {
|
|
|
1317
1369
|
if (status.shouldWarn && status.remaining !== null) {
|
|
1318
1370
|
rateLimitState.lastWarnAt = now;
|
|
1319
1371
|
warn(`Linear API rate limit running low: ${status.remaining} requests remaining (~${status.usagePercent}% used). Resets at ${status.resetTime}`, {
|
|
1372
|
+
limit: status.limit,
|
|
1320
1373
|
remaining: status.remaining,
|
|
1321
1374
|
resetAt: status.resetTime,
|
|
1322
1375
|
usagePercent: status.usagePercent,
|
|
@@ -1331,7 +1384,7 @@ function checkRateLimitWarning() {
|
|
|
1331
1384
|
* @param {Error} error - The error to check
|
|
1332
1385
|
* @returns {boolean}
|
|
1333
1386
|
*/
|
|
1334
|
-
function isLinearError(error) {
|
|
1387
|
+
export function isLinearError(error) {
|
|
1335
1388
|
return error?.constructor?.name?.includes('LinearError') ||
|
|
1336
1389
|
error?.name?.includes('LinearError') ||
|
|
1337
1390
|
error?.type?.startsWith?.('Ratelimited') ||
|
|
@@ -1342,6 +1395,19 @@ function isLinearError(error) {
|
|
|
1342
1395
|
error?.type === 'InternalError';
|
|
1343
1396
|
}
|
|
1344
1397
|
|
|
1398
|
+
|
|
1399
|
+
/**
|
|
1400
|
+
* Check if an error is a rate-limit error
|
|
1401
|
+
* @param {Error} error - The error to check
|
|
1402
|
+
* @returns {boolean}
|
|
1403
|
+
*/
|
|
1404
|
+
export function isRateLimitError(error) {
|
|
1405
|
+
return (
|
|
1406
|
+
isLinearError(error) &&
|
|
1407
|
+
(error?.type === 'Ratelimited' || String(error?.message || '').toLowerCase().includes('rate limit'))
|
|
1408
|
+
);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1345
1411
|
/**
|
|
1346
1412
|
* Format a Linear API error into a user-friendly message
|
|
1347
1413
|
* @param {Error} error - The original error
|
|
@@ -1536,6 +1602,37 @@ function normalizeProjectUpdateHealth(value) {
|
|
|
1536
1602
|
return normalized;
|
|
1537
1603
|
}
|
|
1538
1604
|
|
|
1605
|
+
const ISSUE_PRIORITY_NAMES = ['No priority', 'Urgent', 'High', 'Medium', 'Low'];
|
|
1606
|
+
const ISSUE_PRIORITY_ALIASES = Object.freeze({
|
|
1607
|
+
none: 0,
|
|
1608
|
+
urgent: 1,
|
|
1609
|
+
high: 2,
|
|
1610
|
+
medium: 3,
|
|
1611
|
+
low: 4,
|
|
1612
|
+
});
|
|
1613
|
+
const ISSUE_PRIORITY_MAPPING_DESCRIPTION = '0=None, 1=Urgent, 2=High, 3=Medium, 4=Low';
|
|
1614
|
+
const ISSUE_PRIORITY_ALIAS_DESCRIPTION = Object.keys(ISSUE_PRIORITY_ALIASES).join(', ');
|
|
1615
|
+
|
|
1616
|
+
function parseIssuePriority(value) {
|
|
1617
|
+
if (typeof value === 'number') {
|
|
1618
|
+
if (Number.isInteger(value) && value >= 0 && value <= 4) {
|
|
1619
|
+
return value;
|
|
1620
|
+
}
|
|
1621
|
+
} else if (typeof value === 'string') {
|
|
1622
|
+
const normalized = value.trim().toLowerCase();
|
|
1623
|
+
if (Object.prototype.hasOwnProperty.call(ISSUE_PRIORITY_ALIASES, normalized)) {
|
|
1624
|
+
return ISSUE_PRIORITY_ALIASES[normalized];
|
|
1625
|
+
}
|
|
1626
|
+
if (/^[0-4]$/.test(normalized)) {
|
|
1627
|
+
return Number(normalized);
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
throw new Error(
|
|
1632
|
+
`Invalid priority: ${value}. Use Linear priority ${ISSUE_PRIORITY_MAPPING_DESCRIPTION}, or one of: ${ISSUE_PRIORITY_ALIAS_DESCRIPTION}.`
|
|
1633
|
+
);
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1539
1636
|
function formatPriorityLabel(value) {
|
|
1540
1637
|
if (value === undefined || value === null) {
|
|
1541
1638
|
return null;
|
|
@@ -1546,8 +1643,7 @@ function formatPriorityLabel(value) {
|
|
|
1546
1643
|
return String(value);
|
|
1547
1644
|
}
|
|
1548
1645
|
|
|
1549
|
-
|
|
1550
|
-
return priorityNames[numeric] || `Priority ${numeric}`;
|
|
1646
|
+
return ISSUE_PRIORITY_NAMES[numeric] || `Priority ${numeric}`;
|
|
1551
1647
|
}
|
|
1552
1648
|
|
|
1553
1649
|
function getUserDisplayName(user) {
|
|
@@ -2807,6 +2903,145 @@ export async function fetchIssueDetails(client, issueRef, options = {}) {
|
|
|
2807
2903
|
}, 'fetchIssueDetails');
|
|
2808
2904
|
}
|
|
2809
2905
|
|
|
2906
|
+
function extractMarkdownImages(markdown, source) {
|
|
2907
|
+
if (!markdown || typeof markdown !== 'string') return [];
|
|
2908
|
+
|
|
2909
|
+
const images = [];
|
|
2910
|
+
const markdownImagePattern = /!\[([^\]]*)\]\(([^\s)]+)(?:\s+"[^"]*")?\)/g;
|
|
2911
|
+
const htmlImagePattern = /<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi;
|
|
2912
|
+
|
|
2913
|
+
let match;
|
|
2914
|
+
while ((match = markdownImagePattern.exec(markdown)) !== null) {
|
|
2915
|
+
images.push({ alt: match[1] || null, url: match[2], source });
|
|
2916
|
+
}
|
|
2917
|
+
while ((match = htmlImagePattern.exec(markdown)) !== null) {
|
|
2918
|
+
images.push({ alt: null, url: match[1], source });
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
return images;
|
|
2922
|
+
}
|
|
2923
|
+
|
|
2924
|
+
function isImageUrl(url) {
|
|
2925
|
+
try {
|
|
2926
|
+
const parsed = new URL(url);
|
|
2927
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
2928
|
+
} catch {
|
|
2929
|
+
return false;
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2933
|
+
function isLinearUploadUrl(url) {
|
|
2934
|
+
try {
|
|
2935
|
+
const hostname = new URL(url).hostname.toLowerCase();
|
|
2936
|
+
return hostname === 'uploads.linear.app' || hostname.endsWith('.uploads.linear.app');
|
|
2937
|
+
} catch {
|
|
2938
|
+
return false;
|
|
2939
|
+
}
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2942
|
+
function getLinearAuthHeaderValue(client, mode = 'raw') {
|
|
2943
|
+
const token = client?.__piLinearTrackerKey || client?.apiKey || null;
|
|
2944
|
+
if (!token || token === 'default') return null;
|
|
2945
|
+
return mode === 'bearer' ? `Bearer ${token}` : token;
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
async function fetchImageUrl(client, url, options = {}) {
|
|
2949
|
+
const { maxBytes = 10 * 1024 * 1024 } = options;
|
|
2950
|
+
const attempts = [{ headers: {} }];
|
|
2951
|
+
|
|
2952
|
+
if (isLinearUploadUrl(url)) {
|
|
2953
|
+
const rawAuth = getLinearAuthHeaderValue(client, 'raw');
|
|
2954
|
+
const bearerAuth = getLinearAuthHeaderValue(client, 'bearer');
|
|
2955
|
+
if (rawAuth) attempts.push({ headers: { authorization: rawAuth } });
|
|
2956
|
+
if (bearerAuth) attempts.push({ headers: { authorization: bearerAuth } });
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
let lastError = null;
|
|
2960
|
+
for (const attempt of attempts) {
|
|
2961
|
+
try {
|
|
2962
|
+
const response = await fetch(url, { headers: attempt.headers, redirect: 'follow' });
|
|
2963
|
+
if (!response.ok) {
|
|
2964
|
+
lastError = new Error(`HTTP ${response.status} ${response.statusText}`);
|
|
2965
|
+
continue;
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
const contentType = response.headers.get('content-type') || 'application/octet-stream';
|
|
2969
|
+
if (!contentType.toLowerCase().startsWith('image/')) {
|
|
2970
|
+
lastError = new Error(`URL did not return an image (content-type: ${contentType})`);
|
|
2971
|
+
continue;
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2974
|
+
const contentLength = Number(response.headers.get('content-length') || 0);
|
|
2975
|
+
if (contentLength > maxBytes) {
|
|
2976
|
+
throw new Error(`Image is too large (${contentLength} bytes, max ${maxBytes})`);
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
2980
|
+
if (buffer.length > maxBytes) {
|
|
2981
|
+
throw new Error(`Image is too large (${buffer.length} bytes, max ${maxBytes})`);
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
return {
|
|
2985
|
+
data: buffer.toString('base64'),
|
|
2986
|
+
mimeType: contentType.split(';')[0].trim() || 'application/octet-stream',
|
|
2987
|
+
sizeBytes: buffer.length,
|
|
2988
|
+
};
|
|
2989
|
+
} catch (error) {
|
|
2990
|
+
lastError = error;
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
throw lastError || new Error('Failed to fetch image');
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2997
|
+
export async function fetchIssueImages(client, issueRef, options = {}) {
|
|
2998
|
+
return withLinearErrorHandling(async () => {
|
|
2999
|
+
const { includeComments = true, limit = 10, maxBytes = 10 * 1024 * 1024 } = options;
|
|
3000
|
+
const issueData = await fetchIssueDetails(client, issueRef, { includeComments });
|
|
3001
|
+
const candidates = [];
|
|
3002
|
+
|
|
3003
|
+
candidates.push(...extractMarkdownImages(issueData.description, 'description'));
|
|
3004
|
+
if (includeComments) {
|
|
3005
|
+
for (const comment of issueData.comments || []) {
|
|
3006
|
+
candidates.push(...extractMarkdownImages(comment.body, `comment:${comment.id}`));
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
const seen = new Set();
|
|
3011
|
+
const uniqueCandidates = candidates
|
|
3012
|
+
.filter((candidate) => candidate.url && isImageUrl(candidate.url))
|
|
3013
|
+
.filter((candidate) => {
|
|
3014
|
+
if (seen.has(candidate.url)) return false;
|
|
3015
|
+
seen.add(candidate.url);
|
|
3016
|
+
return true;
|
|
3017
|
+
})
|
|
3018
|
+
.slice(0, limit);
|
|
3019
|
+
|
|
3020
|
+
const images = [];
|
|
3021
|
+
const failures = [];
|
|
3022
|
+
for (const candidate of uniqueCandidates) {
|
|
3023
|
+
try {
|
|
3024
|
+
// eslint-disable-next-line no-await-in-loop
|
|
3025
|
+
const image = await fetchImageUrl(client, candidate.url, { maxBytes });
|
|
3026
|
+
images.push({ ...candidate, ...image });
|
|
3027
|
+
} catch (error) {
|
|
3028
|
+
failures.push({ ...candidate, error: error?.message || String(error) });
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
return {
|
|
3033
|
+
issue: {
|
|
3034
|
+
identifier: issueData.identifier,
|
|
3035
|
+
title: issueData.title,
|
|
3036
|
+
url: issueData.url,
|
|
3037
|
+
},
|
|
3038
|
+
images,
|
|
3039
|
+
failures,
|
|
3040
|
+
totalCandidates: candidates.length,
|
|
3041
|
+
};
|
|
3042
|
+
}, 'fetchIssueImages');
|
|
3043
|
+
}
|
|
3044
|
+
|
|
2810
3045
|
export async function fetchIssueActivity(client, issueRef, options = {}) {
|
|
2811
3046
|
return withLinearErrorHandling(async () => {
|
|
2812
3047
|
const limit = normalizePositiveInteger(options.limit, 'limit', 20);
|
|
@@ -2913,7 +3148,7 @@ export async function setIssueState(client, issueId, stateId) {
|
|
|
2913
3148
|
* @param {string} input.title - Issue title (required)
|
|
2914
3149
|
* @param {string} [input.description] - Issue description
|
|
2915
3150
|
* @param {string} [input.projectId] - Project ID
|
|
2916
|
-
* @param {string} [input.priority] -
|
|
3151
|
+
* @param {number|string} [input.priority] - Issue priority: 0=None, 1=Urgent, 2=High, 3=Medium, 4=Low; or none/urgent/high/medium/low
|
|
2917
3152
|
* @param {string} [input.assigneeId] - Assignee ID
|
|
2918
3153
|
* @param {string} [input.parentId] - Parent issue ID for sub-issues
|
|
2919
3154
|
* @returns {Promise<Object>} Created issue
|
|
@@ -2944,11 +3179,7 @@ export async function createIssue(client, input) {
|
|
|
2944
3179
|
}
|
|
2945
3180
|
|
|
2946
3181
|
if (input.priority !== undefined) {
|
|
2947
|
-
|
|
2948
|
-
if (Number.isNaN(parsed) || parsed < 0 || parsed > 4) {
|
|
2949
|
-
throw new Error(`Invalid priority: ${input.priority}. Valid range: 0..4`);
|
|
2950
|
-
}
|
|
2951
|
-
createInput.priority = parsed;
|
|
3182
|
+
createInput.priority = parseIssuePriority(input.priority);
|
|
2952
3183
|
}
|
|
2953
3184
|
|
|
2954
3185
|
if (input.estimate !== undefined) {
|
|
@@ -3010,7 +3241,7 @@ export async function createIssue(client, input) {
|
|
|
3010
3241
|
title,
|
|
3011
3242
|
description: input.description ?? null,
|
|
3012
3243
|
url: null,
|
|
3013
|
-
priority:
|
|
3244
|
+
priority: createInput.priority ?? null,
|
|
3014
3245
|
state: null,
|
|
3015
3246
|
team: null,
|
|
3016
3247
|
project: null,
|
|
@@ -3024,7 +3255,7 @@ export async function createIssue(client, input) {
|
|
|
3024
3255
|
title,
|
|
3025
3256
|
description: input.description ?? null,
|
|
3026
3257
|
url: null,
|
|
3027
|
-
priority:
|
|
3258
|
+
priority: createInput.priority ?? null,
|
|
3028
3259
|
state: null,
|
|
3029
3260
|
team: null,
|
|
3030
3261
|
project: null,
|
|
@@ -3157,11 +3388,7 @@ export async function updateIssue(client, issueRef, patch = {}) {
|
|
|
3157
3388
|
}
|
|
3158
3389
|
|
|
3159
3390
|
if (patch.priority !== undefined) {
|
|
3160
|
-
|
|
3161
|
-
if (Number.isNaN(parsed) || parsed < 0 || parsed > 4) {
|
|
3162
|
-
throw new Error(`Invalid priority: ${patch.priority}. Valid range: 0..4`);
|
|
3163
|
-
}
|
|
3164
|
-
updateInput.priority = parsed;
|
|
3391
|
+
updateInput.priority = parseIssuePriority(patch.priority);
|
|
3165
3392
|
}
|
|
3166
3393
|
|
|
3167
3394
|
|
|
@@ -3527,24 +3754,90 @@ export async function fetchProjectMilestones(client, projectId) {
|
|
|
3527
3754
|
*/
|
|
3528
3755
|
export async function fetchMilestoneDetails(client, milestoneId) {
|
|
3529
3756
|
return withLinearErrorHandling(async () => {
|
|
3757
|
+
// Prefer raw GraphQL: one request instead of N+1 SDK lazy loads.
|
|
3758
|
+
// This dramatically reduces rate-limit exposure for milestone view.
|
|
3759
|
+
if (getRawRequest(client)) {
|
|
3760
|
+
try {
|
|
3761
|
+
const data = await executeGraphQL(client, MILESTONE_DETAILS_QUERY, {
|
|
3762
|
+
id: milestoneId,
|
|
3763
|
+
issueLimit: 250,
|
|
3764
|
+
});
|
|
3765
|
+
|
|
3766
|
+
const raw = data?.projectMilestone;
|
|
3767
|
+
if (!raw) {
|
|
3768
|
+
throw new Error(`Milestone not found: ${milestoneId}`);
|
|
3769
|
+
}
|
|
3770
|
+
|
|
3771
|
+
const project = raw.project ? { id: raw.project.id, name: raw.project.name } : null;
|
|
3772
|
+
const issues = (raw.issues?.nodes || []).map((issue) => ({
|
|
3773
|
+
id: issue.id,
|
|
3774
|
+
identifier: issue.identifier,
|
|
3775
|
+
title: issue.title,
|
|
3776
|
+
state: issue.state ? { name: issue.state.name, color: issue.state.color, type: issue.state.type } : null,
|
|
3777
|
+
assignee: issue.assignee ? { id: issue.assignee.id, name: issue.assignee.name, displayName: issue.assignee.displayName } : null,
|
|
3778
|
+
priority: issue.priority ?? null,
|
|
3779
|
+
estimate: issue.estimate ?? null,
|
|
3780
|
+
}));
|
|
3781
|
+
|
|
3782
|
+
return {
|
|
3783
|
+
id: raw.id,
|
|
3784
|
+
name: raw.name,
|
|
3785
|
+
description: raw.description ?? null,
|
|
3786
|
+
progress: raw.progress ?? null,
|
|
3787
|
+
order: raw.sortOrder ?? null,
|
|
3788
|
+
targetDate: raw.targetDate ?? null,
|
|
3789
|
+
status: raw.status ?? null,
|
|
3790
|
+
project,
|
|
3791
|
+
issues,
|
|
3792
|
+
};
|
|
3793
|
+
} catch (err) {
|
|
3794
|
+
// If raw GraphQL fails (e.g., network error), fall through to SDK path.
|
|
3795
|
+
// Rate-limit errors are re-thrown by executeGraphQL -> withLinearErrorHandling.
|
|
3796
|
+
if (!isLinearError(err)) {
|
|
3797
|
+
debug('fetchMilestoneDetails: raw GraphQL unavailable, falling back to SDK', { error: err?.message });
|
|
3798
|
+
} else {
|
|
3799
|
+
throw err; // re-throw Linear errors including rate limits
|
|
3800
|
+
}
|
|
3801
|
+
}
|
|
3802
|
+
}
|
|
3803
|
+
|
|
3804
|
+
// SDK fallback: the rate-limit propagation logic for lazy loads is preserved.
|
|
3530
3805
|
const milestone = await client.projectMilestone(milestoneId);
|
|
3531
3806
|
if (!milestone) {
|
|
3532
3807
|
throw new Error(`Milestone not found: ${milestoneId}`);
|
|
3533
3808
|
}
|
|
3534
3809
|
|
|
3535
3810
|
// Fetch project and issues in parallel
|
|
3536
|
-
const [
|
|
3811
|
+
const [projectResult, issuesResult] = await Promise.all([
|
|
3537
3812
|
milestone.project?.catch?.(() => null) ?? milestone.project,
|
|
3538
|
-
milestone.issues?.()?.catch?.(() =>
|
|
3813
|
+
milestone.issues?.()?.catch?.((err) => {
|
|
3814
|
+
if (isRateLimitError(err)) {
|
|
3815
|
+
// Propagate rate-limit errors so the caller can surface a safe user message
|
|
3816
|
+
// instead of silently swallowing the issue list.
|
|
3817
|
+
throw err;
|
|
3818
|
+
}
|
|
3819
|
+
return { nodes: [] };
|
|
3820
|
+
}) ?? { nodes: [] },
|
|
3539
3821
|
]);
|
|
3540
3822
|
|
|
3823
|
+
const project = projectResult;
|
|
3824
|
+
|
|
3541
3825
|
const issues = await Promise.all(
|
|
3542
3826
|
(issuesResult.nodes || []).map(async (issue) => {
|
|
3827
|
+
// Propagate rate-limit errors from per-issue lazy loads so the caller can surface
|
|
3828
|
+
// a safe user message. Silently degrading to partial data masks the problem.
|
|
3543
3829
|
const [state, assignee] = await Promise.all([
|
|
3544
|
-
issue.state?.catch?.(() =>
|
|
3545
|
-
|
|
3830
|
+
issue.state?.catch?.((err) => {
|
|
3831
|
+
if (isRateLimitError(err)) throw err;
|
|
3832
|
+
return null;
|
|
3833
|
+
}) ?? issue.state,
|
|
3834
|
+
issue.assignee?.catch?.((err) => {
|
|
3835
|
+
if (isRateLimitError(err)) throw err;
|
|
3836
|
+
return null;
|
|
3837
|
+
}) ?? issue.assignee,
|
|
3546
3838
|
]);
|
|
3547
3839
|
|
|
3840
|
+
|
|
3548
3841
|
return {
|
|
3549
3842
|
id: issue.id,
|
|
3550
3843
|
identifier: issue.identifier,
|
package/src/settings.js
CHANGED
|
@@ -21,6 +21,8 @@ export function getDefaultSettings() {
|
|
|
21
21
|
defaultTeam: null,
|
|
22
22
|
defaultWorkspace: null,
|
|
23
23
|
projects: {},
|
|
24
|
+
rateLimitDebug: false, // Enable detailed rate limit info per tool call
|
|
25
|
+
allow_overwrite_files: false, // Require explicit opt-in before tools can overwrite local files
|
|
24
26
|
};
|
|
25
27
|
}
|
|
26
28
|
|
|
@@ -111,6 +113,16 @@ function migrateSettings(settings) {
|
|
|
111
113
|
migrated.projects = {};
|
|
112
114
|
}
|
|
113
115
|
|
|
116
|
+
// Ensure rateLimitDebug is a boolean
|
|
117
|
+
if (migrated.rateLimitDebug === undefined) {
|
|
118
|
+
migrated.rateLimitDebug = false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Ensure local file overwrite guard is disabled by default
|
|
122
|
+
if (migrated.allow_overwrite_files === undefined) {
|
|
123
|
+
migrated.allow_overwrite_files = false;
|
|
124
|
+
}
|
|
125
|
+
|
|
114
126
|
// Migrate project scopes
|
|
115
127
|
for (const [projectId, cfg] of Object.entries(migrated.projects)) {
|
|
116
128
|
if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) {
|
|
@@ -216,6 +228,16 @@ export function validateSettings(settings) {
|
|
|
216
228
|
}
|
|
217
229
|
}
|
|
218
230
|
|
|
231
|
+
// Validate rateLimitDebug
|
|
232
|
+
if (settings.rateLimitDebug !== undefined && typeof settings.rateLimitDebug !== 'boolean') {
|
|
233
|
+
errors.push('settings.rateLimitDebug must be a boolean');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Validate local file overwrite guard
|
|
237
|
+
if (settings.allow_overwrite_files !== undefined && typeof settings.allow_overwrite_files !== 'boolean') {
|
|
238
|
+
errors.push('settings.allow_overwrite_files must be a boolean');
|
|
239
|
+
}
|
|
240
|
+
|
|
219
241
|
return { valid: errors.length === 0, errors };
|
|
220
242
|
}
|
|
221
243
|
|