@fink-andreas/pi-linear-tools 0.6.0 → 0.7.1
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 +31 -0
- package/README.md +20 -2
- package/extensions/pi-linear-tools.js +107 -18
- package/package.json +2 -2
- package/settings.json.example +6 -2
- package/src/cli.js +101 -8
- package/src/handlers.js +366 -1
- package/src/linear-client.js +72 -17
- package/src/linear.js +207 -21
- package/src/settings.js +11 -0
package/src/linear.js
CHANGED
|
@@ -288,7 +288,7 @@ const PROJECT_DELETE_MUTATION = `
|
|
|
288
288
|
|
|
289
289
|
const PROJECT_ARCHIVE_MUTATION = `
|
|
290
290
|
mutation ProjectArchive($id: String!) {
|
|
291
|
-
projectArchiveResult:
|
|
291
|
+
projectArchiveResult: projectArchive(id: $id) {
|
|
292
292
|
success
|
|
293
293
|
entity {
|
|
294
294
|
id
|
|
@@ -1292,7 +1292,11 @@ async function fetchProjectMilestonesByQuery(client, projectId, options = {}) {
|
|
|
1292
1292
|
* Track rate limit status from API responses
|
|
1293
1293
|
* Linear API returns headers: X-RateLimit-Requests-Remaining, X-RateLimit-Requests-Reset
|
|
1294
1294
|
*/
|
|
1295
|
+
const DEFAULT_REQUEST_LIMIT = 5000;
|
|
1296
|
+
const LOW_RATE_LIMIT_THRESHOLD = 0.10;
|
|
1297
|
+
|
|
1295
1298
|
const rateLimitState = {
|
|
1299
|
+
limit: DEFAULT_REQUEST_LIMIT,
|
|
1296
1300
|
remaining: null,
|
|
1297
1301
|
resetAt: null,
|
|
1298
1302
|
lastWarnAt: 0,
|
|
@@ -1307,14 +1311,21 @@ function updateRateLimitState(response) {
|
|
|
1307
1311
|
|
|
1308
1312
|
const headers = response.headers;
|
|
1309
1313
|
if (headers) {
|
|
1314
|
+
const limit = headers.get('X-RateLimit-Requests-Limit');
|
|
1310
1315
|
const remaining = headers.get('X-RateLimit-Requests-Remaining');
|
|
1311
1316
|
const resetAt = headers.get('X-RateLimit-Requests-Reset');
|
|
1312
1317
|
|
|
1318
|
+
if (limit !== null) {
|
|
1319
|
+
const parsedLimit = parseInt(limit, 10);
|
|
1320
|
+
if (Number.isFinite(parsedLimit)) rateLimitState.limit = parsedLimit;
|
|
1321
|
+
}
|
|
1313
1322
|
if (remaining !== null) {
|
|
1314
|
-
|
|
1323
|
+
const parsedRemaining = parseInt(remaining, 10);
|
|
1324
|
+
if (Number.isFinite(parsedRemaining)) rateLimitState.remaining = parsedRemaining;
|
|
1315
1325
|
}
|
|
1316
1326
|
if (resetAt !== null) {
|
|
1317
|
-
|
|
1327
|
+
const parsedResetAt = parseInt(resetAt, 10);
|
|
1328
|
+
if (Number.isFinite(parsedResetAt)) rateLimitState.resetAt = parsedResetAt;
|
|
1318
1329
|
}
|
|
1319
1330
|
}
|
|
1320
1331
|
}
|
|
@@ -1325,6 +1336,7 @@ function updateRateLimitState(response) {
|
|
|
1325
1336
|
*/
|
|
1326
1337
|
export function getRateLimitStatus() {
|
|
1327
1338
|
const result = {
|
|
1339
|
+
limit: rateLimitState.limit,
|
|
1328
1340
|
remaining: rateLimitState.remaining,
|
|
1329
1341
|
resetAt: rateLimitState.resetAt,
|
|
1330
1342
|
resetTime: null,
|
|
@@ -1335,9 +1347,10 @@ export function getRateLimitStatus() {
|
|
|
1335
1347
|
if (rateLimitState.resetAt) {
|
|
1336
1348
|
result.resetTime = new Date(rateLimitState.resetAt).toLocaleTimeString();
|
|
1337
1349
|
const remaining = rateLimitState.remaining;
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
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));
|
|
1341
1354
|
}
|
|
1342
1355
|
}
|
|
1343
1356
|
|
|
@@ -1356,6 +1369,7 @@ function checkRateLimitWarning() {
|
|
|
1356
1369
|
if (status.shouldWarn && status.remaining !== null) {
|
|
1357
1370
|
rateLimitState.lastWarnAt = now;
|
|
1358
1371
|
warn(`Linear API rate limit running low: ${status.remaining} requests remaining (~${status.usagePercent}% used). Resets at ${status.resetTime}`, {
|
|
1372
|
+
limit: status.limit,
|
|
1359
1373
|
remaining: status.remaining,
|
|
1360
1374
|
resetAt: status.resetTime,
|
|
1361
1375
|
usagePercent: status.usagePercent,
|
|
@@ -1588,6 +1602,37 @@ function normalizeProjectUpdateHealth(value) {
|
|
|
1588
1602
|
return normalized;
|
|
1589
1603
|
}
|
|
1590
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
|
+
|
|
1591
1636
|
function formatPriorityLabel(value) {
|
|
1592
1637
|
if (value === undefined || value === null) {
|
|
1593
1638
|
return null;
|
|
@@ -1598,8 +1643,7 @@ function formatPriorityLabel(value) {
|
|
|
1598
1643
|
return String(value);
|
|
1599
1644
|
}
|
|
1600
1645
|
|
|
1601
|
-
|
|
1602
|
-
return priorityNames[numeric] || `Priority ${numeric}`;
|
|
1646
|
+
return ISSUE_PRIORITY_NAMES[numeric] || `Priority ${numeric}`;
|
|
1603
1647
|
}
|
|
1604
1648
|
|
|
1605
1649
|
function getUserDisplayName(user) {
|
|
@@ -2859,6 +2903,145 @@ export async function fetchIssueDetails(client, issueRef, options = {}) {
|
|
|
2859
2903
|
}, 'fetchIssueDetails');
|
|
2860
2904
|
}
|
|
2861
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
|
+
|
|
2862
3045
|
export async function fetchIssueActivity(client, issueRef, options = {}) {
|
|
2863
3046
|
return withLinearErrorHandling(async () => {
|
|
2864
3047
|
const limit = normalizePositiveInteger(options.limit, 'limit', 20);
|
|
@@ -2965,8 +3148,9 @@ export async function setIssueState(client, issueId, stateId) {
|
|
|
2965
3148
|
* @param {string} input.title - Issue title (required)
|
|
2966
3149
|
* @param {string} [input.description] - Issue description
|
|
2967
3150
|
* @param {string} [input.projectId] - Project ID
|
|
2968
|
-
* @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
|
|
2969
3152
|
* @param {string} [input.assigneeId] - Assignee ID
|
|
3153
|
+
* @param {string} [input.projectMilestoneId] - Project milestone ID
|
|
2970
3154
|
* @param {string} [input.parentId] - Parent issue ID for sub-issues
|
|
2971
3155
|
* @returns {Promise<Object>} Created issue
|
|
2972
3156
|
*/
|
|
@@ -2996,11 +3180,7 @@ export async function createIssue(client, input) {
|
|
|
2996
3180
|
}
|
|
2997
3181
|
|
|
2998
3182
|
if (input.priority !== undefined) {
|
|
2999
|
-
|
|
3000
|
-
if (Number.isNaN(parsed) || parsed < 0 || parsed > 4) {
|
|
3001
|
-
throw new Error(`Invalid priority: ${input.priority}. Valid range: 0..4`);
|
|
3002
|
-
}
|
|
3003
|
-
createInput.priority = parsed;
|
|
3183
|
+
createInput.priority = parseIssuePriority(input.priority);
|
|
3004
3184
|
}
|
|
3005
3185
|
|
|
3006
3186
|
if (input.estimate !== undefined) {
|
|
@@ -3023,6 +3203,10 @@ export async function createIssue(client, input) {
|
|
|
3023
3203
|
createInput.stateId = input.stateId;
|
|
3024
3204
|
}
|
|
3025
3205
|
|
|
3206
|
+
if (input.projectMilestoneId !== undefined) {
|
|
3207
|
+
createInput.projectMilestoneId = input.projectMilestoneId;
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3026
3210
|
if (getRawRequest(client)) {
|
|
3027
3211
|
const payload = await executeGraphQL(client, ISSUE_CREATE_MUTATION, { input: createInput });
|
|
3028
3212
|
if (!payload?.issueCreate?.success) {
|
|
@@ -3062,10 +3246,13 @@ export async function createIssue(client, input) {
|
|
|
3062
3246
|
title,
|
|
3063
3247
|
description: input.description ?? null,
|
|
3064
3248
|
url: null,
|
|
3065
|
-
priority:
|
|
3249
|
+
priority: createInput.priority ?? null,
|
|
3066
3250
|
state: null,
|
|
3067
3251
|
team: null,
|
|
3068
3252
|
project: null,
|
|
3253
|
+
projectMilestone: createInput.projectMilestoneId
|
|
3254
|
+
? { id: createInput.projectMilestoneId, name: 'Unknown' }
|
|
3255
|
+
: null,
|
|
3069
3256
|
assignee: null,
|
|
3070
3257
|
};
|
|
3071
3258
|
}
|
|
@@ -3076,10 +3263,13 @@ export async function createIssue(client, input) {
|
|
|
3076
3263
|
title,
|
|
3077
3264
|
description: input.description ?? null,
|
|
3078
3265
|
url: null,
|
|
3079
|
-
priority:
|
|
3266
|
+
priority: createInput.priority ?? null,
|
|
3080
3267
|
state: null,
|
|
3081
3268
|
team: null,
|
|
3082
3269
|
project: null,
|
|
3270
|
+
projectMilestone: createInput.projectMilestoneId
|
|
3271
|
+
? { id: createInput.projectMilestoneId, name: 'Unknown' }
|
|
3272
|
+
: null,
|
|
3083
3273
|
assignee: null,
|
|
3084
3274
|
};
|
|
3085
3275
|
}, 'createIssue');
|
|
@@ -3209,11 +3399,7 @@ export async function updateIssue(client, issueRef, patch = {}) {
|
|
|
3209
3399
|
}
|
|
3210
3400
|
|
|
3211
3401
|
if (patch.priority !== undefined) {
|
|
3212
|
-
|
|
3213
|
-
if (Number.isNaN(parsed) || parsed < 0 || parsed > 4) {
|
|
3214
|
-
throw new Error(`Invalid priority: ${patch.priority}. Valid range: 0..4`);
|
|
3215
|
-
}
|
|
3216
|
-
updateInput.priority = parsed;
|
|
3402
|
+
updateInput.priority = parseIssuePriority(patch.priority);
|
|
3217
3403
|
}
|
|
3218
3404
|
|
|
3219
3405
|
|
package/src/settings.js
CHANGED
|
@@ -22,6 +22,7 @@ export function getDefaultSettings() {
|
|
|
22
22
|
defaultWorkspace: null,
|
|
23
23
|
projects: {},
|
|
24
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
|
|
25
26
|
};
|
|
26
27
|
}
|
|
27
28
|
|
|
@@ -117,6 +118,11 @@ function migrateSettings(settings) {
|
|
|
117
118
|
migrated.rateLimitDebug = false;
|
|
118
119
|
}
|
|
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
|
+
|
|
120
126
|
// Migrate project scopes
|
|
121
127
|
for (const [projectId, cfg] of Object.entries(migrated.projects)) {
|
|
122
128
|
if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) {
|
|
@@ -227,6 +233,11 @@ export function validateSettings(settings) {
|
|
|
227
233
|
errors.push('settings.rateLimitDebug must be a boolean');
|
|
228
234
|
}
|
|
229
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
|
+
|
|
230
241
|
return { valid: errors.length === 0, errors };
|
|
231
242
|
}
|
|
232
243
|
|