diff --git a/docs/planning/ROADMAP_1.0.0.md b/docs/planning/ROADMAP_1.0.0.md index 3586748..db9568c 100644 --- a/docs/planning/ROADMAP_1.0.0.md +++ b/docs/planning/ROADMAP_1.0.0.md @@ -310,7 +310,8 @@ Features from Linear/Jira web UIs that would add significant value. | Task | Priority | Effort | Status | |------|----------|--------|--------| -| **Jira rate limiting** | 🟔 P1 | 🟢 | ⬜ Not Started | +| **API Infrastructure** (retry, cache, network monitor) | 🟔 P1 | 🟔 | āœ… Done | +| **Jira rate limiting** | 🟔 P1 | 🟢 | āœ… Done | | **Error handling audit** | 🟔 P1 | 🟔 | ⬜ Not Started | | **Loading states consistency** | 🟔 P1 | 🟢 | ⬜ Not Started | | **Keyboard navigation** | 🟢 P2 | 🟔 | ⬜ Not Started | diff --git a/src/activation/initialization.ts b/src/activation/initialization.ts index d6cb0e9..cee5d13 100644 --- a/src/activation/initialization.ts +++ b/src/activation/initialization.ts @@ -5,6 +5,7 @@ import { getLogger } from "@shared/utils/logger"; import { getTelemetryManager } from "@shared/utils/telemetryManager"; import { loadDevCredentials, showDevModeWarning } from "@shared/utils/devEnvLoader"; import { AIProviderManager } from "@shared/ai/aiProviderManager"; +import { initializeNetworkMonitor } from "@shared/http"; /** * Initialize core services and storage @@ -52,6 +53,14 @@ export async function initializeCoreServices(context: vscode.ExtensionContext): logger.error("Failed to initialize AI Provider Manager (non-critical)", error); } + // Initialize Network Monitor for API health tracking + try { + initializeNetworkMonitor(context); + logger.debug("Network Monitor initialized"); + } catch (error) { + logger.error("Failed to initialize Network Monitor (non-critical)", error); + } + // Initialize Branch Association Manager const branchManager = new BranchAssociationManager(context, "both"); diff --git a/src/activation/treeView.ts b/src/activation/treeView.ts index 099fbde..7b62a5e 100644 --- a/src/activation/treeView.ts +++ b/src/activation/treeView.ts @@ -1,6 +1,12 @@ import * as vscode from "vscode"; import { UniversalTicketsProvider } from "@shared/views/UniversalTicketsProvider"; import { getLogger } from "@shared/utils/logger"; +import { debounce } from "@shared/utils/debounce"; + +/** + * Debounce delay for tree view refresh on visibility changes (in milliseconds) + */ +const TREE_VIEW_REFRESH_DEBOUNCE_MS = 500; /** * Register the tickets tree view (sidebar) @@ -17,11 +23,24 @@ export async function registerTreeView(context: vscode.ExtensionContext): Promis }); context.subscriptions.push(treeView); - // Trigger refresh when the tree view becomes visible + // Track if we've done the initial data load + let hasInitialData = false; + + // Debounced refresh to prevent rapid-fire updates on visibility changes + const debouncedVisibilityRefresh = debounce(() => { + // Only refresh on visibility if we haven't loaded data yet + // Once we have data, the cache will keep it fresh + if (!hasInitialData) { + logger.debug("Universal tree view became visible, loading initial data"); + ticketsProvider?.refresh(); + hasInitialData = true; + } + }, TREE_VIEW_REFRESH_DEBOUNCE_MS); + + // Trigger refresh when the tree view becomes visible (debounced) treeView.onDidChangeVisibility((e) => { if (e.visible) { - logger.debug("Universal tree view became visible, triggering refresh"); - ticketsProvider?.refresh(); + debouncedVisibilityRefresh(); } }); diff --git a/src/extension.ts b/src/extension.ts index d285018..e7f4f96 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,6 +7,7 @@ import * as vscode from "vscode"; import { getLogger } from "@shared/utils/logger"; +import { disposeAllCaches, NetworkMonitor } from "@shared/http"; // Activation modules import { registerUriHandler } from "./activation/uriHandler"; @@ -134,5 +135,15 @@ export async function activate(context: vscode.ExtensionContext) { */ export function deactivate() { const logger = getLogger(); + + // Clean up HTTP infrastructure + try { + disposeAllCaches(); + NetworkMonitor.resetInstance(); + logger.debug("HTTP infrastructure cleaned up"); + } catch (error) { + logger.error("Failed to clean up HTTP infrastructure", error); + } + logger.info("Extension is now deactivated"); } diff --git a/src/providers/jira/cloud/JiraCloudClient.ts b/src/providers/jira/cloud/JiraCloudClient.ts index 92a6e80..b357068 100644 --- a/src/providers/jira/cloud/JiraCloudClient.ts +++ b/src/providers/jira/cloud/JiraCloudClient.ts @@ -10,6 +10,7 @@ import * as vscode from "vscode"; import { BaseJiraClient } from "../common/BaseJiraClient"; +import { TTL } from "@shared/http"; import { JiraIssue, JiraProject, @@ -427,6 +428,9 @@ export class JiraCloudClient extends BaseJiraClient { logger.success(`Created Jira issue: ${validated.key}`); + // Invalidate cache after creating a new issue + this.invalidateAfterMutation(validated.key); + // Fetch the full issue details return this.getIssue(validated.key); } catch (error) { @@ -528,8 +532,12 @@ export class JiraCloudClient extends BaseJiraClient { await this.request(`/issue/${key}`, { method: "PUT", body: JSON.stringify({ fields }), + skipCache: true, }); + // Invalidate cache after updating issue + this.invalidateAfterMutation(key); + logger.success(`Updated Jira issue: ${key}`); return true; } catch (error) { @@ -581,8 +589,12 @@ export class JiraCloudClient extends BaseJiraClient { body: JSON.stringify({ transition: { id: transitionId }, }), + skipCache: true, }); + // Invalidate cache after transition + this.invalidateAfterMutation(key); + logger.success(`Transitioned issue ${key}`); return true; } catch (error) { @@ -639,8 +651,12 @@ export class JiraCloudClient extends BaseJiraClient { try { await this.request(`/issue/${key}`, { method: "DELETE", + skipCache: true, }); + // Invalidate cache after deletion + this.invalidateAfterMutation(key); + logger.success(`Deleted issue ${key}`); return true; } catch (error) { @@ -662,8 +678,10 @@ export class JiraCloudClient extends BaseJiraClient { let isLast = false; while (!isLast) { + // Projects rarely change, cache for 15 minutes const response = await this.request( - `/project/search?startAt=${startAt}&maxResults=${maxResults}` + `/project/search?startAt=${startAt}&maxResults=${maxResults}`, + { ttl: TTL.LONG } ); // Validate response with Zod @@ -805,8 +823,10 @@ export class JiraCloudClient extends BaseJiraClient { */ async getIssueTypes(projectKeyOrId: string): Promise { try { + // Issue types rarely change, cache for 30 minutes const response = await this.request( - `/issuetype/project?projectId=${projectKeyOrId}` + `/issuetype/project?projectId=${projectKeyOrId}`, + { ttl: TTL.VERY_LONG } ); // Validate response with Zod @@ -834,7 +854,8 @@ export class JiraCloudClient extends BaseJiraClient { */ async getPriorities(): Promise { try { - const response = await this.request("/priority"); + // Priorities rarely change, cache for 30 minutes + const response = await this.request("/priority", { ttl: TTL.VERY_LONG }); // Validate response with Zod const validated = JiraPrioritiesResponseSchema.parse(response); @@ -859,8 +880,10 @@ export class JiraCloudClient extends BaseJiraClient { */ async getStatuses(projectKey: string): Promise { try { + // Statuses rarely change, cache for 15 minutes const response = await this.request( - `/project/${projectKey}/statuses` + `/project/${projectKey}/statuses`, + { ttl: TTL.LONG } ); // Validate response with Zod diff --git a/src/providers/jira/common/BaseJiraClient.ts b/src/providers/jira/common/BaseJiraClient.ts index 7ff6c2b..02216f6 100644 --- a/src/providers/jira/common/BaseJiraClient.ts +++ b/src/providers/jira/common/BaseJiraClient.ts @@ -23,8 +23,43 @@ import { JiraSearchOptions, JiraComment, } from "./types"; +import { + getHttpClient, + TTLCache, + TTL, + generateCacheKey, + HttpError, +} from "@shared/http"; +import { getLogger } from "@shared/utils/logger"; + +const logger = getLogger(); + +/** + * Request options with caching support + */ +export interface JiraRequestOptions { + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + body?: object | string; + headers?: Record; + /** TTL for caching (only applies to GET requests). Use 0 to skip cache. */ + ttl?: number; + /** Skip cache lookup/storage for this request */ + skipCache?: boolean; +} export abstract class BaseJiraClient { + /** Cache for API responses */ + protected cache: TTLCache; + + constructor() { + // Initialize cache with default settings + // Jira has stricter rate limits, so we use longer default TTL + this.cache = new TTLCache({ + defaultTTL: TTL.MEDIUM, // 2 minutes default + maxSize: 200, + }); + } + /** * Get the base API URL (platform-specific) */ @@ -36,44 +71,97 @@ export abstract class BaseJiraClient { protected abstract getAuthHeaders(): Record; /** - * Make authenticated HTTP request to Jira API + * Make authenticated HTTP request to Jira API with retry and caching support */ protected async request( endpoint: string, - options: RequestInit = {} + options: JiraRequestOptions = {} ): Promise { + const { method = "GET", body, headers = {}, ttl, skipCache = false } = options; const url = `${this.getApiBaseUrl()}${endpoint}`; - const headers = { - "Accept": "application/json", - "Content-Type": "application/json", - ...this.getAuthHeaders(), - ...options.headers, - }; - - const response = await fetch(url, { - ...options, - headers, - }); - if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `Jira API error: ${response.status} ${response.statusText} - ${errorText}` - ); + // Only cache GET requests + const isCacheable = method === "GET" && !skipCache; + const cacheKey = isCacheable ? generateCacheKey("jira", endpoint) : ""; + + // Check cache first for GET requests + if (isCacheable) { + const cached = this.cache.get(cacheKey); + if (cached !== undefined) { + logger.debug(`[Jira] Cache hit for ${endpoint}`); + return cached as T; + } } - // Handle empty responses (204 No Content) - const contentLength = response.headers.get("content-length"); - if (response.status === 204 || contentLength === "0") { - return undefined as T; - } + const httpClient = getHttpClient(); - const text = await response.text(); - if (!text || text.trim() === "") { - return undefined as T; + try { + const response = await httpClient.request(url, { + method, + body, + headers: { + ...this.getAuthHeaders(), + ...headers, + }, + retry: { + maxRetries: 3, + baseDelay: 1000, + maxDelay: 30000, // Jira rate limits can require longer waits + retryableStatuses: [429, 500, 502, 503, 504], + retryOnNetworkError: true, + }, + }); + + // Cache successful GET responses + if (isCacheable && response.data !== undefined) { + const effectiveTTL = ttl ?? TTL.MEDIUM; + if (effectiveTTL > 0) { + this.cache.set(cacheKey, response.data, effectiveTTL); + } + } + + return response.data; + } catch (error) { + if (error instanceof HttpError) { + logger.error( + `[Jira] API Error - ${method} ${endpoint}: ${error.status} ${error.statusText}` + ); + throw new Error( + `Jira API error: ${error.status} ${error.statusText} - ${error.body}` + ); + } + throw error; } + } + + /** + * Clear the entire API response cache + */ + clearCache(): void { + this.cache.clear(); + logger.debug("[Jira] Cache cleared"); + } + + /** + * Invalidate cache entries matching a pattern + * @param pattern String pattern to match against cache keys + */ + invalidateCache(pattern: string): void { + const count = this.cache.invalidateByPattern(pattern); + logger.debug(`[Jira] Invalidated ${count} cache entries matching: ${pattern}`); + } - return JSON.parse(text) as T; + /** + * Invalidate cache after a mutation (create, update, delete) + * Call this after any operation that modifies data + */ + protected invalidateAfterMutation(issueKey?: string): void { + // Invalidate issue-related caches + this.invalidateCache("issue"); + this.invalidateCache("search"); + if (issueKey) { + this.invalidateCache(issueKey); + } } // ==================== Issue Operations ==================== diff --git a/src/providers/jira/server/JiraServerClient.ts b/src/providers/jira/server/JiraServerClient.ts index 47b70e7..269f32b 100644 --- a/src/providers/jira/server/JiraServerClient.ts +++ b/src/providers/jira/server/JiraServerClient.ts @@ -21,6 +21,7 @@ import * as vscode from "vscode"; import { BaseJiraClient } from "../common/BaseJiraClient"; +import { TTL } from "@shared/http"; import { JiraIssue, JiraProject, @@ -571,10 +572,13 @@ export class JiraServerClient extends BaseJiraClient { const response = await this.request("/issue", { method: "POST", body: JSON.stringify({ fields }), + skipCache: true, }); // Fetch the created issue to get full details if (response.key) { + // Invalidate cache after creating issue + this.invalidateAfterMutation(response.key); return this.getIssue(response.key); } @@ -620,8 +624,12 @@ export class JiraServerClient extends BaseJiraClient { await this.request(`/issue/${key}`, { method: "PUT", body: JSON.stringify({ fields }), + skipCache: true, }); + // Invalidate cache after updating issue + this.invalidateAfterMutation(key); + return true; } catch (error) { logger.error(`Failed to update issue ${key}`, error); @@ -636,8 +644,12 @@ export class JiraServerClient extends BaseJiraClient { body: JSON.stringify({ transition: { id: transitionId }, }), + skipCache: true, }); + // Invalidate cache after transition + this.invalidateAfterMutation(key); + return true; } catch (error) { logger.error(`Failed to transition issue ${key}`, error); @@ -675,8 +687,12 @@ export class JiraServerClient extends BaseJiraClient { try { await this.request(`/issue/${key}`, { method: "DELETE", + skipCache: true, }); + // Invalidate cache after deletion + this.invalidateAfterMutation(key); + return true; } catch (error) { logger.error(`Failed to delete issue ${key}`, error); @@ -688,7 +704,8 @@ export class JiraServerClient extends BaseJiraClient { async getProjects(): Promise { try { - const response = await this.request("/project"); + // Projects rarely change, cache for 15 minutes + const response = await this.request("/project", { ttl: TTL.LONG }); return response.map((p: any) => this.normalizeProject(p)); } catch (error) { logger.error("Failed to fetch projects", error); @@ -746,7 +763,8 @@ export class JiraServerClient extends BaseJiraClient { async getIssueTypes(projectKeyOrId: string): Promise { try { - const response = await this.request(`/project/${projectKeyOrId}`); + // Issue types rarely change, cache for 30 minutes + const response = await this.request(`/project/${projectKeyOrId}`, { ttl: TTL.VERY_LONG }); return response.issueTypes?.map((it: any) => this.normalizeIssueType(it)) || []; } catch (error) { logger.error(`Failed to fetch issue types for ${projectKeyOrId}`, error); @@ -756,7 +774,8 @@ export class JiraServerClient extends BaseJiraClient { async getPriorities(): Promise { try { - const response = await this.request("/priority"); + // Priorities rarely change, cache for 30 minutes + const response = await this.request("/priority", { ttl: TTL.VERY_LONG }); return response.map((p: any) => this.normalizePriority(p)); } catch (error) { logger.error("Failed to fetch priorities", error); @@ -766,7 +785,8 @@ export class JiraServerClient extends BaseJiraClient { async getStatuses(projectKey: string): Promise { try { - const response = await this.request(`/project/${projectKey}/statuses`); + // Statuses rarely change, cache for 15 minutes + const response = await this.request(`/project/${projectKey}/statuses`, { ttl: TTL.LONG }); // Extract unique statuses from all issue types const statusMap = new Map(); diff --git a/src/providers/linear/LinearClient.ts b/src/providers/linear/LinearClient.ts index 0cdffd4..3ac3ed5 100644 --- a/src/providers/linear/LinearClient.ts +++ b/src/providers/linear/LinearClient.ts @@ -1,8 +1,17 @@ import * as vscode from "vscode"; import { BaseTicketProvider, CreateTicketInput, TicketFilter } from "@shared/base/BaseTicketProvider"; import { getLogger } from "@shared/utils/logger"; +import { + getHttpClient, + TTLCache, + TTL, + generateQueryCacheKey, + HttpError, +} from "@shared/http"; import { LinearIssue, LinearUser, LinearProject, LinearTeam, LinearTemplate } from "./types"; +const logger = getLogger(); + export class LinearClient extends BaseTicketProvider< LinearIssue, LinearUser, @@ -14,10 +23,18 @@ export class LinearClient extends BaseTicketProvider< private apiToken: string; private baseUrl = "https://api.linear.app/graphql"; private static secretStorage: vscode.SecretStorage | undefined; + + /** Cache for API responses */ + private cache: TTLCache; constructor(apiToken?: string) { super(); this.apiToken = apiToken || ""; + // Initialize cache with default settings + this.cache = new TTLCache({ + defaultTTL: TTL.MEDIUM, // 2 minutes default + maxSize: 150, + }); } /** @@ -393,10 +410,14 @@ export class LinearClient extends BaseTicketProvider< `; try { - const response = await this.executeQuery(mutation); + const response = await this.executeQuery(mutation, { skipCache: true }); + if (response.data.issueUpdate.success) { + // Invalidate cached issue data after successful update + this.invalidateCache("issue"); + } return response.data.issueUpdate.success; } catch (error) { - console.error(`[Linear Buddy] Failed to update issue status:`, error); + logger.error(`[Linear] Failed to update issue status:`, error); return false; } } @@ -423,10 +444,10 @@ export class LinearClient extends BaseTicketProvider< `; try { - const response = await this.executeQuery(mutation); + const response = await this.executeQuery(mutation, { skipCache: true }); return response.data.commentCreate.success; } catch (error) { - console.error(`[Linear Buddy] Failed to add comment:`, error); + logger.error(`[Linear] Failed to add comment:`, error); return false; } } @@ -455,10 +476,13 @@ export class LinearClient extends BaseTicketProvider< `; try { - const response = await this.executeQuery(mutation); + const response = await this.executeQuery(mutation, { skipCache: true }); + if (response.data.issueUpdate.success) { + this.invalidateCache("issue"); + } return response.data.issueUpdate.success; } catch (error) { - console.error(`[Linear Buddy] Failed to update issue title:`, error); + logger.error(`[Linear] Failed to update issue title:`, error); return false; } } @@ -499,13 +523,13 @@ export class LinearClient extends BaseTicketProvider< `; try { - const response = await this.executeQuery(mutation); + const response = await this.executeQuery(mutation, { skipCache: true }); + if (response.data.issueUpdate.success) { + this.invalidateCache("issue"); + } return response.data.issueUpdate.success; } catch (error) { - console.error( - `[Linear Buddy] Failed to update issue description:`, - error - ); + logger.error(`[Linear] Failed to update issue description:`, error); return false; } } @@ -542,10 +566,13 @@ export class LinearClient extends BaseTicketProvider< `; try { - const response = await this.executeQuery(mutation); + const response = await this.executeQuery(mutation, { skipCache: true }); + if (response.data.issueUpdate.success) { + this.invalidateCache("issue"); + } return response.data.issueUpdate.success; } catch (error) { - console.error(`[Linear Buddy] Failed to update issue assignee:`, error); + logger.error(`[Linear] Failed to update issue assignee:`, error); return false; } } @@ -589,10 +616,13 @@ export class LinearClient extends BaseTicketProvider< `; try { - const response = await this.executeQuery(mutation); + const response = await this.executeQuery(mutation, { skipCache: true }); + if (response.data.issueUpdate.success) { + this.invalidateCache("issue"); + } return response.data.issueUpdate.success; } catch (error) { - console.error(`[Linear Buddy] Failed to update issue labels:`, error); + logger.error(`[Linear] Failed to update issue labels:`, error); return false; } } @@ -636,7 +666,8 @@ export class LinearClient extends BaseTicketProvider< `; try { - const response = await this.executeQuery(query); + // Cycles are metadata, cache for 15 minutes + const response = await this.executeQuery(query, { ttl: TTL.LONG }); const activeCycles = response.data.team.cycles?.nodes || []; const upcomingCycles = response.data.team.upcomingCycles?.nodes || []; // Combine and deduplicate @@ -646,7 +677,7 @@ export class LinearClient extends BaseTicketProvider< ); return uniqueCycles; } catch (error) { - console.error(`[Linear Buddy] Failed to fetch cycles:`, error); + logger.error(`[Linear] Failed to fetch cycles:`, error); return []; } } @@ -684,10 +715,13 @@ export class LinearClient extends BaseTicketProvider< `; try { - const response = await this.executeQuery(mutation); + const response = await this.executeQuery(mutation, { skipCache: true }); + if (response.data.issueUpdate.success) { + this.invalidateCache("issue"); + } return response.data.issueUpdate.success; } catch (error) { - console.error(`[Linear Buddy] Failed to update issue cycle:`, error); + logger.error(`[Linear] Failed to update issue cycle:`, error); return false; } } @@ -716,10 +750,11 @@ export class LinearClient extends BaseTicketProvider< `; try { - const response = await this.executeQuery(query); + // Team members rarely change, cache for 15 minutes + const response = await this.executeQuery(query, { ttl: TTL.LONG }); return response.data.team.members.nodes; } catch (error) { - console.error(`[Linear Buddy] Failed to fetch team members:`, error); + logger.error(`[Linear] Failed to fetch team members:`, error); return []; } } @@ -752,10 +787,11 @@ export class LinearClient extends BaseTicketProvider< `; try { - const response = await this.executeQuery(query); + // User search results can be cached briefly + const response = await this.executeQuery(query, { ttl: TTL.MEDIUM }); return response.data.users.nodes; } catch (error) { - console.error(`[Linear Buddy] Failed to search users:`, error); + logger.error(`[Linear] Failed to search users:`, error); return []; } } @@ -781,10 +817,11 @@ export class LinearClient extends BaseTicketProvider< `; try { - const response = await this.executeQuery(query); + // Teams rarely change, cache for 15 minutes + const response = await this.executeQuery(query, { ttl: TTL.LONG }); return response.data.teams.nodes; } catch (error) { - console.error(`[Linear Buddy] Failed to fetch teams:`, error); + logger.error(`[Linear] Failed to fetch teams:`, error); return []; } } @@ -863,21 +900,24 @@ export class LinearClient extends BaseTicketProvider< `; try { - const response = await this.executeQuery(mutation); + const response = await this.executeQuery(mutation, { skipCache: true }); if (response.data.issueCreate.success) { const createdIssue = response.data.issueCreate.issue; + // Invalidate issues cache after creating new issue + this.invalidateCache("viewer"); + this.invalidateCache("issue"); + // Debug log what came back if (createdIssue && createdIssue.description) { - console.log('[Linear Debug] Created issue returned with description:'); - console.log('Description:', createdIssue.description); + logger.debug(`[Linear] Created issue returned with description: ${createdIssue.description}`); } return createdIssue; } return null; } catch (error) { - console.error(`[Linear Buddy] Failed to create issue:`, error); + logger.error(`[Linear] Failed to create issue:`, error); return null; } } @@ -906,10 +946,11 @@ export class LinearClient extends BaseTicketProvider< `; try { - const response = await this.executeQuery(query); + // Templates rarely change, cache for 15 minutes + const response = await this.executeQuery(query, { ttl: TTL.LONG }); return response.data.team.templates.nodes; } catch (error) { - console.error(`[Linear Buddy] Failed to fetch templates:`, error); + logger.error(`[Linear] Failed to fetch templates:`, error); return []; } } @@ -939,10 +980,11 @@ export class LinearClient extends BaseTicketProvider< `; try { - const response = await this.executeQuery(query); + // Labels rarely change, cache for 15 minutes + const response = await this.executeQuery(query, { ttl: TTL.LONG }); return response.data.team.labels.nodes; } catch (error) { - console.error(`[Linear Buddy] Failed to fetch labels:`, error); + logger.error(`[Linear] Failed to fetch labels:`, error); return []; } } @@ -1392,37 +1434,90 @@ export class LinearClient extends BaseTicketProvider< } /** - * Execute a GraphQL query + * Execute a GraphQL query with caching and retry support + * @param query The GraphQL query string + * @param options Optional configuration for caching */ - private async executeQuery(query: string): Promise { - const response = await fetch(this.baseUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: this.apiToken, - }, - body: JSON.stringify({ query }), - }); + private async executeQuery( + query: string, + options?: { ttl?: number; skipCache?: boolean } + ): Promise { + const cacheKey = generateQueryCacheKey(query); + + // Check cache first (unless explicitly skipped) + if (!options?.skipCache) { + const cached = this.cache.get(cacheKey); + if (cached) { + logger.debug(`[Linear] Cache hit for query`); + return cached; + } + } - if (!response.ok) { - const errorBody = await response.text(); - console.error( - `[Linear Buddy] API Error - Status: ${response.status} ${response.statusText}` - ); - console.error(`[Linear Buddy] Response body:`, errorBody); - throw new Error( - `Linear API error: ${response.status} ${response.statusText} - ${errorBody}` + const httpClient = getHttpClient(); + + try { + const response = await httpClient.post( + this.baseUrl, + { query }, + { + headers: { + Authorization: this.apiToken, + }, + retry: { + maxRetries: 3, + baseDelay: 1000, + maxDelay: 10000, + retryableStatuses: [429, 500, 502, 503, 504], + retryOnNetworkError: true, + }, + } ); - } - const data: any = await response.json(); + const data = response.data; - if (data.errors) { - console.error(`[Linear Buddy] GraphQL Errors:`, data.errors); - throw new Error(`Linear GraphQL error: ${JSON.stringify(data.errors)}`); + // GraphQL errors are not retryable - they indicate query/data issues + if (data.errors) { + logger.error(`[Linear] GraphQL Errors:`, data.errors); + throw new Error(`Linear GraphQL error: ${JSON.stringify(data.errors)}`); + } + + // Cache successful response + const ttl = options?.ttl ?? TTL.MEDIUM; + if (ttl > 0) { + this.cache.set(cacheKey, data, ttl); + } + + return data; + } catch (error) { + if (error instanceof HttpError) { + logger.error( + `[Linear] API Error - Status: ${error.status} ${error.statusText}` + ); + logger.error(`[Linear] Response body:`, error.body); + throw new Error( + `Linear API error: ${error.status} ${error.statusText} - ${error.body}` + ); + } + throw error; } + } - return data; + /** + * Clear the API response cache + * Useful after mutations to ensure fresh data + */ + clearCache(): void { + this.cache.clear(); + logger.debug("[Linear] Cache cleared"); + } + + /** + * Invalidate cache entries matching a pattern + * @param pattern String pattern to match against cache keys + */ + invalidateCache(pattern: string): void { + const count = this.cache.invalidateByPattern(pattern); + logger.debug(`[Linear] Invalidated ${count} cache entries matching: ${pattern}`); } } diff --git a/src/shared/http/NetworkMonitor.ts b/src/shared/http/NetworkMonitor.ts new file mode 100644 index 0000000..dbe70bc --- /dev/null +++ b/src/shared/http/NetworkMonitor.ts @@ -0,0 +1,301 @@ +/** + * Network Monitor + * + * Tracks online/offline status based on request success/failure patterns. + * Provides status bar integration to show network status in VS Code. + */ + +import * as vscode from "vscode"; +import { getLogger } from "@shared/utils/logger"; + +const logger = getLogger(); + +/** + * Configuration for network monitoring + */ +export interface NetworkMonitorConfig { + /** Number of consecutive failures before marking as offline (default: 3) */ + failureThreshold: number; + /** Number of consecutive successes to recover from offline (default: 1) */ + recoveryThreshold: number; + /** Show status bar item (default: true) */ + showStatusBar: boolean; +} + +/** + * Network status + */ +export type NetworkStatus = "online" | "offline" | "degraded"; + +/** + * Event listener type + */ +type StatusChangeListener = (status: NetworkStatus) => void; + +/** + * Default configuration + */ +const DEFAULT_CONFIG: NetworkMonitorConfig = { + failureThreshold: 3, + recoveryThreshold: 1, + showStatusBar: true, +}; + +/** + * Network Monitor + * + * Singleton that tracks network status based on API request outcomes. + * Shows a status bar item when offline or degraded. + */ +export class NetworkMonitor { + private static instance: NetworkMonitor | null = null; + + private config: NetworkMonitorConfig; + private status: NetworkStatus = "online"; + private consecutiveFailures = 0; + private consecutiveSuccesses = 0; + private statusBarItem: vscode.StatusBarItem | null = null; + private listeners: Set = new Set(); + private lastFailureTime: number | null = null; + + private constructor(config?: Partial) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + /** + * Get the singleton instance + */ + static getInstance(config?: Partial): NetworkMonitor { + if (!NetworkMonitor.instance) { + NetworkMonitor.instance = new NetworkMonitor(config); + } + return NetworkMonitor.instance; + } + + /** + * Reset the singleton instance (useful for testing) + */ + static resetInstance(): void { + if (NetworkMonitor.instance) { + NetworkMonitor.instance.dispose(); + } + NetworkMonitor.instance = null; + } + + /** + * Initialize the network monitor with VS Code context + * Creates the status bar item + */ + initialize(context: vscode.ExtensionContext): void { + if (this.config.showStatusBar && !this.statusBarItem) { + this.statusBarItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Right, + 50 + ); + this.statusBarItem.name = "DevBuddy Network Status"; + context.subscriptions.push(this.statusBarItem); + + // Only show when not online + this.updateStatusBar(); + } + } + + /** + * Record a successful network request + */ + recordSuccess(): void { + this.consecutiveSuccesses++; + this.consecutiveFailures = 0; + + // Check for recovery from offline/degraded + if ( + this.status !== "online" && + this.consecutiveSuccesses >= this.config.recoveryThreshold + ) { + this.setStatus("online"); + logger.info("Network connection restored"); + } + } + + /** + * Record a failed network request + */ + recordFailure(): void { + this.consecutiveFailures++; + this.consecutiveSuccesses = 0; + this.lastFailureTime = Date.now(); + + // Check thresholds for status change + if (this.consecutiveFailures >= this.config.failureThreshold) { + if (this.status !== "offline") { + this.setStatus("offline"); + logger.warn("Network appears to be offline"); + } + } else if (this.consecutiveFailures >= 1 && this.status === "online") { + this.setStatus("degraded"); + logger.debug("Network connection may be degraded"); + } + } + + /** + * Get the current network status + */ + getStatus(): NetworkStatus { + return this.status; + } + + /** + * Check if the network is online + */ + isOnline(): boolean { + return this.status === "online"; + } + + /** + * Check if the network is offline + */ + isOffline(): boolean { + return this.status === "offline"; + } + + /** + * Get time since last failure (in milliseconds) + */ + getTimeSinceLastFailure(): number | null { + if (this.lastFailureTime === null) { + return null; + } + return Date.now() - this.lastFailureTime; + } + + /** + * Subscribe to status changes + */ + onStatusChange(listener: StatusChangeListener): vscode.Disposable { + this.listeners.add(listener); + return new vscode.Disposable(() => { + this.listeners.delete(listener); + }); + } + + /** + * Manually set status (useful for testing or manual override) + */ + setStatus(status: NetworkStatus): void { + if (this.status !== status) { + const previousStatus = this.status; + this.status = status; + + logger.debug(`Network status changed: ${previousStatus} -> ${status}`); + + // Update UI + this.updateStatusBar(); + + // Notify listeners + for (const listener of this.listeners) { + try { + listener(status); + } catch (error) { + logger.error("Error in network status listener:", error); + } + } + } + } + + /** + * Reset counters (useful after manual intervention) + */ + reset(): void { + this.consecutiveFailures = 0; + this.consecutiveSuccesses = 0; + this.setStatus("online"); + } + + /** + * Dispose of resources + */ + dispose(): void { + if (this.statusBarItem) { + this.statusBarItem.dispose(); + this.statusBarItem = null; + } + this.listeners.clear(); + } + + /** + * Update the status bar item based on current status + */ + private updateStatusBar(): void { + if (!this.statusBarItem) { + return; + } + + switch (this.status) { + case "offline": + this.statusBarItem.text = "$(cloud-offline) DevBuddy Offline"; + this.statusBarItem.tooltip = "Network connection appears to be offline. Some features may be unavailable."; + this.statusBarItem.backgroundColor = new vscode.ThemeColor( + "statusBarItem.errorBackground" + ); + this.statusBarItem.show(); + break; + + case "degraded": + this.statusBarItem.text = "$(warning) DevBuddy"; + this.statusBarItem.tooltip = "Network connection may be unstable."; + this.statusBarItem.backgroundColor = new vscode.ThemeColor( + "statusBarItem.warningBackground" + ); + this.statusBarItem.show(); + break; + + case "online": + default: + // Hide status bar when online + this.statusBarItem.hide(); + break; + } + } + + /** + * Get diagnostic information + */ + getDiagnostics(): { + status: NetworkStatus; + consecutiveFailures: number; + consecutiveSuccesses: number; + lastFailureTime: number | null; + config: NetworkMonitorConfig; + } { + return { + status: this.status, + consecutiveFailures: this.consecutiveFailures, + consecutiveSuccesses: this.consecutiveSuccesses, + lastFailureTime: this.lastFailureTime, + config: { ...this.config }, + }; + } +} + +/** + * Get the singleton network monitor instance + */ +export function getNetworkMonitor( + config?: Partial +): NetworkMonitor { + return NetworkMonitor.getInstance(config); +} + +/** + * Initialize the network monitor with VS Code context + * Should be called during extension activation + */ +export function initializeNetworkMonitor( + context: vscode.ExtensionContext, + config?: Partial +): NetworkMonitor { + const monitor = NetworkMonitor.getInstance(config); + monitor.initialize(context); + return monitor; +} + diff --git a/src/shared/http/RetryableHttpClient.ts b/src/shared/http/RetryableHttpClient.ts new file mode 100644 index 0000000..2df1fe0 --- /dev/null +++ b/src/shared/http/RetryableHttpClient.ts @@ -0,0 +1,523 @@ +/** + * Retryable HTTP Client + * + * Provides HTTP request functionality with: + * - Exponential backoff retry logic + * - Rate limit handling (429 with Retry-After) + * - Configurable retry strategies + * - Network error detection + */ + +import { getLogger } from "@shared/utils/logger"; +import { getNetworkMonitor } from "./NetworkMonitor"; + +const logger = getLogger(); + +/** + * Configuration for retry behavior + */ +export interface RetryConfig { + /** Maximum number of retry attempts (default: 3) */ + maxRetries: number; + /** Base delay in milliseconds for exponential backoff (default: 1000) */ + baseDelay: number; + /** Maximum delay in milliseconds (default: 10000) */ + maxDelay: number; + /** HTTP status codes that should trigger a retry */ + retryableStatuses: number[]; + /** Whether to retry on network errors (default: true) */ + retryOnNetworkError: boolean; +} + +/** + * Request options for the HTTP client + */ +export interface HttpRequestOptions { + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + headers?: Record; + body?: string | object; + /** Override default retry configuration */ + retry?: Partial; + /** Request timeout in milliseconds */ + timeout?: number; + /** Skip caching for this request */ + skipCache?: boolean; +} + +/** + * Response from the HTTP client + */ +export interface HttpResponse { + data: T; + status: number; + headers: Headers; + fromCache?: boolean; +} + +/** + * Error thrown by the HTTP client + */ +export class HttpError extends Error { + constructor( + message: string, + public readonly status: number, + public readonly statusText: string, + public readonly body?: string, + public readonly retryable: boolean = false + ) { + super(message); + this.name = "HttpError"; + } +} + +/** + * Minimum delay in milliseconds for rate limit retries when no Retry-After header is present. + * This ensures we wait long enough to avoid immediately hitting the rate limit again. + */ +const MIN_RATE_LIMIT_DELAY_MS = 30000; // 30 seconds + +/** + * Default retry configuration + */ +const DEFAULT_RETRY_CONFIG: RetryConfig = { + maxRetries: 3, + baseDelay: 1000, + maxDelay: 10000, + retryableStatuses: [429, 500, 502, 503, 504], + retryOnNetworkError: true, +}; + +/** + * Calculate delay for exponential backoff with jitter + */ +function calculateBackoffDelay( + attempt: number, + baseDelay: number, + maxDelay: number +): number { + // Exponential backoff: baseDelay * 2^attempt + const exponentialDelay = baseDelay * Math.pow(2, attempt); + // Add jitter (±25% randomization) + const jitter = exponentialDelay * 0.25 * (Math.random() * 2 - 1); + const delay = Math.min(exponentialDelay + jitter, maxDelay); + return Math.floor(delay); +} + +/** + * Parse Retry-After header value + * Can be either a number of seconds or an HTTP date + */ +function parseRetryAfter(retryAfter: string | null): number | null { + if (!retryAfter) { + return null; + } + + // Try parsing as number of seconds + const seconds = parseInt(retryAfter, 10); + if (!isNaN(seconds)) { + return seconds * 1000; // Convert to milliseconds + } + + // Try parsing as HTTP date + const date = Date.parse(retryAfter); + if (!isNaN(date)) { + return Math.max(0, date - Date.now()); + } + + return null; +} + +/** + * Sleep for a given number of milliseconds + */ +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Check if an error is a network error + */ +export function isNetworkError(error: unknown): boolean { + if (error instanceof TypeError) { + // fetch throws TypeError for network errors + return true; + } + if (error instanceof Error) { + const message = error.message.toLowerCase(); + return ( + message.includes("network") || + message.includes("fetch") || + message.includes("econnrefused") || + message.includes("enotfound") || + message.includes("timeout") + ); + } + return false; +} + +/** + * User-friendly error messages based on error type + */ +export interface UserFriendlyError { + /** Short title for UI display */ + title: string; + /** Longer description for tooltips */ + description: string; + /** Icon name (VS Code Codicon) */ + icon: string; + /** Whether the user can retry */ + canRetry: boolean; + /** Suggested action */ + action?: string; +} + +/** + * Convert an error to a user-friendly message + */ +export function getUserFriendlyError(error: unknown, context?: string): UserFriendlyError { + // Network/connectivity errors + if (isNetworkError(error)) { + return { + title: "Unable to connect", + description: "Check your internet connection and try again.", + icon: "cloud-offline", + canRetry: true, + action: "Check your network connection", + }; + } + + // HTTP errors + if (error instanceof HttpError) { + switch (error.status) { + case 401: + return { + title: "Authentication failed", + description: "Your API token may be invalid or expired. Please reconfigure.", + icon: "key", + canRetry: false, + action: "Reconfigure API token", + }; + case 403: + return { + title: "Access denied", + description: "You don't have permission to access this resource.", + icon: "lock", + canRetry: false, + }; + case 404: + return { + title: "Not found", + description: "The requested resource could not be found.", + icon: "search-stop", + canRetry: false, + }; + case 429: + return { + title: "Rate limited", + description: "Too many requests. Please wait a moment and try again.", + icon: "watch", + canRetry: true, + action: "Wait and retry", + }; + case 500: + case 502: + case 503: + case 504: + return { + title: "Service temporarily unavailable", + description: `The ${context || "service"} is experiencing issues. Try again later.`, + icon: "server", + canRetry: true, + action: "Try again later", + }; + default: + return { + title: "Request failed", + description: `Error ${error.status}: ${error.statusText}`, + icon: "error", + canRetry: error.retryable, + }; + } + } + + // Generic errors + return { + title: "Something went wrong", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + icon: "warning", + canRetry: true, + action: "Try refreshing", + }; +} + +/** + * Retryable HTTP Client + * + * Singleton instance that handles HTTP requests with retry logic, + * rate limiting, and network error detection. + */ +export class RetryableHttpClient { + private static instance: RetryableHttpClient | null = null; + private defaultConfig: RetryConfig; + + private constructor(config?: Partial) { + this.defaultConfig = { ...DEFAULT_RETRY_CONFIG, ...config }; + } + + /** + * Get the singleton instance + */ + static getInstance(config?: Partial): RetryableHttpClient { + if (!RetryableHttpClient.instance) { + RetryableHttpClient.instance = new RetryableHttpClient(config); + } + return RetryableHttpClient.instance; + } + + /** + * Reset the singleton instance (useful for testing) + */ + static resetInstance(): void { + RetryableHttpClient.instance = null; + } + + /** + * Make an HTTP request with retry logic + */ + async request( + url: string, + options: HttpRequestOptions = {} + ): Promise> { + const retryConfig = { ...this.defaultConfig, ...options.retry }; + const networkMonitor = getNetworkMonitor(); + + let lastError: Error | null = null; + let attempt = 0; + + while (attempt <= retryConfig.maxRetries) { + try { + const response = await this.executeRequest(url, options); + + // Notify network monitor of success + networkMonitor.recordSuccess(); + + return response; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + // Check if we should retry + const shouldRetry = this.shouldRetry(error, attempt, retryConfig); + + if (!shouldRetry) { + // Notify network monitor of failure (only for network errors) + if (isNetworkError(error)) { + networkMonitor.recordFailure(); + } + throw error; + } + + // Calculate delay + let delay = calculateBackoffDelay( + attempt, + retryConfig.baseDelay, + retryConfig.maxDelay + ); + + // Check for Retry-After header on 429 responses + if (error instanceof HttpError && error.status === 429) { + const retryAfter = parseRetryAfter( + (error as any).retryAfterHeader || null + ); + if (retryAfter !== null) { + delay = Math.min(retryAfter, retryConfig.maxDelay); + } else { + // When no Retry-After header, use minimum delay to avoid immediately re-triggering rate limit + delay = Math.max(delay, MIN_RATE_LIMIT_DELAY_MS); + } + } + + logger.debug( + `Request to ${url} failed (attempt ${attempt + 1}/${retryConfig.maxRetries + 1}), ` + + `retrying in ${delay}ms: ${lastError.message}` + ); + + await sleep(delay); + attempt++; + } + } + + // All retries exhausted + networkMonitor.recordFailure(); + throw lastError || new Error("Request failed after all retries"); + } + + /** + * Execute a single HTTP request + */ + private async executeRequest( + url: string, + options: HttpRequestOptions + ): Promise> { + const { method = "GET", headers = {}, body, timeout } = options; + + const requestInit: RequestInit = { + method, + headers: { + "Content-Type": "application/json", + Accept: "application/json", + ...headers, + }, + }; + + if (body) { + requestInit.body = typeof body === "string" ? body : JSON.stringify(body); + } + + // Add timeout using AbortController + let timeoutId: NodeJS.Timeout | undefined; + if (timeout) { + const controller = new AbortController(); + requestInit.signal = controller.signal; + timeoutId = setTimeout(() => controller.abort(), timeout); + } + + try { + const response = await fetch(url, requestInit); + + if (!response.ok) { + const errorBody = await response.text(); + const error = new HttpError( + `HTTP ${response.status}: ${response.statusText}`, + response.status, + response.statusText, + errorBody, + this.defaultConfig.retryableStatuses.includes(response.status) + ); + + // Attach Retry-After header for 429 responses + if (response.status === 429) { + (error as any).retryAfterHeader = response.headers.get("Retry-After"); + } + + throw error; + } + + // Handle empty responses + const contentLength = response.headers.get("content-length"); + if (response.status === 204 || contentLength === "0") { + return { + data: undefined as T, + status: response.status, + headers: response.headers, + }; + } + + const text = await response.text(); + if (!text || text.trim() === "") { + return { + data: undefined as T, + status: response.status, + headers: response.headers, + }; + } + + const data = JSON.parse(text) as T; + return { + data, + status: response.status, + headers: response.headers, + }; + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } + } + + /** + * Determine if a request should be retried + */ + private shouldRetry( + error: unknown, + attempt: number, + config: RetryConfig + ): boolean { + // Don't retry if we've exhausted attempts + if (attempt >= config.maxRetries) { + return false; + } + + // Retry on network errors if configured + if (isNetworkError(error) && config.retryOnNetworkError) { + return true; + } + + // Retry on retryable HTTP status codes + if (error instanceof HttpError) { + return config.retryableStatuses.includes(error.status); + } + + return false; + } + + /** + * Convenience method for GET requests + */ + async get( + url: string, + options: Omit = {} + ): Promise> { + return this.request(url, { ...options, method: "GET" }); + } + + /** + * Convenience method for POST requests + */ + async post( + url: string, + body: object | string, + options: Omit = {} + ): Promise> { + return this.request(url, { ...options, method: "POST", body }); + } + + /** + * Convenience method for PUT requests + */ + async put( + url: string, + body: object | string, + options: Omit = {} + ): Promise> { + return this.request(url, { ...options, method: "PUT", body }); + } + + /** + * Convenience method for PATCH requests + */ + async patch( + url: string, + body: object | string, + options: Omit = {} + ): Promise> { + return this.request(url, { ...options, method: "PATCH", body }); + } + + /** + * Convenience method for DELETE requests + */ + async delete( + url: string, + options: Omit = {} + ): Promise> { + return this.request(url, { ...options, method: "DELETE" }); + } +} + +/** + * Get the singleton HTTP client instance + */ +export function getHttpClient(config?: Partial): RetryableHttpClient { + return RetryableHttpClient.getInstance(config); +} + diff --git a/src/shared/http/TTLCache.ts b/src/shared/http/TTLCache.ts new file mode 100644 index 0000000..16c6cfc --- /dev/null +++ b/src/shared/http/TTLCache.ts @@ -0,0 +1,385 @@ +/** + * TTL Cache + * + * Simple in-memory cache with: + * - Time-to-live (TTL) per entry + * - LRU eviction when max size exceeded + * - Pattern-based invalidation + * - Cache key generation helpers + */ + +import { getLogger } from "@shared/utils/logger"; + +const logger = getLogger(); + +/** + * Configuration for the TTL cache + */ +export interface TTLCacheConfig { + /** Default TTL in milliseconds (default: 5 minutes) */ + defaultTTL: number; + /** Maximum number of entries (default: 100) */ + maxSize: number; + /** Enable debug logging (default: false) */ + debug: boolean; +} + +/** + * Cache entry with value and metadata + */ +interface CacheEntry { + value: T; + expiresAt: number; + lastAccessed: number; + key: string; +} + +/** + * Default cache configuration + */ +const DEFAULT_CONFIG: TTLCacheConfig = { + defaultTTL: 5 * 60 * 1000, // 5 minutes + maxSize: 100, + debug: false, +}; + +/** + * Common TTL presets in milliseconds + */ +export const TTL = { + /** 1 minute */ + SHORT: 1 * 60 * 1000, + /** 2 minutes */ + MEDIUM: 2 * 60 * 1000, + /** 5 minutes */ + DEFAULT: 5 * 60 * 1000, + /** 15 minutes */ + LONG: 15 * 60 * 1000, + /** 30 minutes */ + VERY_LONG: 30 * 60 * 1000, + /** No caching */ + NONE: 0, +} as const; + +/** + * TTL Cache with LRU eviction + * + * Thread-safe in-memory cache that automatically expires entries + * and evicts least-recently-used entries when capacity is reached. + */ +export class TTLCache { + private cache: Map>; + private config: TTLCacheConfig; + private cleanupInterval: NodeJS.Timeout | null = null; + + constructor(config?: Partial) { + this.config = { ...DEFAULT_CONFIG, ...config }; + this.cache = new Map(); + + // Start periodic cleanup every minute + this.cleanupInterval = setInterval(() => { + this.cleanup(); + }, 60 * 1000); + } + + /** + * Get a value from the cache + * Returns undefined if not found or expired + */ + get(key: string): T | undefined { + const entry = this.cache.get(key); + + if (!entry) { + if (this.config.debug) { + logger.debug(`[TTLCache] Miss: ${key}`); + } + return undefined; + } + + // Check if expired + if (Date.now() > entry.expiresAt) { + if (this.config.debug) { + logger.debug(`[TTLCache] Expired: ${key}`); + } + this.cache.delete(key); + return undefined; + } + + // Update last accessed time for LRU + entry.lastAccessed = Date.now(); + + if (this.config.debug) { + logger.debug(`[TTLCache] Hit: ${key}`); + } + + return entry.value; + } + + /** + * Set a value in the cache + * @param key Cache key + * @param value Value to cache + * @param ttl Optional TTL in milliseconds (uses default if not specified) + */ + set(key: string, value: T, ttl?: number): void { + const effectiveTTL = ttl ?? this.config.defaultTTL; + + // Don't cache if TTL is 0 + if (effectiveTTL <= 0) { + return; + } + + // Evict if at capacity + if (this.cache.size >= this.config.maxSize && !this.cache.has(key)) { + this.evictLRU(); + } + + const entry: CacheEntry = { + value, + expiresAt: Date.now() + effectiveTTL, + lastAccessed: Date.now(), + key, + }; + + this.cache.set(key, entry); + + if (this.config.debug) { + logger.debug(`[TTLCache] Set: ${key} (TTL: ${effectiveTTL}ms)`); + } + } + + /** + * Check if a key exists and is not expired + */ + has(key: string): boolean { + const entry = this.cache.get(key); + if (!entry) { + return false; + } + if (Date.now() > entry.expiresAt) { + this.cache.delete(key); + return false; + } + return true; + } + + /** + * Delete a specific key from the cache + */ + delete(key: string): boolean { + const deleted = this.cache.delete(key); + if (deleted && this.config.debug) { + logger.debug(`[TTLCache] Deleted: ${key}`); + } + return deleted; + } + + /** + * Invalidate all entries matching a pattern + * @param pattern String that cache keys must contain, or RegExp to match against + */ + invalidateByPattern(pattern: string | RegExp): number { + let count = 0; + const keysToDelete: string[] = []; + + for (const key of this.cache.keys()) { + const matches = + pattern instanceof RegExp + ? pattern.test(key) + : key.includes(pattern); + + if (matches) { + keysToDelete.push(key); + count++; + } + } + + for (const key of keysToDelete) { + this.cache.delete(key); + } + + if (this.config.debug && count > 0) { + logger.debug(`[TTLCache] Invalidated ${count} entries matching: ${pattern}`); + } + + return count; + } + + /** + * Clear all entries from the cache + */ + clear(): void { + const size = this.cache.size; + this.cache.clear(); + if (this.config.debug) { + logger.debug(`[TTLCache] Cleared ${size} entries`); + } + } + + /** + * Get the current number of entries in the cache + */ + get size(): number { + return this.cache.size; + } + + /** + * Get cache statistics + */ + getStats(): { + size: number; + maxSize: number; + defaultTTL: number; + } { + return { + size: this.cache.size, + maxSize: this.config.maxSize, + defaultTTL: this.config.defaultTTL, + }; + } + + /** + * Dispose of the cache and stop cleanup interval + */ + dispose(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + this.cache.clear(); + } + + /** + * Remove expired entries + */ + private cleanup(): void { + const now = Date.now(); + let expiredCount = 0; + + for (const [key, entry] of this.cache.entries()) { + if (now > entry.expiresAt) { + this.cache.delete(key); + expiredCount++; + } + } + + if (this.config.debug && expiredCount > 0) { + logger.debug(`[TTLCache] Cleanup: removed ${expiredCount} expired entries`); + } + } + + /** + * Evict the least recently used entry + */ + private evictLRU(): void { + let oldestKey: string | null = null; + let oldestTime = Infinity; + + for (const [key, entry] of this.cache.entries()) { + if (entry.lastAccessed < oldestTime) { + oldestTime = entry.lastAccessed; + oldestKey = key; + } + } + + if (oldestKey) { + this.cache.delete(oldestKey); + if (this.config.debug) { + logger.debug(`[TTLCache] Evicted LRU: ${oldestKey}`); + } + } + } +} + +/** + * Generate a cache key from multiple parts + * Useful for creating unique keys based on function name and parameters + */ +export function generateCacheKey(...parts: (string | number | boolean | null | undefined)[]): string { + return parts + .map((part) => { + if (part === null || part === undefined) { + return "_null_"; + } + return String(part); + }) + .join(":"); +} + +/** + * Generate a cache key from a GraphQL query + * Normalizes whitespace and creates a hash + */ +export function generateQueryCacheKey(query: string, variables?: object): string { + // Normalize whitespace in query + const normalizedQuery = query.replace(/\s+/g, " ").trim(); + + // Create a simple hash of the query + let hash = 0; + for (let i = 0; i < normalizedQuery.length; i++) { + const char = normalizedQuery.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32bit integer + } + + const queryKey = `query:${hash}`; + + if (variables && Object.keys(variables).length > 0) { + return `${queryKey}:${JSON.stringify(variables)}`; + } + + return queryKey; +} + +// Singleton instances for different cache purposes +let apiCache: TTLCache | null = null; +let metadataCache: TTLCache | null = null; + +/** + * Get the shared API response cache + * Used for caching ticket data, issues, etc. + */ +export function getApiCache(): TTLCache { + if (!apiCache) { + apiCache = new TTLCache({ + defaultTTL: TTL.MEDIUM, // 2 minutes for API responses + maxSize: 200, + debug: false, + }); + } + return apiCache; +} + +/** + * Get the shared metadata cache + * Used for caching workflow states, users, teams, etc. + */ +export function getMetadataCache(): TTLCache { + if (!metadataCache) { + metadataCache = new TTLCache({ + defaultTTL: TTL.LONG, // 15 minutes for metadata + maxSize: 100, + debug: false, + }); + } + return metadataCache; +} + +/** + * Clear all caches + */ +export function clearAllCaches(): void { + apiCache?.clear(); + metadataCache?.clear(); +} + +/** + * Dispose all cache instances + */ +export function disposeAllCaches(): void { + apiCache?.dispose(); + metadataCache?.dispose(); + apiCache = null; + metadataCache = null; +} + diff --git a/src/shared/http/index.ts b/src/shared/http/index.ts new file mode 100644 index 0000000..37c925b --- /dev/null +++ b/src/shared/http/index.ts @@ -0,0 +1,44 @@ +/** + * HTTP Infrastructure + * + * Shared utilities for HTTP requests with: + * - Retry logic with exponential backoff + * - TTL-based caching + * - Network status monitoring + */ + +// Retryable HTTP Client +export { + RetryableHttpClient, + getHttpClient, + isNetworkError, + getUserFriendlyError, + type RetryConfig, + type HttpRequestOptions, + type HttpResponse, + type UserFriendlyError, + HttpError, +} from "./RetryableHttpClient"; + +// TTL Cache +export { + TTLCache, + TTL, + getApiCache, + getMetadataCache, + clearAllCaches, + disposeAllCaches, + generateCacheKey, + generateQueryCacheKey, + type TTLCacheConfig, +} from "./TTLCache"; + +// Network Monitor +export { + NetworkMonitor, + getNetworkMonitor, + initializeNetworkMonitor, + type NetworkMonitorConfig, + type NetworkStatus, +} from "./NetworkMonitor"; + diff --git a/src/shared/views/UniversalTicketsProvider.ts b/src/shared/views/UniversalTicketsProvider.ts index be16c55..527a660 100644 --- a/src/shared/views/UniversalTicketsProvider.ts +++ b/src/shared/views/UniversalTicketsProvider.ts @@ -18,6 +18,7 @@ import { BranchAssociationManager } from "@shared/git/branchAssociationManager"; import { getRepositoryRegistry } from "@shared/git/repositoryRegistry"; import { fuzzySearch } from "@shared/utils/fuzzySearch"; import { debounce } from "@shared/utils/debounce"; +import { getUserFriendlyError, type UserFriendlyError } from "@shared/http"; const logger = getLogger(); @@ -349,7 +350,7 @@ export class UniversalTicketsProvider return []; } catch (error) { logger.error("Failed to load Linear tickets:", error); - return [this.createErrorItem("Failed to load Linear tickets")]; + return [this.createErrorItemFromError(error, "Linear")]; } } @@ -982,7 +983,7 @@ export class UniversalTicketsProvider return []; } catch (error) { logger.error("Failed to load Jira issues:", error); - return [this.createErrorItem("Failed to load Jira issues")]; + return [this.createErrorItemFromError(error, "Jira")]; } } @@ -1508,13 +1509,61 @@ export class UniversalTicketsProvider // ==================== UTILITY METHODS ==================== + /** + * Create an error item from a raw error with user-friendly messaging + */ + private createErrorItemFromError(error: unknown, context: string): UniversalTicketTreeItem { + const friendlyError = getUserFriendlyError(error, context); + return this.createErrorItemFromFriendlyError(friendlyError); + } + + /** + * Create an error item from a UserFriendlyError + */ + private createErrorItemFromFriendlyError(error: UserFriendlyError): UniversalTicketTreeItem { + const item = new UniversalTicketTreeItem( + error.title, + vscode.TreeItemCollapsibleState.None, + null + ); + + item.iconPath = new vscode.ThemeIcon( + error.icon, + new vscode.ThemeColor("errorForeground") + ); + + // Build tooltip with description and action hint + let tooltip = error.description; + if (error.action) { + tooltip += `\n\nšŸ’” ${error.action}`; + } + if (error.canRetry) { + tooltip += "\n\nClick the refresh button (↻) to try again."; + } + item.tooltip = tooltip; + + // Add description showing actionable hint + if (error.canRetry) { + item.description = "Click ↻ to retry"; + } else if (error.action) { + item.description = error.action; + } + + return item; + } + + /** + * Create a simple error item with custom message (legacy support) + */ private createErrorItem(message: string): UniversalTicketTreeItem { const item = new UniversalTicketTreeItem( - `āŒ ${message}`, + message, vscode.TreeItemCollapsibleState.None, null ); + item.iconPath = new vscode.ThemeIcon("error", new vscode.ThemeColor("errorForeground")); item.tooltip = "Click refresh to try again"; + item.description = "Click ↻ to retry"; return item; } }