@fink-andreas/pi-linear-tools 0.2.1 → 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/CHANGELOG.md +42 -0
- package/extensions/pi-linear-tools.js +167 -134
- package/index.js +167 -134
- package/package.json +2 -2
- package/src/auth/callback-server.js +1 -1
- package/src/auth/constants.js +9 -0
- package/src/auth/index.js +50 -7
- package/src/auth/oauth.js +6 -5
- package/src/auth/pkce.js +16 -53
- package/src/auth/token-refresh.js +5 -7
- package/src/cli.js +1 -13
- package/src/error-hints.js +24 -0
- package/src/handlers.js +50 -45
- package/src/linear-client.js +252 -11
- package/src/linear.js +1066 -636
- package/src/logger.js +56 -16
- package/src/settings.js +5 -4
- package/src/shared.js +112 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -27,6 +25,7 @@ import {
|
|
|
27
25
|
updateProjectMilestone,
|
|
28
26
|
deleteProjectMilestone,
|
|
29
27
|
deleteIssue,
|
|
28
|
+
withHandlerErrorHandling,
|
|
30
29
|
} from './linear.js';
|
|
31
30
|
import { debug } from './logger.js';
|
|
32
31
|
|
|
@@ -73,8 +72,12 @@ async function runGitCommand(args) {
|
|
|
73
72
|
* @returns {Promise<boolean>}
|
|
74
73
|
*/
|
|
75
74
|
async function gitBranchExists(branchName) {
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
try {
|
|
76
|
+
const result = await runGitCommand(['rev-parse', '--verify', branchName]);
|
|
77
|
+
return result.code === 0;
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
/**
|
|
@@ -128,57 +131,59 @@ async function startGitBranch(branchName, fromRef = 'HEAD', onBranchExists = 'sw
|
|
|
128
131
|
* List issues in a project
|
|
129
132
|
*/
|
|
130
133
|
export async function executeIssueList(client, params) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const resolved = await resolveProjectRef(client, projectRef);
|
|
134
|
+
return withHandlerErrorHandling(async () => {
|
|
135
|
+
let projectRef = params.project;
|
|
136
|
+
if (!projectRef) {
|
|
137
|
+
projectRef = process.cwd().split('/').pop();
|
|
138
|
+
}
|
|
137
139
|
|
|
138
|
-
|
|
139
|
-
if (params.assignee === 'me') {
|
|
140
|
-
const viewer = await client.viewer;
|
|
141
|
-
assigneeId = viewer.id;
|
|
142
|
-
}
|
|
140
|
+
const resolved = await resolveProjectRef(client, projectRef);
|
|
143
141
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
142
|
+
let assigneeId = null;
|
|
143
|
+
if (params.assignee === 'me') {
|
|
144
|
+
const viewer = await client.viewer;
|
|
145
|
+
assigneeId = viewer.id;
|
|
146
|
+
}
|
|
148
147
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
projectName: resolved.name,
|
|
153
|
-
issueCount: 0,
|
|
148
|
+
const { issues, truncated } = await fetchIssuesByProject(client, resolved.id, params.states || null, {
|
|
149
|
+
assigneeId,
|
|
150
|
+
limit: params.limit || 20,
|
|
154
151
|
});
|
|
155
|
-
}
|
|
156
152
|
|
|
157
|
-
|
|
153
|
+
if (issues.length === 0) {
|
|
154
|
+
return toTextResult(`No issues found in project "${resolved.name}"`, {
|
|
155
|
+
projectId: resolved.id,
|
|
156
|
+
projectName: resolved.name,
|
|
157
|
+
issueCount: 0,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
158
160
|
|
|
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;
|
|
161
|
+
const lines = [`## Issues in project "${resolved.name}" (${issues.length}${truncated ? '+' : ''})\n`];
|
|
165
162
|
|
|
166
|
-
const
|
|
167
|
-
|
|
163
|
+
for (const issue of issues) {
|
|
164
|
+
const stateLabel = issue.state?.name || 'Unknown';
|
|
165
|
+
const assigneeLabel = issue.assignee?.displayName || 'Unassigned';
|
|
166
|
+
const priorityLabel = issue.priority !== undefined && issue.priority !== null
|
|
167
|
+
? ['None', 'Urgent', 'High', 'Medium', 'Low'][issue.priority] || `P${issue.priority}`
|
|
168
|
+
: null;
|
|
168
169
|
|
|
169
|
-
|
|
170
|
-
|
|
170
|
+
const metaParts = [`[${stateLabel}]`, `@${assigneeLabel}`];
|
|
171
|
+
if (priorityLabel) metaParts.push(priorityLabel);
|
|
171
172
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
173
|
+
lines.push(`- **${issue.identifier}**: ${issue.title} _${metaParts.join(' ')}_`);
|
|
174
|
+
}
|
|
175
175
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
176
|
+
if (truncated) {
|
|
177
|
+
lines.push('\n_Results may be truncated. Use limit parameter to fetch more._');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return toTextResult(lines.join('\n'), {
|
|
181
|
+
projectId: resolved.id,
|
|
182
|
+
projectName: resolved.name,
|
|
183
|
+
issueCount: issues.length,
|
|
184
|
+
truncated,
|
|
185
|
+
});
|
|
186
|
+
}, 'executeIssueList');
|
|
182
187
|
}
|
|
183
188
|
|
|
184
189
|
/**
|
package/src/linear-client.js
CHANGED
|
@@ -6,13 +6,185 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { LinearClient } from '@linear/sdk';
|
|
9
|
-
import { debug, warn,
|
|
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;
|
|
13
13
|
|
|
14
|
+
/** @type {Map<string, {remaining: number, resetAt: number}>} Per-client rate limit tracking */
|
|
15
|
+
const rateLimitTracker = new Map();
|
|
16
|
+
|
|
17
|
+
/** @type {Map<string, {total: number, success: number, failed: number, rateLimited: number, windowStart: number, lastSummaryAt: number}>} */
|
|
18
|
+
const requestMetrics = new Map();
|
|
19
|
+
|
|
20
|
+
/** Track globally if we've detected a rate limit error */
|
|
21
|
+
let globalRateLimited = false;
|
|
22
|
+
let globalRateLimitResetAt = null;
|
|
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
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Clear rate limit state if the window has expired
|
|
83
|
+
* @returns {{isRateLimited: boolean, resetAt: Date|null}}
|
|
84
|
+
*/
|
|
85
|
+
export function checkAndClearRateLimit() {
|
|
86
|
+
if (!globalRateLimited || !globalRateLimitResetAt) {
|
|
87
|
+
return { isRateLimited: false, resetAt: null };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Check if rate limit window has passed
|
|
91
|
+
if (Date.now() >= globalRateLimitResetAt) {
|
|
92
|
+
globalRateLimited = false;
|
|
93
|
+
globalRateLimitResetAt = null;
|
|
94
|
+
return { isRateLimited: false, resetAt: null };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { isRateLimited: true, resetAt: new Date(globalRateLimitResetAt) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @deprecated Use checkAndClearRateLimit() instead
|
|
102
|
+
* @returns {{isRateLimited: boolean, resetAt: Date|null}}
|
|
103
|
+
*/
|
|
104
|
+
export function isGloballyRateLimited() {
|
|
105
|
+
return checkAndClearRateLimit();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Mark that we've hit the rate limit
|
|
110
|
+
* @param {number} resetAt - Reset timestamp in milliseconds
|
|
111
|
+
*/
|
|
112
|
+
export function markRateLimited(resetAt) {
|
|
113
|
+
globalRateLimited = true;
|
|
114
|
+
globalRateLimitResetAt = resetAt;
|
|
115
|
+
warn('[pi-linear-tools] Rate limit hit - will skip API calls until reset', {
|
|
116
|
+
resetAt: new Date(resetAt).toLocaleTimeString(),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
14
120
|
/**
|
|
15
|
-
*
|
|
121
|
+
* Extract rate limit info from SDK client response
|
|
122
|
+
* The Linear SDK stores response metadata on the client after requests
|
|
123
|
+
* @param {LinearClient} client - Linear SDK client
|
|
124
|
+
* @returns {{remaining: number|null, resetAt: number|null, resetTime: string|null}}
|
|
125
|
+
*/
|
|
126
|
+
export function getClientRateLimit(client) {
|
|
127
|
+
const trackerData = rateLimitTracker.get(client.apiKey || 'default');
|
|
128
|
+
if (trackerData) {
|
|
129
|
+
return {
|
|
130
|
+
remaining: trackerData.remaining,
|
|
131
|
+
resetAt: trackerData.resetAt,
|
|
132
|
+
resetTime: trackerData.resetAt ? new Date(trackerData.resetAt).toLocaleTimeString() : null,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { remaining: null, resetAt: null, resetTime: null };
|
|
137
|
+
}
|
|
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
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Check and warn about low rate limit for a client
|
|
166
|
+
* Call this after making API requests to check if limits are getting low
|
|
167
|
+
* @param {LinearClient} client - Linear SDK client
|
|
168
|
+
* @returns {boolean} True if warning was issued
|
|
169
|
+
*/
|
|
170
|
+
export function checkAndWarnRateLimit(client) {
|
|
171
|
+
const { remaining, resetTime } = getClientRateLimit(client);
|
|
172
|
+
|
|
173
|
+
if (remaining !== null && remaining <= 500) {
|
|
174
|
+
const usagePercent = Math.round(((5000 - remaining) / 5000) * 100);
|
|
175
|
+
warn(`Linear API rate limit running low: ${remaining} requests remaining (~${usagePercent}% used). Resets at ${resetTime}`, {
|
|
176
|
+
remaining,
|
|
177
|
+
resetTime,
|
|
178
|
+
usagePercent,
|
|
179
|
+
});
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Create a Linear SDK client with rate limit tracking
|
|
16
188
|
*
|
|
17
189
|
* Supports two authentication methods:
|
|
18
190
|
* 1. API Key: Pass as string or { apiKey: '...' }
|
|
@@ -30,31 +202,100 @@ export function createLinearClient(auth) {
|
|
|
30
202
|
}
|
|
31
203
|
|
|
32
204
|
let clientConfig;
|
|
205
|
+
let apiKey = null;
|
|
33
206
|
|
|
34
207
|
// Handle different input formats
|
|
35
208
|
if (typeof auth === 'string') {
|
|
36
|
-
// Legacy: API key passed as string
|
|
37
209
|
clientConfig = { apiKey: auth };
|
|
210
|
+
apiKey = auth;
|
|
38
211
|
} else if (typeof auth === 'object' && auth !== null) {
|
|
39
|
-
// Object format: { apiKey: '...' } or { accessToken: '...' }
|
|
40
212
|
if (auth.accessToken) {
|
|
41
213
|
clientConfig = { apiKey: auth.accessToken };
|
|
214
|
+
apiKey = auth.accessToken;
|
|
42
215
|
debug('Creating Linear client with OAuth access token');
|
|
43
216
|
} else if (auth.apiKey) {
|
|
44
217
|
clientConfig = { apiKey: auth.apiKey };
|
|
218
|
+
apiKey = auth.apiKey;
|
|
45
219
|
debug('Creating Linear client with API key');
|
|
46
220
|
} else {
|
|
47
|
-
throw new Error(
|
|
48
|
-
'Auth object must contain either apiKey or accessToken'
|
|
49
|
-
);
|
|
221
|
+
throw new Error('Auth object must contain either apiKey or accessToken');
|
|
50
222
|
}
|
|
51
223
|
} else {
|
|
52
|
-
throw new Error(
|
|
53
|
-
|
|
54
|
-
|
|
224
|
+
throw new Error('Invalid auth parameter: must be a string (API key) or an object with apiKey or accessToken');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const client = new LinearClient(clientConfig);
|
|
228
|
+
|
|
229
|
+
const trackerKey = getTrackerKey(apiKey);
|
|
230
|
+
if (!rateLimitTracker.has(trackerKey)) {
|
|
231
|
+
rateLimitTracker.set(trackerKey, { remaining: 5000, resetAt: Date.now() + 3600000 });
|
|
55
232
|
}
|
|
233
|
+
getRequestMetric(trackerKey);
|
|
234
|
+
|
|
235
|
+
// Wrap internal rawRequest to capture request counts + rate limit metadata
|
|
236
|
+
const originalRawRequest = client.client.rawRequest.bind(client.client);
|
|
237
|
+
client.client.rawRequest = async function wrappedRawRequest(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
|
+
});
|
|
246
|
+
|
|
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');
|
|
254
|
+
|
|
255
|
+
const tracker = rateLimitTracker.get(trackerKey);
|
|
256
|
+
if (tracker && remaining !== null) {
|
|
257
|
+
tracker.remaining = parseInt(remaining, 10);
|
|
258
|
+
}
|
|
259
|
+
if (tracker && resetAt !== null) {
|
|
260
|
+
tracker.resetAt = parseInt(resetAt, 10);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
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
|
+
}
|
|
273
|
+
|
|
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
|
+
}
|
|
296
|
+
};
|
|
56
297
|
|
|
57
|
-
return
|
|
298
|
+
return client;
|
|
58
299
|
}
|
|
59
300
|
|
|
60
301
|
/**
|