@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/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: projectDelete(id: $id) {
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
- rateLimitState.remaining = parseInt(remaining, 10);
1323
+ const parsedRemaining = parseInt(remaining, 10);
1324
+ if (Number.isFinite(parsedRemaining)) rateLimitState.remaining = parsedRemaining;
1315
1325
  }
1316
1326
  if (resetAt !== null) {
1317
- rateLimitState.resetAt = parseInt(resetAt, 10);
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
- if (remaining !== null && remaining <= 1000) {
1339
- result.usagePercent = Math.round(((5000 - remaining) / 5000) * 100);
1340
- result.shouldWarn = remaining <= 500;
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
- const priorityNames = ['No priority', 'Urgent', 'High', 'Medium', 'Low'];
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] - Priority 0-4
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
- const parsed = Number.parseInt(String(input.priority), 10);
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: input.priority ?? null,
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: input.priority ?? null,
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
- const parsed = Number.parseInt(String(patch.priority), 10);
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