@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/CHANGELOG.md +23 -0
- package/extensions/pi-linear-tools.js +4 -96
- 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 +7 -5
- package/src/linear-client.js +149 -39
- package/src/linear.js +7 -12
- package/src/logger.js +56 -16
- package/src/settings.js +5 -4
- package/src/shared.js +112 -0
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:
|
|
19
|
+
clientId: OAUTH_CLIENT_ID,
|
|
19
20
|
redirectUri: 'http://localhost:34711/callback',
|
|
20
21
|
|
|
21
|
-
// OAuth scopes
|
|
22
|
-
scopes:
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
|
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
|
|
69
|
-
*
|
|
70
|
-
* @param {string}
|
|
71
|
-
* @
|
|
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
|
-
|
|
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,
|
|
@@ -29,7 +27,7 @@ import {
|
|
|
29
27
|
deleteIssue,
|
|
30
28
|
withHandlerErrorHandling,
|
|
31
29
|
} from './linear.js';
|
|
32
|
-
import { debug
|
|
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
|
-
|
|
78
|
-
|
|
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
|
/**
|
package/src/linear-client.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
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;
|
|
@@ -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
|
-
*
|
|
82
|
+
* Clear rate limit state if the window has expired
|
|
23
83
|
* @returns {{isRateLimited: boolean, resetAt: Date|null}}
|
|
24
84
|
*/
|
|
25
|
-
export function
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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([
|