@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/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: projectDelete(id: $id) {
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
- rateLimitState.remaining = parseInt(remaining, 10);
1323
+ const parsedRemaining = parseInt(remaining, 10);
1324
+ if (Number.isFinite(parsedRemaining)) rateLimitState.remaining = parsedRemaining;
1276
1325
  }
1277
1326
  if (resetAt !== null) {
1278
- rateLimitState.resetAt = parseInt(resetAt, 10);
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
- if (remaining !== null && remaining <= 1000) {
1300
- result.usagePercent = Math.round(((5000 - remaining) / 5000) * 100);
1301
- 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));
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
- const priorityNames = ['No priority', 'Urgent', 'High', 'Medium', 'Low'];
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] - 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
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
- const parsed = Number.parseInt(String(input.priority), 10);
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: input.priority ?? null,
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: input.priority ?? null,
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
- const parsed = Number.parseInt(String(patch.priority), 10);
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 [project, issuesResult] = await Promise.all([
3811
+ const [projectResult, issuesResult] = await Promise.all([
3537
3812
  milestone.project?.catch?.(() => null) ?? milestone.project,
3538
- milestone.issues?.()?.catch?.(() => ({ nodes: [] })) ?? milestone.issues?.() ?? { nodes: [] },
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?.(() => null) ?? issue.state,
3545
- issue.assignee?.catch?.(() => null) ?? issue.assignee,
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