diff --git a/contributions.json b/contributions.json index 58f7743429d4a..a9c01185f9ec6 100644 --- a/contributions.json +++ b/contributions.json @@ -4906,7 +4906,7 @@ } }, "gitlens.views.ai.generateChangelog": { - "label": "Generate Changelog", + "label": "Generate Changelog (Preview)", "icon": "$(sparkle)", "menus": { "view/item/context": [ diff --git a/package.json b/package.json index 7f6cf34fce215..029519dae8353 100644 --- a/package.json +++ b/package.json @@ -7833,7 +7833,7 @@ }, { "command": "gitlens.views.ai.generateChangelog", - "title": "Generate Changelog", + "title": "Generate Changelog (Preview)", "icon": "$(sparkle)" }, { diff --git a/src/features.ts b/src/features.ts index 70c54ee8dc9c4..c5f547b1097e0 100644 --- a/src/features.ts +++ b/src/features.ts @@ -30,7 +30,31 @@ export type RepoFeatureAccess = visibility?: RepositoryVisibility; }; -export type PlusFeatures = 'timeline' | 'worktrees' | 'graph' | 'launchpad' | 'startWork' | 'associateIssueWithBranch'; +export type PlusFeatures = ProFeatures | AdvancedFeatures; + +export type ProFeatures = + | 'timeline' + | 'worktrees' + | 'graph' + | 'launchpad' + | 'startWork' + | 'associateIssueWithBranch' + | ProAIFeatures; +export type ProAIFeatures = 'generateStashMessage' | 'explainCommit' | 'cloudPatchGenerateTitleAndDescription'; + +export type AdvancedFeatures = AdvancedAIFeatures; +export type AdvancedAIFeatures = 'generateChangelog'; + +export type AIFeatures = ProAIFeatures | AdvancedAIFeatures; + +export function isAdvancedFeature(feature: PlusFeatures): feature is AdvancedFeatures { + switch (feature) { + case 'generateChangelog': + return true; + default: + return false; + } +} export type FeaturePreviews = 'graph'; export const featurePreviews: FeaturePreviews[] = ['graph']; diff --git a/src/git/gitProviderService.ts b/src/git/gitProviderService.ts index b0c849d9261d7..084dfd38f452c 100644 --- a/src/git/gitProviderService.ts +++ b/src/git/gitProviderService.ts @@ -775,7 +775,15 @@ export class GitProviderService implements Disposable { return { allowed: subscription.account?.verified !== false, subscription: { current: subscription } }; } - if (feature === 'launchpad' || feature === 'startWork' || feature === 'associateIssueWithBranch') { + if ( + feature === 'launchpad' || + feature === 'startWork' || + feature === 'associateIssueWithBranch' || + feature === 'generateStashMessage' || + feature === 'explainCommit' || + feature === 'cloudPatchGenerateTitleAndDescription' || + feature === 'generateChangelog' + ) { return { allowed: false, subscription: { current: subscription, required: SubscriptionPlanId.Pro } }; } diff --git a/src/plus/ai/aiProviderService.ts b/src/plus/ai/aiProviderService.ts index 7e1044c13a853..f4b85b4b5fff4 100644 --- a/src/plus/ai/aiProviderService.ts +++ b/src/plus/ai/aiProviderService.ts @@ -5,6 +5,8 @@ import { primaryAIProviders } from '../../constants.ai'; import type { AIGenerateDraftEventData, Source, TelemetryEvents } from '../../constants.telemetry'; import type { Container } from '../../container'; import { CancellationError } from '../../errors'; +import type { AIFeatures } from '../../features'; +import { isAdvancedFeature } from '../../features'; import type { GitCommit } from '../../git/models/commit'; import { isCommit } from '../../git/models/commit'; import type { GitRevisionReference } from '../../git/models/reference'; @@ -13,6 +15,7 @@ import { uncommitted, uncommittedStaged } from '../../git/models/revision'; import { assertsCommitHasFullDetails } from '../../git/utils/commit.utils'; import { showAIModelPicker } from '../../quickpicks/aiModelPicker'; import { configuration } from '../../system/-webview/configuration'; +import { getContext } from '../../system/-webview/context'; import type { Storage } from '../../system/-webview/storage'; import { supportedInVSCodeVersion } from '../../system/-webview/vscode'; import { debounce } from '../../system/function/debounce'; @@ -22,6 +25,7 @@ import { lazy } from '../../system/lazy'; import type { Deferred } from '../../system/promise'; import { getSettledValue } from '../../system/promise'; import type { ServerConnection } from '../gk/serverConnection'; +import { ensureFeatureAccess } from '../gk/utils/-webview/acount.utils'; import type { AIActionType, AIModel, AIModelDescriptor } from './models/model'; import type { PromptTemplateContext } from './models/promptTemplates'; import type { AIProvider, AIRequestResult } from './models/provider'; @@ -284,11 +288,44 @@ export class AIProviderService implements Disposable { return model; } + private async ensureOrgAccess(): Promise { + const orgEnabled = getContext('gitlens:gk:organization:ai:enabled'); + if (orgEnabled === false) { + await window.showErrorMessage(`AI features have been disabled for your organization.`); + return false; + } + + return true; + } + + private async ensureFeatureAccess(feature: AIFeatures, source: Source): Promise { + if (!(await this.ensureOrgAccess())) return false; + + if ( + !(await ensureFeatureAccess( + this.container, + isAdvancedFeature(feature) + ? `Advanced AI features require a trial or GitLens Advanced.` + : `Pro AI features require a trial or GitLens Pro.`, + feature, + source, + )) + ) { + return false; + } + + return true; + } + async explainCommit( commitOrRevision: GitRevisionReference | GitCommit, sourceContext: Source & { type: TelemetryEvents['ai/explain']['changeType'] }, options?: { cancellation?: CancellationToken; progress?: ProgressOptions }, ): Promise { + if (!(await this.ensureFeatureAccess('explainCommit', sourceContext))) { + return undefined; + } + const diff = await this.container.git.diff(commitOrRevision.repoPath).getDiff?.(commitOrRevision.ref); if (!diff?.contents) throw new Error('No changes found to explain.'); @@ -339,6 +376,8 @@ export class AIProviderService implements Disposable { progress?: ProgressOptions; }, ): Promise { + if (!(await this.ensureOrgAccess())) return undefined; + const changes: string | undefined = await this.getChanges(changesOrRepo); if (changes == null) return undefined; @@ -377,6 +416,10 @@ export class AIProviderService implements Disposable { codeSuggestion?: boolean; }, ): Promise { + if (!(await this.ensureFeatureAccess('cloudPatchGenerateTitleAndDescription', sourceContext))) { + return undefined; + } + const changes: string | undefined = await this.getChanges(changesOrRepo); if (changes == null) return undefined; @@ -423,6 +466,10 @@ export class AIProviderService implements Disposable { progress?: ProgressOptions; }, ): Promise { + if (!(await this.ensureFeatureAccess('generateStashMessage', source))) { + return undefined; + } + const changes: string | undefined = await this.getChanges(changesOrRepo); if (changes == null) { options?.generating?.cancel(); @@ -458,6 +505,10 @@ export class AIProviderService implements Disposable { source: Source, options?: { cancellation?: CancellationToken; progress?: ProgressOptions }, ): Promise { + if (!(await this.ensureFeatureAccess('generateChangelog', source))) { + return undefined; + } + const result = await this.sendRequest( 'generate-changelog', async () => ({ diff --git a/src/plus/gk/subscriptionService.ts b/src/plus/gk/subscriptionService.ts index 269faf952d474..b0a87b634c9cc 100644 --- a/src/plus/gk/subscriptionService.ts +++ b/src/plus/gk/subscriptionService.ts @@ -882,10 +882,10 @@ export class SubscriptionService implements Disposable { } @log() - async upgrade(source: Source | undefined, plan?: SubscriptionPlanId): Promise { + async upgrade(source: Source | undefined, plan?: SubscriptionPlanId): Promise { const scope = getLogScope(); - if (!(await ensurePlusFeaturesEnabled())) return; + if (!(await ensurePlusFeaturesEnabled())) return false; let aborted = false; const promo = await this.container.productConfig.getApplicablePromo(this._subscription.state); @@ -919,7 +919,7 @@ export class SubscriptionService implements Disposable { getSubscriptionPlanPriority(this._subscription.plan.effective.id) >= getSubscriptionPlanPriority(plan ?? SubscriptionPlanId.Pro) ) { - return; + return true; } } } catch {} @@ -982,7 +982,7 @@ export class SubscriptionService implements Disposable { aborted = !(await openUrl(this.container.urls.getGkDevUrl('purchase/checkout', query))); if (aborted) { - return; + return false; } telemetry?.dispose(); @@ -1016,6 +1016,8 @@ export class SubscriptionService implements Disposable { if (refresh) { void this.checkUpdatedSubscription(source); } + + return true; } @gate(o => `${o?.force ?? false}`) diff --git a/src/plus/gk/utils/-webview/acount.utils.ts b/src/plus/gk/utils/-webview/acount.utils.ts index fea4c61e347d9..59432cc73353a 100644 --- a/src/plus/gk/utils/-webview/acount.utils.ts +++ b/src/plus/gk/utils/-webview/acount.utils.ts @@ -1,6 +1,8 @@ +import type { Uri } from 'vscode'; import { window } from 'vscode'; import type { Source } from '../../../../constants.telemetry'; import type { Container } from '../../../../container'; +import type { PlusFeatures } from '../../../../features'; export async function ensureAccount(container: Container, title: string, source: Source): Promise { while (true) { @@ -52,3 +54,37 @@ export async function ensureAccount(container: Container, title: string, source: return true; } + +export async function ensureFeatureAccess( + container: Container, + title: string, + feature: PlusFeatures, + source: Source, + repoPath?: string | Uri, +): Promise { + if (!(await ensureAccount(container, title, source))) return false; + + while (true) { + const access = await container.git.access(feature, repoPath); + if (access.allowed) break; + + const upgrade = { title: 'Upgrade to Pro' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showWarningMessage( + `${title}\n\nPlease upgrade to GitLens Pro to continue.`, + { modal: true }, + upgrade, + cancel, + ); + + if (result === upgrade) { + if (await container.subscription.upgrade(source)) { + continue; + } + } + + return false; + } + + return true; +}