// import type { EnrichedAutolink } from './annotations/autolinks'; import type { Disposable } from './api/gitlens'; import type { Container } from './container'; import type { DefaultBranch } from './git/models/defaultBranch'; import type { IssueOrPullRequest } from './git/models/issue'; import type { PullRequest } from './git/models/pullRequest'; import type { GitRemote } from './git/models/remote'; import type { RepositoryMetadata } from './git/models/repositoryMetadata'; import type { RemoteProvider } from './git/remotes/remoteProvider'; import type { RepositoryDescriptor, RichRemoteProvider } from './git/remotes/richRemoteProvider'; import { isPromise } from './system/promise'; type Caches = { defaultBranch: { key: `repo:${string}`; value: DefaultBranch }; // enrichedAutolinksBySha: { key: `sha:${string}:${string}`; value: Map }; issuesOrPrsById: { key: `id:${string}:${string}`; value: IssueOrPullRequest }; issuesOrPrsByIdAndRepo: { key: `id:${string}:${string}:${string}`; value: IssueOrPullRequest }; prByBranch: { key: `branch:${string}:${string}`; value: PullRequest }; prsBySha: { key: `sha:${string}:${string}`; value: PullRequest }; repoMetadata: { key: `repo:${string}`; value: RepositoryMetadata }; }; type Cache = keyof Caches; type CacheKey = Caches[T]['key']; type CacheValue = Caches[T]['value']; type CacheResult = Promise | T | undefined; type Cacheable = () => { value: CacheResult; expiresAt?: number }; type Cached = | { value: T | undefined; expiresAt?: number; etag?: string; } | { value: Promise; expiresAt?: never; // Don't set an expiration on promises as they will resolve to a value with the desired expiration etag?: string; }; export class CacheProvider implements Disposable { private readonly _cache = new Map<`${Cache}:${CacheKey}`, Cached>>>(); // eslint-disable-next-line @typescript-eslint/no-useless-constructor constructor(_container: Container) {} dispose() { this._cache.clear(); } delete(cache: T, key: CacheKey) { this._cache.delete(`${cache}:${key}`); } get( cache: T, key: CacheKey, etag: string | undefined, cacheable: Cacheable>, ): CacheResult> { const item = this._cache.get(`${cache}:${key}`); if ( item == null || (item.expiresAt != null && item.expiresAt > 0 && item.expiresAt < Date.now()) || (item.etag != null && item.etag !== etag) ) { const { value, expiresAt } = cacheable(); return this.set(cache, key, value, etag, expiresAt)?.value as CacheResult>; } return item.value as CacheResult>; } getIssueOrPullRequest( id: string, repo: RepositoryDescriptor | undefined, remoteOrProvider: RichRemoteProvider | GitRemote, cacheable: Cacheable, ): CacheResult { const { key, etag } = getRemoteKeyAndEtag(remoteOrProvider); if (repo == null) { return this.get('issuesOrPrsById', `id:${id}:${key}`, etag, cacheable); } return this.get('issuesOrPrsByIdAndRepo', `id:${id}:${key}:${JSON.stringify(repo)}}`, etag, cacheable); } // getEnrichedAutolinks( // sha: string, // remoteOrProvider: RichRemoteProvider | GitRemote, // cacheable: Cacheable>, // ): CacheResult> { // const { key, etag } = getRemoteKeyAndEtag(remoteOrProvider); // return this.get('enrichedAutolinksBySha', `sha:${sha}:${key}`, etag, cacheable); // } getPullRequestForBranch( branch: string, remoteOrProvider: RichRemoteProvider | GitRemote, cacheable: Cacheable, ): CacheResult { const cache = 'prByBranch'; const { key, etag } = getRemoteKeyAndEtag(remoteOrProvider); // Wrap the cacheable so we can also add the result to the issuesOrPrsById cache return this.get(cache, `branch:${branch}:${key}`, etag, this.wrapPullRequestCacheable(cacheable, key, etag)); } getPullRequestForSha( sha: string, remoteOrProvider: RichRemoteProvider | GitRemote, cacheable: Cacheable, ): CacheResult { const cache = 'prsBySha'; const { key, etag } = getRemoteKeyAndEtag(remoteOrProvider); // Wrap the cacheable so we can also add the result to the issuesOrPrsById cache return this.get(cache, `sha:${sha}:${key}`, etag, this.wrapPullRequestCacheable(cacheable, key, etag)); } getRepositoryDefaultBranch( remoteOrProvider: RichRemoteProvider | GitRemote, cacheable: Cacheable, ): CacheResult { const { key, etag } = getRemoteKeyAndEtag(remoteOrProvider); return this.get('defaultBranch', `repo:${key}`, etag, cacheable); } getRepositoryMetadata( remoteOrProvider: RichRemoteProvider | GitRemote, cacheable: Cacheable, ): CacheResult { const { key, etag } = getRemoteKeyAndEtag(remoteOrProvider); return this.get('repoMetadata', `repo:${key}`, etag, cacheable); } private set( cache: T, key: CacheKey, value: CacheResult>, etag: string | undefined, expiresAt?: number, ): Cached>> { let item: Cached>>; if (isPromise(value)) { void value.then( v => { this.set(cache, key, v, etag, expiresAt); }, () => { this.delete(cache, key); }, ); item = { value: value, etag: etag }; } else { item = { value: value, etag: etag, expiresAt: expiresAt ?? getExpiresAt(cache, value) }; } this._cache.set(`${cache}:${key}`, item); return item; } private wrapPullRequestCacheable( cacheable: Cacheable, key: string, etag: string | undefined, ): Cacheable { return () => { const item = cacheable(); if (isPromise(item.value)) { void item.value.then(v => { if (v != null) { this.set('issuesOrPrsById', `id:${v.id}:${key}`, v, etag); } }); } return item; }; } } function getExpiresAt(cache: T, value: CacheValue | undefined): number { const now = Date.now(); const defaultExpiresAt = now + 60 * 60 * 1000; // 1 hour switch (cache) { case 'defaultBranch': case 'repoMetadata': return 0; // Never expires case 'issuesOrPrsById': case 'issuesOrPrsByIdAndRepo': { if (value == null) return 0; // Never expires // Open issues expire after 1 hour, but closed issues expire after 12 hours unless recently updated and then expire in 1 hour const issueOrPr = value as CacheValue<'issuesOrPrsById'>; if (!issueOrPr.closed) return defaultExpiresAt; const updatedAgo = now - (issueOrPr.closedDate ?? issueOrPr.date).getTime(); return now + (updatedAgo > 14 * 24 * 60 * 60 * 1000 ? 12 : 1) * 60 * 60 * 1000; } case 'prByBranch': case 'prsBySha': { if (value == null) return cache === 'prByBranch' ? defaultExpiresAt : 0 /* Never expires */; // Open prs expire after 1 hour, but closed/merge prs expire after 12 hours unless recently updated and then expire in 1 hour const pr = value as CacheValue<'prsBySha'>; if (pr.state === 'opened') return defaultExpiresAt; const updatedAgo = now - (pr.closedDate ?? pr.mergedDate ?? pr.date).getTime(); return now + (updatedAgo > 14 * 24 * 60 * 60 * 1000 ? 12 : 1) * 60 * 60 * 1000; } // case 'enrichedAutolinksBySha': default: return value == null ? 0 /* Never expires */ : defaultExpiresAt; } } function getRemoteKeyAndEtag(remoteOrProvider: RemoteProvider | GitRemote) { return { key: remoteOrProvider.remoteKey, etag: remoteOrProvider.hasRichIntegration() ? `${remoteOrProvider.remoteKey}:${remoteOrProvider.maybeConnected ?? false}` : remoteOrProvider.remoteKey, }; }