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
26 changes: 25 additions & 1 deletion docs/telemetry-events.md

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions src/commands/resets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ const resetTypes = [
'ai',
'ai:confirmations',
'avatars',
'homeSections',
'integrations',
'previews',
'promoOptIns',
'repositoryAccess',
'subscription',
'suppressedWarnings',
Expand Down Expand Up @@ -46,6 +48,11 @@ export class ResetCommand extends GlCommandBase {
detail: 'Clears the stored avatar cache',
item: 'avatars',
},
{
label: 'Home Sections...',
detail: 'Clears dismissed home view banners and sections',
item: 'homeSections',
},
{
label: 'Integrations (Authentication)...',
detail: 'Clears any locally stored authentication for integrations',
Expand Down Expand Up @@ -93,6 +100,11 @@ export class ResetCommand extends GlCommandBase {
detail: 'Resets the stored state for feature previews',
item: 'previews',
},
{
label: 'Promo Opt-Ins...',
detail: 'Clears any locally stored promo opt-ins',
item: 'promoOptIns'
},
);
}

Expand Down Expand Up @@ -133,6 +145,10 @@ export class ResetCommand extends GlCommandBase {
confirmationMessage = 'Are you sure you want to reset the stored state for feature previews?';
confirm.title = 'Reset Feature Previews';
break;
case 'promoOptIns':
confirmationMessage = 'Are you sure you want to reset all of the locally stored promo opt-ins?';
confirm.title = 'Reset Promo Opt-Ins';
break;
case 'repositoryAccess':
confirmationMessage = 'Are you sure you want to reset the repository access cache?';
confirm.title = 'Reset Repository Access';
Expand Down Expand Up @@ -190,10 +206,19 @@ export class ResetCommand extends GlCommandBase {
resetAvatarCache('all');
break;

case 'homeSections':
await this.container.storage.delete('home:sections:collapsed');
await this.container.storage.delete('home:walkthrough:dismissed');
break;

case 'integrations':
await this.container.integrations.reset();
break;

case 'promoOptIns':
await this.container.storage.deleteWithPrefix('gk:promo');
break;

case 'repositoryAccess':
await this.container.git.clearAllRepoVisibilityCaches();
break;
Expand Down
2 changes: 2 additions & 0 deletions src/constants.commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,15 @@ type InternalHomeWebviewCommands =

type InternalHomeWebviewViewCommands =
| 'gitlens.views.home.account.resync'
| 'gitlens.views.home.ai.allAccess.dismiss'
| 'gitlens.views.home.publishBranch'
| 'gitlens.views.home.pull'
| 'gitlens.views.home.push';

type InternalLaunchPadCommands = 'gitlens.launchpad.indicator.action';

type InternalPlusCommands =
| 'gitlens.plus.aiAllAccess.optIn'
| 'gitlens.plus.continueFeaturePreview'
| 'gitlens.plus.resendVerification'
| 'gitlens.plus.showPlans'
Expand Down
4 changes: 4 additions & 0 deletions src/constants.storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ export type GlobalStorage = {
>;
} & {
[key in `provider:authentication:skip:${string}`]: boolean;
} & {
[key in `gk:promo:${string}:ai:allAccess:dismissed`]: boolean;
} & {
[key in `gk:promo:${string}:ai:allAccess:notified`]: boolean;
} & { [key in `gk:${string}:checkin`]: Stored<StoredGKCheckInResponse> } & {
[key in `gk:${string}:organizations`]: Stored<StoredOrganization[]>;
} & { [key in `jira:${string}:organizations`]: Stored<StoredJiraOrganization[] | undefined> } & {
Expand Down
9 changes: 9 additions & 0 deletions src/constants.telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ export interface TelemetryEvents extends WebviewShowAbortedEvents, WebviewShownE
/** Sent when switching ai models */
'ai/switchModel': AISwitchModelEvent;

/** Sent when user dismisses the AI All Access banner */
'aiAllAccess/bannerDismissed': void;

/** Sent when user opens the AI All Access page */
'aiAllAccess/opened': void;

/** Sent when user opts in to AI All Access */
'aiAllAccess/optedIn': void;

/** Sent when connecting to one or more cloud-based integrations */
'cloudIntegrations/connecting': CloudIntegrationsConnectingEvent;

Expand Down
72 changes: 72 additions & 0 deletions src/plus/ai/aiProviderService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ import { getSettledValue, getSettledValues } from '../../system/promise';
import { PromiseCache } from '../../system/promiseCache';
import type { ServerConnection } from '../gk/serverConnection';
import { ensureFeatureAccess } from '../gk/utils/-webview/acount.utils';
import { isAiAllAccessPromotionActive } from '../gk/utils/-webview/promo.utils';
import { compareSubscriptionPlans, getSubscriptionPlanName, isSubscriptionPaid } from '../gk/utils/subscription.utils';
import { GitKrakenProvider } from './gitkrakenProvider';
import type {
AIActionType,
AIModel,
Expand Down Expand Up @@ -1149,6 +1151,11 @@ export class AIProviderService implements Disposable {
}
const cancellation = cancellationSource.token;

const isGkModel = model.provider.id === 'gitkraken';
if (isGkModel) {
await this.showAiAllAccessNotificationIfNeeded(true);
}

const confirmed = await showConfirmAIProviderToS(this.container.storage);
if (!confirmed || cancellation.isCancellationRequested) {
this.container.telemetry.sendEvent(
Expand Down Expand Up @@ -1226,6 +1233,10 @@ export class AIProviderService implements Disposable {
source,
);

if (!isGkModel) {
void this.showAiAllAccessNotificationIfNeeded();
}

return result;
} catch (ex) {
if (ex instanceof CancellationError) {
Expand Down Expand Up @@ -1584,8 +1595,69 @@ export class AIProviderService implements Disposable {
switchModel(source?: Source): Promise<AIModel | undefined> {
return this.getModel({ force: true }, source);
}

private async showAiAllAccessNotificationIfNeeded(usingGkProvider?: boolean): Promise<void> {
// Only show during the AI All Access promotion period
if (!isAiAllAccessPromotionActive()) return;

// Get current subscription to determine user ID
const subscription = await this.container.subscription.getSubscription(true);
const userId = subscription?.account?.id ?? '00000000';

// Check if notification has already been shown or if user already completed opt-in
const notificationShown = this.container.storage.get(`gk:promo:${userId}:ai:allAccess:notified`, false);
const alreadyCompleted = this.container.storage.get(`gk:promo:${userId}:ai:allAccess:dismissed`, false);
if (notificationShown || alreadyCompleted) return;

const hasAdvancedOrHigher = subscription.plan &&
(compareSubscriptionPlans(subscription.plan.actual.id, 'advanced') >= 0 ||
compareSubscriptionPlans(subscription.plan.effective.id, 'advanced') >= 0);

let body = 'All Access Week - now until July 11th!';
const detail = hasAdvancedOrHigher ? 'Opt in now to get unlimited GitKraken AI until July 11th!' : 'Opt in now to try all Advanced GitLens features with unlimited GitKraken AI for FREE until July 11th!';

if (!usingGkProvider) {
body += ` ${detail}`;
}

const optInButton: MessageItem = usingGkProvider ? { title: 'Opt in for Unlimited AI' } : { title: 'Opt in and Switch to GitKraken AI' };
const dismissButton: MessageItem = { title: 'No, Thanks', isCloseAffordance: true };

// Show the notification
const result = await window.showInformationMessage(body, { modal: usingGkProvider, detail: detail }, optInButton, dismissButton);

// Mark notification as shown regardless of user action
void this.container.storage.store(`gk:promo:${userId}:ai:allAccess:notified`, true);

// If user clicked the button, trigger the opt-in command
if (result === optInButton) {
void this.allAccessOptIn(usingGkProvider);
}
}

private async allAccessOptIn(usingGkProvider?: boolean): Promise<void> {
const optIn = await this.container.subscription.aiAllAccessOptIn({ source: 'notification' });
if (optIn && !usingGkProvider && isProviderEnabledByOrg('gitkraken')) {
const gkProvider = new GitKrakenProvider(this.container, this.connection);
const defaultModel = (await gkProvider.getModels()).find(m => m.default);
if (defaultModel != null) {
this._provider = gkProvider;
this._model = defaultModel;
if (isPrimaryAIProviderModel(defaultModel)) {
await configuration.updateEffective('ai.model', 'gitkraken');
await configuration.updateEffective(`ai.gitkraken.model`, defaultModel.id);
} else {
await configuration.updateEffective('ai.model', `gitkraken:${defaultModel.id}` as SupportedAIModels);
}

this._onDidChangeModel.fire({ model: defaultModel });
}
}
}
}



async function showConfirmAIProviderToS(storage: Storage): Promise<boolean> {
const confirmed = storage.get(`confirm:ai:tos`, false) || storage.getWorkspace(`confirm:ai:tos`, false);
if (confirmed) return true;
Expand Down
85 changes: 85 additions & 0 deletions src/plus/gk/subscriptionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import { ensurePlusFeaturesEnabled } from './utils/-webview/plus.utils';
import { getConfiguredActiveOrganizationId, updateActiveOrganizationId } from './utils/-webview/subscription.utils';
import { getSubscriptionFromCheckIn } from './utils/checkin.utils';
import {
AiAllAccessOptInPathPrefix,
assertSubscriptionState,
compareSubscriptionPlans,
computeSubscriptionState,
Expand Down Expand Up @@ -140,6 +141,7 @@ export class SubscriptionService implements Disposable {
}
}),
container.uri.onDidReceiveSubscriptionUpdatedUri(() => this.checkUpdatedSubscription(undefined), this),
container.uri.onDidReceiveAiAllAccessOptInUri(this.onAiAllAccessOptInUri, this),
container.uri.onDidReceiveLoginUri(this.onLoginUri, this),
);

Expand Down Expand Up @@ -348,6 +350,7 @@ export class SubscriptionService implements Disposable {
registerCommand('gitlens.plus.upgrade', (args?: SubscriptionUpgradeCommandArgs) =>
this.upgrade(args?.plan, args ? { source: args.source, detail: args.detail } : undefined),
),
registerCommand('gitlens.plus.aiAllAccess.optIn', (src?: Source) => this.aiAllAccessOptIn(src)),

registerCommand('gitlens.plus.hide', (src?: Source) => this.setProFeaturesVisibility(false, src)),
registerCommand('gitlens.plus.restore', (src?: Source) => this.setProFeaturesVisibility(true, src)),
Expand Down Expand Up @@ -1663,6 +1666,88 @@ export class SubscriptionService implements Disposable {

return this._subscription.state;
}

@log()
async aiAllAccessOptIn(source: Source | undefined): Promise<boolean> {
const scope = getLogScope();

if (!(await ensurePlusFeaturesEnabled())) return false;

const hasAccount = this._session != null;

const query = new URLSearchParams();
query.set('source', 'gitlens');
query.set('product', 'gitlens');

try {
if (hasAccount) {
try {
const token = await this.container.accountAuthentication.getExchangeToken(
AiAllAccessOptInPathPrefix,
);
query.set('token', token);
} catch (ex) {
Logger.error(ex, scope);
}
} else {
const callbackUri = await env.asExternalUri(
Uri.parse(`${env.uriScheme}://${this.container.context.extension.id}/${AiAllAccessOptInPathPrefix}`),
);
query.set('redirect_uri', callbackUri.toString(true));
}

if (this.container.telemetry.enabled) {
this.container.telemetry.sendEvent('aiAllAccess/opened', undefined, source);
}

if(!(await openUrl(this.container.urls.getGkDevUrl('all-access', query)))) {
return false;
}
} catch (ex) {
Logger.error(ex, scope);
return false;
}

const completionPromises = [
new Promise<string>(resolve => setTimeout(() => resolve('cancel'), 5 * 60 * 1000)),
new Promise<string>(resolve => once(this.container.uri.onDidReceiveAiAllAccessOptInUri)(() => resolve(hasAccount ? 'update' : 'login'))),
];

const action = await Promise.race(completionPromises);

if (action === 'update' && hasAccount) {
void this.checkUpdatedSubscription(source);
void this.container.storage.store(`gk:promo:${this._session?.account.id ?? '00000000'}:ai:allAccess:dismissed`, true).catch();
void this.container.views.home.refresh();
}

if (action !== 'cancel') {
if (this.container.telemetry.enabled) {
this.container.telemetry.sendEvent('aiAllAccess/optedIn', undefined, source);
}

return true;
}

return false;
}

private async onAiAllAccessOptInUri(uri: Uri): Promise<void> {
const queryParams = new URLSearchParams(uri.query);
const code = queryParams.get('code');

if (code == null) return;

// If we don't have an account and received a code, login with the code
if (this._session == null) {
await this.loginWithCode({ code: code }, { source: 'subscription' });
const newSession = await this.getAuthenticationSession();
if (newSession?.account?.id != null) {
await this.container.storage.store(`gk:promo:${newSession.account.id}:ai:allAccess:dismissed`, true).catch();
void this.container.views.home.refresh();
}
}
}
}

function flattenFeaturePreview(preview: FeaturePreview): FeaturePreviewEventData {
Expand Down
8 changes: 8 additions & 0 deletions src/plus/gk/utils/-webview/promo.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function isAiAllAccessPromotionActive(): boolean {
// AI All Access promotion runs from July 8th through July 12th, 2025
const now = Date.now();
const startDate = new Date('2025-07-07T23:59:59-00:00').getTime(); // July 8th, 2025 UTC
const endDate = new Date('2025-07-12T10:00:00-00:00').getTime(); // July 12th, 2025 UTC

return now >= startDate && now <= endDate;
}
1 change: 1 addition & 0 deletions src/plus/gk/utils/subscription.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const orderedPlans: SubscriptionPlanIds[] = [
];
const orderedPaidPlans: PaidSubscriptionPlanIds[] = ['pro', 'advanced', 'teams', 'enterprise'];
export const SubscriptionUpdatedUriPathPrefix = 'did-update-subscription';
export const AiAllAccessOptInPathPrefix = 'ai-all-access-opt-in';

export function compareSubscriptionPlans(
planA: SubscriptionPlanIds | undefined,
Expand Down
11 changes: 10 additions & 1 deletion src/uris/uriService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Event, Uri, UriHandler } from 'vscode';
import { Disposable, EventEmitter, window } from 'vscode';
import type { Container } from '../container';
import { AuthenticationUriPathPrefix, LoginUriPathPrefix } from '../plus/gk/authenticationConnection';
import { SubscriptionUpdatedUriPathPrefix } from '../plus/gk/utils/subscription.utils';
import { AiAllAccessOptInPathPrefix, SubscriptionUpdatedUriPathPrefix } from '../plus/gk/utils/subscription.utils';
import { CloudIntegrationAuthenticationUriPathPrefix } from '../plus/integrations/authentication/models';
import { log } from '../system/decorators/log';

Expand Down Expand Up @@ -30,6 +30,11 @@ export class UriService implements Disposable, UriHandler {
return this._onDidReceiveSubscriptionUpdatedUri.event;
}

private _onDidReceiveAiAllAccessOptInUri: EventEmitter<Uri> = new EventEmitter<Uri>();
get onDidReceiveAiAllAccessOptInUri(): Event<Uri> {
return this._onDidReceiveAiAllAccessOptInUri.event;
}

private _onDidReceiveUri: EventEmitter<Uri> = new EventEmitter<Uri>();
get onDidReceiveUri(): Event<Uri> {
return this._onDidReceiveUri.event;
Expand All @@ -43,6 +48,7 @@ export class UriService implements Disposable, UriHandler {
this._onDidReceiveCloudIntegrationAuthenticationUri,
this._onDidReceiveLoginUri,
this._onDidReceiveSubscriptionUpdatedUri,
this._onDidReceiveAiAllAccessOptInUri,
this._onDidReceiveUri,
window.registerUriHandler(this),
);
Expand All @@ -64,6 +70,9 @@ export class UriService implements Disposable, UriHandler {
} else if (type === SubscriptionUpdatedUriPathPrefix) {
this._onDidReceiveSubscriptionUpdatedUri.fire(uri);
return;
} else if (type === AiAllAccessOptInPathPrefix) {
this._onDidReceiveAiAllAccessOptInUri.fire(uri);
return;
} else if (type === LoginUriPathPrefix) {
this._onDidReceiveLoginUri.fire(uri);
return;
Expand Down
Loading