Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/planning/ROADMAP_1.0.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
9 changes: 9 additions & 0 deletions src/activation/initialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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
Expand All @@ -16,7 +17,7 @@
const logger = getLogger();

// Store context globally so firstTimeSetup and clients can access it
(global as any).devBuddyContext = context;

Check warning on line 20 in src/activation/initialization.ts

View workflow job for this annotation

GitHub Actions / build-and-test (windows-latest, 20)

Unexpected any. Specify a different type

Check warning on line 20 in src/activation/initialization.ts

View workflow job for this annotation

GitHub Actions / build-and-test (macos-latest, 20)

Unexpected any. Specify a different type

Check warning on line 20 in src/activation/initialization.ts

View workflow job for this annotation

GitHub Actions / build-and-test (ubuntu-latest, 20)

Unexpected any. Specify a different type

// Load development credentials if in dev mode (non-blocking)
loadDevCredentials(context).then(() => {
Expand Down Expand Up @@ -52,6 +53,14 @@
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");

Expand All @@ -74,7 +83,7 @@
for (const secret of secretsToDelete) {
try {
await context.secrets.delete(secret);
} catch (error) {

Check warning on line 86 in src/activation/initialization.ts

View workflow job for this annotation

GitHub Actions / build-and-test (windows-latest, 20)

'error' is defined but never used

Check warning on line 86 in src/activation/initialization.ts

View workflow job for this annotation

GitHub Actions / build-and-test (macos-latest, 20)

'error' is defined but never used

Check warning on line 86 in src/activation/initialization.ts

View workflow job for this annotation

GitHub Actions / build-and-test (ubuntu-latest, 20)

'error' is defined but never used
// Ignore errors
}
}
Expand All @@ -91,7 +100,7 @@
for (const setting of settingsToReset) {
try {
await config.update(setting, undefined, vscode.ConfigurationTarget.Global);
} catch (error) {

Check warning on line 103 in src/activation/initialization.ts

View workflow job for this annotation

GitHub Actions / build-and-test (windows-latest, 20)

'error' is defined but never used

Check warning on line 103 in src/activation/initialization.ts

View workflow job for this annotation

GitHub Actions / build-and-test (macos-latest, 20)

'error' is defined but never used

Check warning on line 103 in src/activation/initialization.ts

View workflow job for this annotation

GitHub Actions / build-and-test (ubuntu-latest, 20)

'error' is defined but never used
// Ignore errors
}
}
Expand Down Expand Up @@ -119,7 +128,7 @@
try {
const linearToken = await LinearClient.getApiToken();
await vscode.commands.executeCommand("setContext", "devBuddy.linearConfigured", !!linearToken);
} catch (error) {

Check warning on line 131 in src/activation/initialization.ts

View workflow job for this annotation

GitHub Actions / build-and-test (windows-latest, 20)

'error' is defined but never used

Check warning on line 131 in src/activation/initialization.ts

View workflow job for this annotation

GitHub Actions / build-and-test (macos-latest, 20)

'error' is defined but never used

Check warning on line 131 in src/activation/initialization.ts

View workflow job for this annotation

GitHub Actions / build-and-test (ubuntu-latest, 20)

'error' is defined but never used
logger.debug("Could not check Linear token status");
await vscode.commands.executeCommand("setContext", "devBuddy.linearConfigured", false);
}
Expand All @@ -132,7 +141,7 @@
try {
const jiraToken = await context.secrets.get("jiraCloudApiToken");
await vscode.commands.executeCommand("setContext", "devBuddy.jiraConfigured", !!jiraToken);
} catch (error) {

Check warning on line 144 in src/activation/initialization.ts

View workflow job for this annotation

GitHub Actions / build-and-test (windows-latest, 20)

'error' is defined but never used

Check warning on line 144 in src/activation/initialization.ts

View workflow job for this annotation

GitHub Actions / build-and-test (macos-latest, 20)

'error' is defined but never used

Check warning on line 144 in src/activation/initialization.ts

View workflow job for this annotation

GitHub Actions / build-and-test (ubuntu-latest, 20)

'error' is defined but never used
logger.debug("Could not check Jira token status");
await vscode.commands.executeCommand("setContext", "devBuddy.jiraConfigured", false);
}
Expand Down
25 changes: 22 additions & 3 deletions src/activation/treeView.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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;
}
Comment on lines +30 to +37

Copilot AI Dec 21, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The magic number 500 (ms) for the debounce delay should be extracted to a named constant like TREE_VIEW_REFRESH_DEBOUNCE_MS to improve maintainability and make the delay configurable.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets abstract this magic number

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

}, 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();
}
});

Expand Down
11 changes: 11 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
}
31 changes: 27 additions & 4 deletions src/providers/jira/cloud/JiraCloudClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import * as vscode from "vscode";
import { BaseJiraClient } from "../common/BaseJiraClient";
import { TTL } from "@shared/http";
import {
JiraIssue,
JiraProject,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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<unknown>(
`/project/search?startAt=${startAt}&maxResults=${maxResults}`
`/project/search?startAt=${startAt}&maxResults=${maxResults}`,
{ ttl: TTL.LONG }
);

// Validate response with Zod
Expand Down Expand Up @@ -805,8 +823,10 @@ export class JiraCloudClient extends BaseJiraClient {
*/
async getIssueTypes(projectKeyOrId: string): Promise<JiraIssueType[]> {
try {
// Issue types rarely change, cache for 30 minutes
const response = await this.request<unknown>(
`/issuetype/project?projectId=${projectKeyOrId}`
`/issuetype/project?projectId=${projectKeyOrId}`,
{ ttl: TTL.VERY_LONG }
);

// Validate response with Zod
Expand Down Expand Up @@ -834,7 +854,8 @@ export class JiraCloudClient extends BaseJiraClient {
*/
async getPriorities(): Promise<JiraPriority[]> {
try {
const response = await this.request<unknown>("/priority");
// Priorities rarely change, cache for 30 minutes
const response = await this.request<unknown>("/priority", { ttl: TTL.VERY_LONG });

// Validate response with Zod
const validated = JiraPrioritiesResponseSchema.parse(response);
Expand All @@ -859,8 +880,10 @@ export class JiraCloudClient extends BaseJiraClient {
*/
async getStatuses(projectKey: string): Promise<JiraStatus[]> {
try {
// Statuses rarely change, cache for 15 minutes
const response = await this.request<unknown>(
`/project/${projectKey}/statuses`
`/project/${projectKey}/statuses`,
{ ttl: TTL.LONG }
);

// Validate response with Zod
Expand Down
142 changes: 115 additions & 27 deletions src/providers/jira/common/BaseJiraClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
/** 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)
*/
Expand All @@ -36,44 +71,97 @@ export abstract class BaseJiraClient {
protected abstract getAuthHeaders(): Record<string, string>;

/**
* Make authenticated HTTP request to Jira API
* Make authenticated HTTP request to Jira API with retry and caching support
*/
protected async request<T>(
endpoint: string,
options: RequestInit = {}
options: JiraRequestOptions = {}
): Promise<T> {
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<T>(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 ====================
Expand Down
Loading
Loading