@fink-andreas/pi-linear-tools 0.2.0 → 0.3.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 +19 -0
- package/extensions/pi-linear-tools.js +165 -49
- package/index.js +916 -6
- package/package.json +3 -3
- package/src/auth/token-store.js +5 -5
- package/src/cli.js +11 -11
- package/src/handlers.js +56 -50
- package/src/linear-client.js +133 -2
- package/src/linear.js +1071 -641
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fink-andreas/pi-linear-tools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Pi extension with Linear SDK tools and configuration commands",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"scripts": {
|
|
29
29
|
"start": "node index.js",
|
|
30
|
-
"test": "node tests/test-package-manifest.js && node tests/test-extension-registration.js && node tests/test-settings.js && node tests/test-assignee-update.js && node tests/test-full-assignee-flow.js",
|
|
30
|
+
"test": "node tests/test-package-manifest.js && node tests/test-extension-registration.js && node tests/test-settings.js && node tests/test-assignee-update.js && node tests/test-full-assignee-flow.js && node tests/test-branch-param.js",
|
|
31
31
|
"dev:sync-local-extension": "node scripts/dev-sync-local-extension.mjs",
|
|
32
32
|
"release:check": "npm test && npm pack --dry-run"
|
|
33
33
|
},
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
],
|
|
41
41
|
"pi": {
|
|
42
42
|
"extensions": [
|
|
43
|
-
"./
|
|
43
|
+
"./index.js"
|
|
44
44
|
]
|
|
45
45
|
},
|
|
46
46
|
"license": "MIT",
|
package/src/auth/token-store.js
CHANGED
|
@@ -73,7 +73,7 @@ async function writeTokensToFile(tokenData) {
|
|
|
73
73
|
await mkdir(parentDir, { recursive: true, mode: 0o700 });
|
|
74
74
|
await writeFile(tokenFilePath, tokenData, { encoding: 'utf-8', mode: 0o600 });
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
debug('Stored OAuth tokens in fallback file storage because keychain is unavailable', {
|
|
77
77
|
path: tokenFilePath,
|
|
78
78
|
});
|
|
79
79
|
}
|
|
@@ -94,7 +94,7 @@ async function isKeytarAvailable() {
|
|
|
94
94
|
debug('keytar module loaded successfully');
|
|
95
95
|
return true;
|
|
96
96
|
} catch (error) {
|
|
97
|
-
|
|
97
|
+
debug('keytar module not available, using fallback storage', { error: error.message });
|
|
98
98
|
keytarModule = false;
|
|
99
99
|
return false;
|
|
100
100
|
}
|
|
@@ -153,7 +153,7 @@ export async function storeTokens(tokens) {
|
|
|
153
153
|
// Clean up fallback file if keychain works again
|
|
154
154
|
await unlink(getTokenFilePath()).catch(() => {});
|
|
155
155
|
} catch (error) {
|
|
156
|
-
|
|
156
|
+
debug('Failed to store tokens in keychain, falling back to file storage', {
|
|
157
157
|
error: error.message,
|
|
158
158
|
});
|
|
159
159
|
await writeTokensToFile(tokenData);
|
|
@@ -235,7 +235,7 @@ export async function getTokens() {
|
|
|
235
235
|
|
|
236
236
|
debug('No tokens found in keychain');
|
|
237
237
|
} catch (error) {
|
|
238
|
-
|
|
238
|
+
debug('Failed to retrieve tokens from keychain, trying fallback storage', {
|
|
239
239
|
error: error.message,
|
|
240
240
|
});
|
|
241
241
|
}
|
|
@@ -277,7 +277,7 @@ export async function clearTokens() {
|
|
|
277
277
|
const message = String(error?.message || 'Unknown error');
|
|
278
278
|
// Keychain providers (e.g. DBus Secret Service) may be unavailable at runtime.
|
|
279
279
|
// Clearing is best-effort and we still clear fallback file/in-memory tokens below.
|
|
280
|
-
|
|
280
|
+
debug('Skipping keychain token clear; keychain backend unavailable', {
|
|
281
281
|
error: message,
|
|
282
282
|
});
|
|
283
283
|
if (/org\.freedesktop\.secrets/i.test(message)) {
|
package/src/cli.js
CHANGED
|
@@ -143,15 +143,17 @@ Usage:
|
|
|
143
143
|
pi-linear-tools <command> [options]
|
|
144
144
|
|
|
145
145
|
Commands:
|
|
146
|
+
issue <action> [options] Manage issues
|
|
147
|
+
project <action> [options] Manage projects
|
|
148
|
+
team <action> [options] Manage teams
|
|
149
|
+
milestone <action> [options] Manage milestones
|
|
150
|
+
|
|
151
|
+
Other commands:
|
|
146
152
|
help Show this help message
|
|
147
153
|
auth <action> Manage authentication (OAuth 2.0)
|
|
148
154
|
config Show current configuration
|
|
149
155
|
config --api-key <key> Set Linear API key (legacy)
|
|
150
156
|
config --default-team <key> Set default team
|
|
151
|
-
issue <action> [options] Manage issues
|
|
152
|
-
project <action> [options] Manage projects
|
|
153
|
-
team <action> [options] Manage teams
|
|
154
|
-
milestone <action> [options] Manage milestones
|
|
155
157
|
|
|
156
158
|
Auth Actions:
|
|
157
159
|
login Authenticate with Linear via OAuth 2.0
|
|
@@ -165,7 +167,7 @@ Issue Actions:
|
|
|
165
167
|
update <issue> [--title X] [--description X] [--state X] [--priority 0-4]
|
|
166
168
|
[--assignee me|ID] [--milestone X] [--sub-issue-of X]
|
|
167
169
|
comment <issue> --body X
|
|
168
|
-
start <issue> [--
|
|
170
|
+
start <issue> [--from-ref X] [--on-branch-exists switch|suffix]
|
|
169
171
|
delete <issue>
|
|
170
172
|
|
|
171
173
|
Project Actions:
|
|
@@ -201,10 +203,10 @@ Examples:
|
|
|
201
203
|
pi-linear-tools config --api-key lin_xxx
|
|
202
204
|
|
|
203
205
|
Authentication:
|
|
204
|
-
|
|
205
|
-
Run 'pi-linear-tools
|
|
206
|
-
For CI/headless environments, set environment
|
|
207
|
-
|
|
206
|
+
API key is the recommended authentication method (supports milestones).
|
|
207
|
+
Run 'pi-linear-tools config --api-key <key>' to authenticate.
|
|
208
|
+
For CI/headless environments, set the LINEAR_API_KEY environment variable.
|
|
209
|
+
OAuth 2.0 is also available via 'pi-linear-tools auth login'.
|
|
208
210
|
`);
|
|
209
211
|
}
|
|
210
212
|
|
|
@@ -258,7 +260,6 @@ Comment Options:
|
|
|
258
260
|
|
|
259
261
|
Start Options:
|
|
260
262
|
<issue> Issue key or ID
|
|
261
|
-
--branch X Custom branch name (default: issue's branch name)
|
|
262
263
|
--from-ref X Git ref to branch from (default: HEAD)
|
|
263
264
|
--on-branch-exists X "switch" or "suffix" (default: switch)
|
|
264
265
|
|
|
@@ -642,7 +643,6 @@ async function handleIssueStart(args) {
|
|
|
642
643
|
|
|
643
644
|
const params = {
|
|
644
645
|
issue: positional[0],
|
|
645
|
-
branch: readFlag(args, '--branch'),
|
|
646
646
|
fromRef: readFlag(args, '--from-ref'),
|
|
647
647
|
onBranchExists: readFlag(args, '--on-branch-exists'),
|
|
648
648
|
};
|
package/src/handlers.js
CHANGED
|
@@ -27,8 +27,9 @@ import {
|
|
|
27
27
|
updateProjectMilestone,
|
|
28
28
|
deleteProjectMilestone,
|
|
29
29
|
deleteIssue,
|
|
30
|
+
withHandlerErrorHandling,
|
|
30
31
|
} from './linear.js';
|
|
31
|
-
import { debug } from './logger.js';
|
|
32
|
+
import { debug, warn } from './logger.js';
|
|
32
33
|
|
|
33
34
|
function toTextResult(text, details = {}) {
|
|
34
35
|
return {
|
|
@@ -128,57 +129,59 @@ async function startGitBranch(branchName, fromRef = 'HEAD', onBranchExists = 'sw
|
|
|
128
129
|
* List issues in a project
|
|
129
130
|
*/
|
|
130
131
|
export async function executeIssueList(client, params) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
return withHandlerErrorHandling(async () => {
|
|
133
|
+
let projectRef = params.project;
|
|
134
|
+
if (!projectRef) {
|
|
135
|
+
projectRef = process.cwd().split('/').pop();
|
|
136
|
+
}
|
|
135
137
|
|
|
136
|
-
|
|
138
|
+
const resolved = await resolveProjectRef(client, projectRef);
|
|
137
139
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const { issues, truncated } = await fetchIssuesByProject(client, resolved.id, params.states || null, {
|
|
145
|
-
assigneeId,
|
|
146
|
-
limit: params.limit || 50,
|
|
147
|
-
});
|
|
140
|
+
let assigneeId = null;
|
|
141
|
+
if (params.assignee === 'me') {
|
|
142
|
+
const viewer = await client.viewer;
|
|
143
|
+
assigneeId = viewer.id;
|
|
144
|
+
}
|
|
148
145
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
projectName: resolved.name,
|
|
153
|
-
issueCount: 0,
|
|
146
|
+
const { issues, truncated } = await fetchIssuesByProject(client, resolved.id, params.states || null, {
|
|
147
|
+
assigneeId,
|
|
148
|
+
limit: params.limit || 20,
|
|
154
149
|
});
|
|
155
|
-
}
|
|
156
150
|
|
|
157
|
-
|
|
151
|
+
if (issues.length === 0) {
|
|
152
|
+
return toTextResult(`No issues found in project "${resolved.name}"`, {
|
|
153
|
+
projectId: resolved.id,
|
|
154
|
+
projectName: resolved.name,
|
|
155
|
+
issueCount: 0,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
158
|
|
|
159
|
-
|
|
160
|
-
const stateLabel = issue.state?.name || 'Unknown';
|
|
161
|
-
const assigneeLabel = issue.assignee?.displayName || 'Unassigned';
|
|
162
|
-
const priorityLabel = issue.priority !== undefined && issue.priority !== null
|
|
163
|
-
? ['None', 'Urgent', 'High', 'Medium', 'Low'][issue.priority] || `P${issue.priority}`
|
|
164
|
-
: null;
|
|
159
|
+
const lines = [`## Issues in project "${resolved.name}" (${issues.length}${truncated ? '+' : ''})\n`];
|
|
165
160
|
|
|
166
|
-
const
|
|
167
|
-
|
|
161
|
+
for (const issue of issues) {
|
|
162
|
+
const stateLabel = issue.state?.name || 'Unknown';
|
|
163
|
+
const assigneeLabel = issue.assignee?.displayName || 'Unassigned';
|
|
164
|
+
const priorityLabel = issue.priority !== undefined && issue.priority !== null
|
|
165
|
+
? ['None', 'Urgent', 'High', 'Medium', 'Low'][issue.priority] || `P${issue.priority}`
|
|
166
|
+
: null;
|
|
168
167
|
|
|
169
|
-
|
|
170
|
-
|
|
168
|
+
const metaParts = [`[${stateLabel}]`, `@${assigneeLabel}`];
|
|
169
|
+
if (priorityLabel) metaParts.push(priorityLabel);
|
|
171
170
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
171
|
+
lines.push(`- **${issue.identifier}**: ${issue.title} _${metaParts.join(' ')}_`);
|
|
172
|
+
}
|
|
175
173
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
174
|
+
if (truncated) {
|
|
175
|
+
lines.push('\n_Results may be truncated. Use limit parameter to fetch more._');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return toTextResult(lines.join('\n'), {
|
|
179
|
+
projectId: resolved.id,
|
|
180
|
+
projectName: resolved.name,
|
|
181
|
+
issueCount: issues.length,
|
|
182
|
+
truncated,
|
|
183
|
+
});
|
|
184
|
+
}, 'executeIssueList');
|
|
182
185
|
}
|
|
183
186
|
|
|
184
187
|
/**
|
|
@@ -447,28 +450,31 @@ export async function executeIssueStart(client, params, options = {}) {
|
|
|
447
450
|
const issue = ensureNonEmpty(params.issue, 'issue');
|
|
448
451
|
const prepared = await prepareIssueStart(client, issue);
|
|
449
452
|
|
|
450
|
-
|
|
451
|
-
|
|
453
|
+
// Always use Linear's suggested branchName - it cannot be changed via API
|
|
454
|
+
// and using a custom branch would break Linear's branch-to-issue linking
|
|
455
|
+
const branchName = prepared.branchName;
|
|
456
|
+
if (!branchName) {
|
|
452
457
|
throw new Error(
|
|
453
|
-
`No branch name
|
|
458
|
+
`No branch name available for issue ${prepared.issue.identifier}. The issue may not have a team assigned.`
|
|
454
459
|
);
|
|
455
460
|
}
|
|
456
461
|
|
|
457
462
|
let gitResult;
|
|
458
463
|
if (gitExecutor) {
|
|
459
464
|
// Use provided git executor (e.g., pi.exec)
|
|
460
|
-
gitResult = await gitExecutor(
|
|
465
|
+
gitResult = await gitExecutor(branchName, params.fromRef || 'HEAD', params.onBranchExists || 'switch');
|
|
461
466
|
} else {
|
|
462
467
|
// Use built-in child_process git operations
|
|
463
|
-
gitResult = await startGitBranch(
|
|
468
|
+
gitResult = await startGitBranch(branchName, params.fromRef || 'HEAD', params.onBranchExists || 'switch');
|
|
464
469
|
}
|
|
465
470
|
|
|
466
471
|
const updatedIssue = await setIssueState(client, prepared.issue.id, prepared.startedState.id);
|
|
467
472
|
|
|
473
|
+
const identifier = updatedIssue.identifier || prepared.issue.identifier;
|
|
468
474
|
const compactTitle = String(updatedIssue.title || prepared.issue?.title || '').trim().toLowerCase();
|
|
469
475
|
const summary = compactTitle
|
|
470
|
-
? `Started issue ${
|
|
471
|
-
: `Started issue ${
|
|
476
|
+
? `Started issue ${identifier} (${compactTitle})`
|
|
477
|
+
: `Started issue ${identifier}`;
|
|
472
478
|
|
|
473
479
|
return toTextResult(summary, {
|
|
474
480
|
issueId: updatedIssue.id,
|
|
@@ -731,11 +737,11 @@ export async function executeMilestoneCreate(client, params) {
|
|
|
731
737
|
export async function executeMilestoneUpdate(client, params) {
|
|
732
738
|
const milestoneId = ensureNonEmpty(params.milestone, 'milestone');
|
|
733
739
|
|
|
740
|
+
// Note: status is not included as it's a computed/read-only field in Linear's API
|
|
734
741
|
const result = await updateProjectMilestone(client, milestoneId, {
|
|
735
742
|
name: params.name,
|
|
736
743
|
description: params.description,
|
|
737
744
|
targetDate: params.targetDate,
|
|
738
|
-
status: params.status,
|
|
739
745
|
});
|
|
740
746
|
|
|
741
747
|
const friendlyChanges = result.changed;
|
package/src/linear-client.js
CHANGED
|
@@ -11,8 +11,88 @@ import { debug, warn, error as logError } from './logger.js';
|
|
|
11
11
|
/** @type {Function|null} Test-only client factory override */
|
|
12
12
|
let _testClientFactory = null;
|
|
13
13
|
|
|
14
|
+
/** @type {Map<string, {remaining: number, resetAt: number}>} Per-client rate limit tracking */
|
|
15
|
+
const rateLimitTracker = new Map();
|
|
16
|
+
|
|
17
|
+
/** Track globally if we've detected a rate limit error */
|
|
18
|
+
let globalRateLimited = false;
|
|
19
|
+
let globalRateLimitResetAt = null;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check if we know we're rate limited and should skip API calls
|
|
23
|
+
* @returns {{isRateLimited: boolean, resetAt: Date|null}}
|
|
24
|
+
*/
|
|
25
|
+
export function isGloballyRateLimited() {
|
|
26
|
+
if (!globalRateLimited || !globalRateLimitResetAt) {
|
|
27
|
+
return { isRateLimited: false, resetAt: null };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Check if rate limit window has passed
|
|
31
|
+
if (Date.now() >= globalRateLimitResetAt) {
|
|
32
|
+
globalRateLimited = false;
|
|
33
|
+
globalRateLimitResetAt = null;
|
|
34
|
+
return { isRateLimited: false, resetAt: null };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { isRateLimited: true, resetAt: new Date(globalRateLimitResetAt) };
|
|
38
|
+
}
|
|
39
|
+
|
|
14
40
|
/**
|
|
15
|
-
*
|
|
41
|
+
* Mark that we've hit the rate limit
|
|
42
|
+
* @param {number} resetAt - Reset timestamp in milliseconds
|
|
43
|
+
*/
|
|
44
|
+
export function markRateLimited(resetAt) {
|
|
45
|
+
globalRateLimited = true;
|
|
46
|
+
globalRateLimitResetAt = resetAt;
|
|
47
|
+
warn('[pi-linear-tools] Rate limit hit - will skip API calls until reset', {
|
|
48
|
+
resetAt: new Date(resetAt).toLocaleTimeString(),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Extract rate limit info from SDK client response
|
|
54
|
+
* The Linear SDK stores response metadata on the client after requests
|
|
55
|
+
* @param {LinearClient} client - Linear SDK client
|
|
56
|
+
* @returns {{remaining: number|null, resetAt: number|null, resetTime: string|null}}
|
|
57
|
+
*/
|
|
58
|
+
export function getClientRateLimit(client) {
|
|
59
|
+
// Try to get from tracker first
|
|
60
|
+
const trackerData = rateLimitTracker.get(client.apiKey || 'default');
|
|
61
|
+
if (trackerData) {
|
|
62
|
+
return {
|
|
63
|
+
remaining: trackerData.remaining,
|
|
64
|
+
resetAt: trackerData.resetAt,
|
|
65
|
+
resetTime: trackerData.resetAt ? new Date(trackerData.resetAt).toLocaleTimeString() : null,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { remaining: null, resetAt: null, resetTime: null };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Check and warn about low rate limit for a client
|
|
74
|
+
* Call this after making API requests to check if limits are getting low
|
|
75
|
+
* @param {LinearClient} client - Linear SDK client
|
|
76
|
+
* @returns {boolean} True if warning was issued
|
|
77
|
+
*/
|
|
78
|
+
export function checkAndWarnRateLimit(client) {
|
|
79
|
+
const { remaining, resetTime } = getClientRateLimit(client);
|
|
80
|
+
|
|
81
|
+
if (remaining !== null && remaining <= 500) {
|
|
82
|
+
const usagePercent = Math.round(((5000 - remaining) / 5000) * 100);
|
|
83
|
+
warn(`Linear API rate limit running low: ${remaining} requests remaining (~${usagePercent}% used). Resets at ${resetTime}`, {
|
|
84
|
+
remaining,
|
|
85
|
+
resetTime,
|
|
86
|
+
usagePercent,
|
|
87
|
+
});
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Create a Linear SDK client with rate limit tracking
|
|
16
96
|
*
|
|
17
97
|
* Supports two authentication methods:
|
|
18
98
|
* 1. API Key: Pass as string or { apiKey: '...' }
|
|
@@ -30,18 +110,22 @@ export function createLinearClient(auth) {
|
|
|
30
110
|
}
|
|
31
111
|
|
|
32
112
|
let clientConfig;
|
|
113
|
+
let apiKey = null;
|
|
33
114
|
|
|
34
115
|
// Handle different input formats
|
|
35
116
|
if (typeof auth === 'string') {
|
|
36
117
|
// Legacy: API key passed as string
|
|
37
118
|
clientConfig = { apiKey: auth };
|
|
119
|
+
apiKey = auth;
|
|
38
120
|
} else if (typeof auth === 'object' && auth !== null) {
|
|
39
121
|
// Object format: { apiKey: '...' } or { accessToken: '...' }
|
|
40
122
|
if (auth.accessToken) {
|
|
41
123
|
clientConfig = { apiKey: auth.accessToken };
|
|
124
|
+
apiKey = auth.accessToken;
|
|
42
125
|
debug('Creating Linear client with OAuth access token');
|
|
43
126
|
} else if (auth.apiKey) {
|
|
44
127
|
clientConfig = { apiKey: auth.apiKey };
|
|
128
|
+
apiKey = auth.apiKey;
|
|
45
129
|
debug('Creating Linear client with API key');
|
|
46
130
|
} else {
|
|
47
131
|
throw new Error(
|
|
@@ -54,7 +138,54 @@ export function createLinearClient(auth) {
|
|
|
54
138
|
);
|
|
55
139
|
}
|
|
56
140
|
|
|
57
|
-
|
|
141
|
+
const client = new LinearClient(clientConfig);
|
|
142
|
+
|
|
143
|
+
// Initialize rate limit tracking for this client
|
|
144
|
+
const trackerKey = apiKey || 'default';
|
|
145
|
+
if (!rateLimitTracker.has(trackerKey)) {
|
|
146
|
+
rateLimitTracker.set(trackerKey, { remaining: 5000, resetAt: Date.now() + 3600000 });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Wrap the rawRequest to capture rate limit headers
|
|
150
|
+
// rawRequest is on client.client (internal GraphQL client)
|
|
151
|
+
const originalRawRequest = client.client.rawRequest.bind(client.client);
|
|
152
|
+
client.client.rawRequest = async function wrappedRawRequest(query, variables, requestHeaders) {
|
|
153
|
+
const response = await originalRawRequest(query, variables, requestHeaders);
|
|
154
|
+
|
|
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');
|
|
159
|
+
|
|
160
|
+
if (remaining !== null) {
|
|
161
|
+
const tracker = rateLimitTracker.get(trackerKey);
|
|
162
|
+
if (tracker) {
|
|
163
|
+
tracker.remaining = parseInt(remaining, 10);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (resetAt !== null) {
|
|
167
|
+
const tracker = rateLimitTracker.get(trackerKey);
|
|
168
|
+
if (tracker) {
|
|
169
|
+
tracker.resetAt = parseInt(resetAt, 10);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
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
|
+
}
|
|
184
|
+
|
|
185
|
+
return response;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
return client;
|
|
58
189
|
}
|
|
59
190
|
|
|
60
191
|
/**
|