@fink-andreas/pi-linear-tools 0.3.0 → 0.4.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/auth/oauth.js CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { debug, warn, error as logError } from '../logger.js';
9
+ import { OAUTH_CLIENT_ID, OAUTH_SCOPES } from './constants.js';
9
10
 
10
11
  // OAuth configuration
11
12
  const OAUTH_CONFIG = {
@@ -15,11 +16,11 @@ const OAUTH_CONFIG = {
15
16
  revokeUrl: 'https://api.linear.app/oauth/revoke',
16
17
 
17
18
  // Client configuration
18
- clientId: 'a3e177176c6697611367f1a2405d4a34',
19
+ clientId: OAUTH_CLIENT_ID,
19
20
  redirectUri: 'http://localhost:34711/callback',
20
21
 
21
- // OAuth scopes - minimal required scopes
22
- scopes: ['read', 'issues:create', 'comments:create'],
22
+ // OAuth scopes
23
+ scopes: OAUTH_SCOPES,
23
24
 
24
25
  // Prompt consent to allow workspace reselection
25
26
  prompt: 'consent',
@@ -272,10 +273,10 @@ export async function revokeToken(token, tokenTypeHint) {
272
273
  }
273
274
 
274
275
  /**
275
- * Get OAuth configuration
276
+ * Get OAuth configuration (internal)
276
277
  *
277
278
  * @returns {object} OAuth configuration object
278
279
  */
279
- export function getOAuthConfig() {
280
+ function _getOAuthConfig() {
280
281
  return { ...OAUTH_CONFIG };
281
282
  }
package/src/auth/pkce.js CHANGED
@@ -6,106 +6,69 @@
6
6
  */
7
7
 
8
8
  import crypto from 'node:crypto';
9
- import { debug } from '../logger.js';
9
+ import { debug, warn, error as logError } from '../logger.js';
10
10
 
11
11
  /**
12
- * Generate a random code verifier for PKCE
13
- *
14
- * The code verifier must be:
15
- * - High-entropy cryptographically random
16
- * - Between 43 and 128 characters
17
- * - Containing only alphanumeric characters and '-', '.', '_', '~'
18
- *
19
- * @returns {string} Base64URL-encoded code verifier
12
+ * Generate a random code verifier for PKCE (RFC 7636: 43-128 chars, base64url)
13
+ * @returns {string} Code verifier
20
14
  */
21
15
  export function generateCodeVerifier() {
22
- // Generate 32 bytes of cryptographically secure random data
23
- // Base64URL encoding results in ~43 characters
16
+ // 32 bytes = ~43 base64url characters
24
17
  const verifier = crypto.randomBytes(32).toString('base64url');
25
-
26
18
  debug('Generated code verifier', { length: verifier.length });
27
-
28
19
  return verifier;
29
20
  }
30
21
 
31
22
  /**
32
- * Generate a code challenge from the verifier
33
- *
34
- * Uses SHA-256 hash as specified in PKCE with S256 method.
35
- *
36
- * @param {string} verifier - The code verifier generated by generateCodeVerifier()
37
- * @returns {string} Base64URL-encoded code challenge
23
+ * Generate a code challenge from verifier (SHA-256, S256 method)
24
+ * @param {string} verifier - Code verifier
25
+ * @returns {string} Code challenge
38
26
  */
39
27
  export function generateCodeChallenge(verifier) {
40
28
  const challenge = crypto
41
29
  .createHash('sha256')
42
30
  .update(verifier)
43
31
  .digest('base64url');
44
-
45
32
  debug('Generated code challenge', { challengeLength: challenge.length });
46
-
47
33
  return challenge;
48
34
  }
49
35
 
50
36
  /**
51
- * Generate a cryptographically random state parameter
52
- *
53
- * The state parameter is used for CSRF protection during OAuth flow.
54
- * Must be unique per authentication session.
55
- *
56
- * @returns {string} Hex-encoded random state
37
+ * Generate a random state parameter for CSRF protection
38
+ * @returns {string} Hex-encoded state
57
39
  */
58
40
  export function generateState() {
59
- // Generate 16 bytes of random data (32 hex characters)
60
41
  const state = crypto.randomBytes(16).toString('hex');
61
-
62
42
  debug('Generated state parameter', { state });
63
-
64
43
  return state;
65
44
  }
66
45
 
67
46
  /**
68
- * Validate state parameter matches expected value
69
- *
70
- * @param {string} receivedState - State received from OAuth callback
71
- * @param {string} expectedState - State generated at start of flow
72
- * @returns {boolean} True if states match, false otherwise
47
+ * Validate OAuth callback state matches expected
48
+ * @param {string} receivedState - From callback
49
+ * @param {string} expectedState - Generated at flow start
50
+ * @returns {boolean} True if match
73
51
  */
74
52
  export function validateState(receivedState, expectedState) {
75
53
  const isValid = receivedState === expectedState;
76
-
77
54
  if (!isValid) {
78
- debug('State validation failed', {
79
- received: receivedState,
80
- expected: expectedState,
81
- });
82
- } else {
83
- debug('State validation successful');
55
+ debug('State validation failed', { received: receivedState, expected: expectedState });
84
56
  }
85
-
86
57
  return isValid;
87
58
  }
88
59
 
89
60
  /**
90
61
  * Generate all PKCE parameters for OAuth flow
91
- *
92
- * Convenience function that generates all required PKCE parameters.
93
- *
94
- * @returns {object} Object containing verifier, challenge, and state
95
- * @returns {string} returns.verifier - Code verifier
96
- * @returns {string} returns.challenge - Code challenge
97
- * @returns {string} returns.state - State parameter
62
+ * @returns {{verifier: string, challenge: string, state: string}}
98
63
  */
99
64
  export function generatePkceParams() {
100
65
  const verifier = generateCodeVerifier();
101
66
  const challenge = generateCodeChallenge(verifier);
102
67
  const state = generateState();
103
-
104
68
  debug('Generated PKCE parameters', {
105
69
  verifierLength: verifier.length,
106
70
  challengeLength: challenge.length,
107
71
  stateLength: state.length,
108
72
  });
109
-
110
73
  return { verifier, challenge, state };
111
- }
74
+ }
@@ -133,25 +133,23 @@ const refreshManager = new TokenRefreshManager();
133
133
  * @returns {Promise<string>} New access token
134
134
  * @throws {Error} If refresh fails
135
135
  */
136
- export async function refreshTokens(refreshToken) {
136
+ async function refreshTokens(refreshToken) {
137
137
  return refreshManager.refresh(refreshToken);
138
138
  }
139
139
 
140
140
  /**
141
- * Check if a token refresh is currently in progress
141
+ * Check if a token refresh is currently in progress (internal)
142
142
  *
143
143
  * @returns {boolean} True if a refresh is in progress
144
144
  */
145
- export function isRefreshing() {
145
+ function isRefreshing() {
146
146
  return refreshManager.isRefreshInProgress();
147
147
  }
148
148
 
149
149
  /**
150
- * Reset the token refresh manager
151
- *
152
- * This is mainly for testing purposes.
150
+ * Reset the token refresh manager (internal, for testing)
153
151
  */
154
- export function resetRefreshManager() {
152
+ function resetRefreshManager() {
155
153
  refreshManager.reset();
156
154
  }
157
155
 
package/src/cli.js CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  executeMilestoneUpdate,
25
25
  executeMilestoneDelete,
26
26
  } from './handlers.js';
27
+ import { withMilestoneScopeHint } from './error-hints.js';
27
28
 
28
29
  // ===== ARGUMENT PARSING =====
29
30
 
@@ -67,19 +68,6 @@ function parseBoolean(value) {
67
68
  return undefined;
68
69
  }
69
70
 
70
- function withMilestoneScopeHint(error) {
71
- const message = String(error?.message || error || 'Unknown error');
72
-
73
- if (/invalid scope/i.test(message) && /write/i.test(message)) {
74
- return new Error(
75
- `${message}\nHint: Milestone create/update/delete require Linear write scope. ` +
76
- `Use API key auth: pi-linear-tools config --api-key <key>`
77
- );
78
- }
79
-
80
- return error;
81
- }
82
-
83
71
  // ===== AUTH RESOLUTION =====
84
72
 
85
73
  let cachedApiKey = null;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Error hint utilities
3
+ *
4
+ * Provides helpful hints for common error scenarios.
5
+ */
6
+
7
+ /**
8
+ * Wraps errors related to milestone operations with helpful scope hints
9
+ *
10
+ * @param {Error} error - The error to wrap
11
+ * @returns {Error} The original error or a new error with additional hint
12
+ */
13
+ export function withMilestoneScopeHint(error) {
14
+ const message = String(error?.message || error || 'Unknown error');
15
+
16
+ if (/invalid scope/i.test(message) && /write/i.test(message)) {
17
+ return new Error(
18
+ `${message}\nHint: Milestone create/update/delete require Linear write scope. ` +
19
+ `Use API key auth for milestone management: /linear-tools-config --api-key <key>`
20
+ );
21
+ }
22
+
23
+ return error;
24
+ }
package/src/handlers.js CHANGED
@@ -5,7 +5,6 @@
5
5
  * All handlers are pure functions that accept a LinearClient and parameters.
6
6
  */
7
7
 
8
- import { createLinearClient } from './linear-client.js';
9
8
  import {
10
9
  prepareIssueStart,
11
10
  setIssueState,
@@ -14,7 +13,6 @@ import {
14
13
  createIssue,
15
14
  fetchProjects,
16
15
  fetchTeams,
17
- fetchWorkspaces,
18
16
  resolveProjectRef,
19
17
  resolveTeamRef,
20
18
  getTeamWorkflowStates,
@@ -29,7 +27,7 @@ import {
29
27
  deleteIssue,
30
28
  withHandlerErrorHandling,
31
29
  } from './linear.js';
32
- import { debug, warn } from './logger.js';
30
+ import { debug } from './logger.js';
33
31
 
34
32
  function toTextResult(text, details = {}) {
35
33
  return {
@@ -74,8 +72,12 @@ async function runGitCommand(args) {
74
72
  * @returns {Promise<boolean>}
75
73
  */
76
74
  async function gitBranchExists(branchName) {
77
- const result = await runGitCommand(['rev-parse', '--verify', branchName]);
78
- return result.code === 0;
75
+ try {
76
+ const result = await runGitCommand(['rev-parse', '--verify', branchName]);
77
+ return result.code === 0;
78
+ } catch {
79
+ return false;
80
+ }
79
81
  }
80
82
 
81
83
  /**
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { LinearClient } from '@linear/sdk';
9
- import { debug, warn, error as logError } from './logger.js';
9
+ import { debug, warn, info } from './logger.js';
10
10
 
11
11
  /** @type {Function|null} Test-only client factory override */
12
12
  let _testClientFactory = null;
@@ -14,15 +14,75 @@ let _testClientFactory = null;
14
14
  /** @type {Map<string, {remaining: number, resetAt: number}>} Per-client rate limit tracking */
15
15
  const rateLimitTracker = new Map();
16
16
 
17
+ /** @type {Map<string, {total: number, success: number, failed: number, rateLimited: number, windowStart: number, lastSummaryAt: number}>} */
18
+ const requestMetrics = new Map();
19
+
17
20
  /** Track globally if we've detected a rate limit error */
18
21
  let globalRateLimited = false;
19
22
  let globalRateLimitResetAt = null;
20
23
 
24
+ const REQUEST_SUMMARY_INTERVAL = 50;
25
+ const REQUEST_SUMMARY_MIN_MS = 15000;
26
+
27
+ function getTrackerKey(apiKey) {
28
+ return apiKey || 'default';
29
+ }
30
+
31
+ function getRequestMetric(trackerKey) {
32
+ let metric = requestMetrics.get(trackerKey);
33
+ if (!metric) {
34
+ metric = {
35
+ total: 0,
36
+ success: 0,
37
+ failed: 0,
38
+ rateLimited: 0,
39
+ windowStart: Date.now(),
40
+ lastSummaryAt: 0,
41
+ };
42
+ requestMetrics.set(trackerKey, metric);
43
+ }
44
+ return metric;
45
+ }
46
+
47
+ function maybeLogRequestSummary(trackerKey) {
48
+ const metric = requestMetrics.get(trackerKey);
49
+ const tracker = rateLimitTracker.get(trackerKey);
50
+ if (!metric || !tracker) return;
51
+
52
+ const now = Date.now();
53
+ const shouldLogByCount = metric.total % REQUEST_SUMMARY_INTERVAL === 0;
54
+ const shouldLogByTime = now - metric.lastSummaryAt >= REQUEST_SUMMARY_MIN_MS;
55
+
56
+ if (!shouldLogByCount && !shouldLogByTime) return;
57
+
58
+ metric.lastSummaryAt = now;
59
+ const used = Math.max(0, 5000 - (tracker.remaining ?? 5000));
60
+
61
+ info('[pi-linear-tools] Linear API usage summary', {
62
+ trackerKey,
63
+ requestsTotal: metric.total,
64
+ requestsSuccess: metric.success,
65
+ requestsFailed: metric.failed,
66
+ requestsRateLimited: metric.rateLimited,
67
+ requestsRemaining: tracker.remaining,
68
+ requestsUsed: used,
69
+ resetAt: tracker.resetAt,
70
+ resetTime: tracker.resetAt ? new Date(tracker.resetAt).toLocaleTimeString() : null,
71
+ });
72
+ }
73
+
74
+ function extractOperationName(query) {
75
+ if (!query || typeof query !== 'string') return 'unknown';
76
+ const compact = query.replace(/\s+/g, ' ').trim();
77
+ const match = compact.match(/^(query|mutation)\s+([a-zA-Z0-9_]+)/i);
78
+ return match?.[2] || 'anonymous';
79
+ }
80
+
21
81
  /**
22
- * Check if we know we're rate limited and should skip API calls
82
+ * Clear rate limit state if the window has expired
23
83
  * @returns {{isRateLimited: boolean, resetAt: Date|null}}
24
84
  */
25
- export function isGloballyRateLimited() {
85
+ export function checkAndClearRateLimit() {
26
86
  if (!globalRateLimited || !globalRateLimitResetAt) {
27
87
  return { isRateLimited: false, resetAt: null };
28
88
  }
@@ -37,6 +97,14 @@ export function isGloballyRateLimited() {
37
97
  return { isRateLimited: true, resetAt: new Date(globalRateLimitResetAt) };
38
98
  }
39
99
 
100
+ /**
101
+ * @deprecated Use checkAndClearRateLimit() instead
102
+ * @returns {{isRateLimited: boolean, resetAt: Date|null}}
103
+ */
104
+ export function isGloballyRateLimited() {
105
+ return checkAndClearRateLimit();
106
+ }
107
+
40
108
  /**
41
109
  * Mark that we've hit the rate limit
42
110
  * @param {number} resetAt - Reset timestamp in milliseconds
@@ -56,7 +124,6 @@ export function markRateLimited(resetAt) {
56
124
  * @returns {{remaining: number|null, resetAt: number|null, resetTime: string|null}}
57
125
  */
58
126
  export function getClientRateLimit(client) {
59
- // Try to get from tracker first
60
127
  const trackerData = rateLimitTracker.get(client.apiKey || 'default');
61
128
  if (trackerData) {
62
129
  return {
@@ -69,6 +136,31 @@ export function getClientRateLimit(client) {
69
136
  return { remaining: null, resetAt: null, resetTime: null };
70
137
  }
71
138
 
139
+ /**
140
+ * Expose per-client request counters for diagnostics
141
+ */
142
+ export function getClientRequestMetrics(client) {
143
+ const key = client?.apiKey || 'default';
144
+ const metric = requestMetrics.get(key);
145
+ if (!metric) {
146
+ return {
147
+ total: 0,
148
+ success: 0,
149
+ failed: 0,
150
+ rateLimited: 0,
151
+ windowStart: null,
152
+ };
153
+ }
154
+
155
+ return {
156
+ total: metric.total,
157
+ success: metric.success,
158
+ failed: metric.failed,
159
+ rateLimited: metric.rateLimited,
160
+ windowStart: metric.windowStart,
161
+ };
162
+ }
163
+
72
164
  /**
73
165
  * Check and warn about low rate limit for a client
74
166
  * Call this after making API requests to check if limits are getting low
@@ -114,11 +206,9 @@ export function createLinearClient(auth) {
114
206
 
115
207
  // Handle different input formats
116
208
  if (typeof auth === 'string') {
117
- // Legacy: API key passed as string
118
209
  clientConfig = { apiKey: auth };
119
210
  apiKey = auth;
120
211
  } else if (typeof auth === 'object' && auth !== null) {
121
- // Object format: { apiKey: '...' } or { accessToken: '...' }
122
212
  if (auth.accessToken) {
123
213
  clientConfig = { apiKey: auth.accessToken };
124
214
  apiKey = auth.accessToken;
@@ -128,61 +218,81 @@ export function createLinearClient(auth) {
128
218
  apiKey = auth.apiKey;
129
219
  debug('Creating Linear client with API key');
130
220
  } else {
131
- throw new Error(
132
- 'Auth object must contain either apiKey or accessToken'
133
- );
221
+ throw new Error('Auth object must contain either apiKey or accessToken');
134
222
  }
135
223
  } else {
136
- throw new Error(
137
- 'Invalid auth parameter: must be a string (API key) or an object with apiKey or accessToken'
138
- );
224
+ throw new Error('Invalid auth parameter: must be a string (API key) or an object with apiKey or accessToken');
139
225
  }
140
226
 
141
227
  const client = new LinearClient(clientConfig);
142
228
 
143
- // Initialize rate limit tracking for this client
144
- const trackerKey = apiKey || 'default';
229
+ const trackerKey = getTrackerKey(apiKey);
145
230
  if (!rateLimitTracker.has(trackerKey)) {
146
231
  rateLimitTracker.set(trackerKey, { remaining: 5000, resetAt: Date.now() + 3600000 });
147
232
  }
233
+ getRequestMetric(trackerKey);
148
234
 
149
- // Wrap the rawRequest to capture rate limit headers
150
- // rawRequest is on client.client (internal GraphQL client)
235
+ // Wrap internal rawRequest to capture request counts + rate limit metadata
151
236
  const originalRawRequest = client.client.rawRequest.bind(client.client);
152
237
  client.client.rawRequest = async function wrappedRawRequest(query, variables, requestHeaders) {
153
- const response = await originalRawRequest(query, variables, requestHeaders);
238
+ const metric = getRequestMetric(trackerKey);
239
+ metric.total += 1;
240
+
241
+ debug('[pi-linear-tools] Linear request', {
242
+ trackerKey,
243
+ requestNumber: metric.total,
244
+ operation: extractOperationName(query),
245
+ });
154
246
 
155
- // Extract rate limit headers from response
156
- if (response.headers) {
157
- const remaining = response.headers.get('X-RateLimit-Requests-Remaining');
158
- const resetAt = response.headers.get('X-RateLimit-Requests-Reset');
247
+ try {
248
+ const response = await originalRawRequest(query, variables, requestHeaders);
249
+ metric.success += 1;
250
+
251
+ if (response.headers) {
252
+ const remaining = response.headers.get('X-RateLimit-Requests-Remaining');
253
+ const resetAt = response.headers.get('X-RateLimit-Requests-Reset');
159
254
 
160
- if (remaining !== null) {
161
255
  const tracker = rateLimitTracker.get(trackerKey);
162
- if (tracker) {
256
+ if (tracker && remaining !== null) {
163
257
  tracker.remaining = parseInt(remaining, 10);
164
258
  }
165
- }
166
- if (resetAt !== null) {
167
- const tracker = rateLimitTracker.get(trackerKey);
168
- if (tracker) {
259
+ if (tracker && resetAt !== null) {
169
260
  tracker.resetAt = parseInt(resetAt, 10);
170
261
  }
171
262
  }
172
- }
173
263
 
174
- // Check if we should warn about low rate limits
175
- const tracker = rateLimitTracker.get(trackerKey);
176
- if (tracker && tracker.remaining <= 500 && tracker.remaining > 0) {
177
- const usagePercent = Math.round(((5000 - tracker.remaining) / 5000) * 100);
178
- warn(`Linear API rate limit running low: ${tracker.remaining} requests remaining (~${usagePercent}% used). Resets at ${new Date(tracker.resetAt).toLocaleTimeString()}`, {
179
- remaining: tracker.remaining,
180
- resetTime: new Date(tracker.resetAt).toLocaleTimeString(),
181
- usagePercent,
182
- });
183
- }
264
+ const tracker = rateLimitTracker.get(trackerKey);
265
+ if (tracker && tracker.remaining <= 500 && tracker.remaining > 0) {
266
+ const usagePercent = Math.round(((5000 - tracker.remaining) / 5000) * 100);
267
+ warn(`Linear API rate limit running low: ${tracker.remaining} requests remaining (~${usagePercent}% used). Resets at ${new Date(tracker.resetAt).toLocaleTimeString()}`, {
268
+ remaining: tracker.remaining,
269
+ resetTime: new Date(tracker.resetAt).toLocaleTimeString(),
270
+ usagePercent,
271
+ });
272
+ }
184
273
 
185
- return response;
274
+ maybeLogRequestSummary(trackerKey);
275
+ return response;
276
+ } catch (error) {
277
+ metric.failed += 1;
278
+
279
+ const message = String(error?.message || error || 'unknown');
280
+ const isRateLimited = error?.type === 'Ratelimited' || message.toLowerCase().includes('rate limit');
281
+
282
+ if (isRateLimited) {
283
+ metric.rateLimited += 1;
284
+ const resetAt = Number(error?.requestsResetAt) || Date.now() + 3600000;
285
+ const remaining = Number.isFinite(error?.requestsRemaining) ? error.requestsRemaining : 0;
286
+ rateLimitTracker.set(trackerKey, {
287
+ remaining,
288
+ resetAt,
289
+ });
290
+ markRateLimited(resetAt);
291
+ }
292
+
293
+ maybeLogRequestSummary(trackerKey);
294
+ throw error;
295
+ }
186
296
  };
187
297
 
188
298
  return client;
package/src/linear.js CHANGED
@@ -139,10 +139,10 @@ const rateLimitState = {
139
139
  };
140
140
 
141
141
  /**
142
- * Update rate limit state from response headers
142
+ * Update rate limit state from response headers (internal)
143
143
  * @param {Response} response - Fetch response object
144
144
  */
145
- export function updateRateLimitState(response) {
145
+ function updateRateLimitState(response) {
146
146
  if (!response) return;
147
147
 
148
148
  const headers = response.headers;
@@ -224,15 +224,14 @@ function isLinearError(error) {
224
224
  /**
225
225
  * Format a Linear API error into a user-friendly message
226
226
  * @param {Error} error - The original error
227
- * @returns {Error} Formatted error with user-friendly message
227
+ * @returns {Error|unknown} Formatted error with user-friendly message, or original if unhandled
228
228
  */
229
229
  function formatLinearError(error) {
230
230
  const message = String(error?.message || error || 'Unknown error');
231
231
  const errorType = error?.type || 'Unknown';
232
232
 
233
- // Rate limit error
233
+ // Rate limit: provide reset time and reduce-frequency hint
234
234
  if (errorType === 'Ratelimited' || message.toLowerCase().includes('rate limit')) {
235
- const retryAfter = error?.retryAfter;
236
235
  const resetAt = error?.requestsResetAt
237
236
  ? new Date(error.requestsResetAt).toLocaleTimeString()
238
237
  : '1 hour';
@@ -244,7 +243,7 @@ function formatLinearError(error) {
244
243
  );
245
244
  }
246
245
 
247
- // Authentication/Forbidden errors
246
+ // Auth/permission failures: prompt to check credentials
248
247
  if (errorType === 'Forbidden' || errorType === 'AuthenticationError' ||
249
248
  message.toLowerCase().includes('forbidden') || message.toLowerCase().includes('unauthorized')) {
250
249
  return new Error(
@@ -273,7 +272,8 @@ function formatLinearError(error) {
273
272
  );
274
273
  }
275
274
 
276
- return error;
275
+ // Unknown error - wrap if not already an Error
276
+ return error instanceof Error ? error : new Error(String(error));
277
277
  }
278
278
 
279
279
  /**
@@ -889,14 +889,12 @@ export async function fetchIssueDetails(client, issueRef, options = {}) {
889
889
  };
890
890
  }
891
891
 
892
- // Transform children
893
892
  const children = (childrenResult.nodes || []).map(c => ({
894
893
  identifier: c.identifier,
895
894
  title: c.title,
896
895
  state: c.state ? { name: c.state.name, color: c.state.color } : null,
897
896
  }));
898
897
 
899
- // Transform comments
900
898
  const comments = (commentsResult.nodes || []).map(c => ({
901
899
  id: c.id,
902
900
  body: c.body,
@@ -907,7 +905,6 @@ export async function fetchIssueDetails(client, issueRef, options = {}) {
907
905
  parent: c.parent ? { id: c.parent.id } : null,
908
906
  }));
909
907
 
910
- // Transform attachments
911
908
  const attachments = (attachmentsResult.nodes || []).map(a => ({
912
909
  id: a.id,
913
910
  title: a.title,
@@ -917,7 +914,6 @@ export async function fetchIssueDetails(client, issueRef, options = {}) {
917
914
  createdAt: a.createdAt,
918
915
  }));
919
916
 
920
- // Transform labels
921
917
  const labels = (labelsResult.nodes || []).map(l => ({
922
918
  id: l.id,
923
919
  name: l.name,
@@ -1424,7 +1420,6 @@ export async function fetchMilestoneDetails(client, milestoneId) {
1424
1420
  milestone.issues?.()?.catch?.(() => ({ nodes: [] })) ?? milestone.issues?.() ?? { nodes: [] },
1425
1421
  ]);
1426
1422
 
1427
- // Transform issues
1428
1423
  const issues = await Promise.all(
1429
1424
  (issuesResult.nodes || []).map(async (issue) => {
1430
1425
  const [state, assignee] = await Promise.all([