diff --git a/contributions.json b/contributions.json index 502a94d89ff1c..10417af45f331 100644 --- a/contributions.json +++ b/contributions.json @@ -23,6 +23,10 @@ ] } }, + "gitlens.associateIssueWithBranch": { + "label": "Associate Issue with Branch...", + "commandPalette": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, "gitlens.annotations.nextChange": { "label": "Next Change", "icon": "$(arrow-down)" @@ -1453,6 +1457,19 @@ ] } }, + "gitlens.graph.associateIssueWithBranch": { + "label": "Associate Issue with Branch...", + "enablement": "!operationInProgress", + "menus": { + "webview/context": [ + { + "when": "webviewItem =~ /gitlens:branch\\b(?!.*?\\b\\+remote\\b)/ && !gitlens:hasVirtualFolders && !gitlens:readonly && !gitlens:untrusted", + "group": "1_gitlens_actions", + "order": 8 + } + ] + } + }, "gitlens.graph.cherryPick": { "label": "Cherry Pick Commit...", "enablement": "!operationInProgress", @@ -4739,6 +4756,19 @@ ] } }, + "gitlens.views.associateIssueWithBranch": { + "label": "Associate Issue with Branch...", + "enablement": "!operationInProgress", + "menus": { + "view/item/context": [ + { + "when": "viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+remote\\b)/ && !listMultiSelection && !gitlens:hasVirtualFolders && !gitlens:readonly && !gitlens:untrusted", + "group": "1_gitlens_actions", + "order": 8 + } + ] + } + }, "gitlens.views.addRemote": { "label": "Add Remote...", "icon": "$(add)", diff --git a/docs/telemetry-events.md b/docs/telemetry-events.md index 6e8b3dd5699d4..d4ae876d3aa42 100644 --- a/docs/telemetry-events.md +++ b/docs/telemetry-events.md @@ -160,6 +160,105 @@ or } ``` +### associateIssueWithBranch/action + +> Sent when the user chooses to manage integrations + +```typescript +{ + 'instance': number, + 'action': 'manage' | 'connect', + 'connected': boolean, + 'items.count': number +} +``` + +### associateIssueWithBranch/issue/action + +> Sent when the user takes an action on an issue + +```typescript +{ + 'instance': number, + 'action': 'soft-open', + 'connected': boolean, + [`item.${string}`]: string | number | boolean, + 'items.count': number +} +``` + +### associateIssueWithBranch/issue/chosen + +> Sent when the user chooses an issue to associate with the branch in the second step + +```typescript +{ + 'instance': number, + 'connected': boolean, + [`item.${string}`]: string | number | boolean, + 'items.count': number +} +``` + +### associateIssueWithBranch/open + +> Sent when the user opens Start Work; use `instance` to correlate an Associate Issue with Branch "session" + +```typescript +{ + 'instance': number +} +``` + +### associateIssueWithBranch/opened + +> Sent when the launchpad is opened; use `instance` to correlate an Associate Issue with Branch "session" + +```typescript +{ + 'instance': number, + 'connected': boolean, + 'items.count': number +} +``` + +### associateIssueWithBranch/steps/connect + +> Sent when the user reaches the "connect an integration" step of Associate Issue with Branch + +```typescript +{ + 'instance': number, + 'connected': boolean, + 'items.count': number +} +``` + +### associateIssueWithBranch/steps/issue + +> Sent when the user reaches the "choose an issue" step of Associate Issue with Branch + +```typescript +{ + 'instance': number, + 'connected': boolean, + 'items.count': number +} +``` + +### associateIssueWithBranch/title/action + +> Sent when the user chooses to connect an integration + +```typescript +{ + 'instance': number, + 'action': 'connect', + 'connected': boolean, + 'items.count': number +} +``` + ### cloudIntegrations/connected > Sent when connected to one or more cloud-based integrations from gkdev @@ -960,7 +1059,7 @@ void { 'instance': number, 'items.error': string, - 'action': 'open' | 'code-suggest' | 'merge' | 'soft-open' | 'switch' | 'open-worktree' | 'switch-and-code-suggest' | 'show-overview' | 'open-changes' | 'open-in-graph' | 'pin' | 'unpin' | 'snooze' | 'unsnooze' | 'open-suggestion' | 'open-suggestion-browser', + 'action': 'soft-open' | 'open' | 'code-suggest' | 'merge' | 'switch' | 'open-worktree' | 'switch-and-code-suggest' | 'show-overview' | 'open-changes' | 'open-in-graph' | 'pin' | 'unpin' | 'snooze' | 'unsnooze' | 'open-suggestion' | 'open-suggestion-browser', 'groups.blocked.collapsed': boolean, 'groups.blocked.count': number, 'groups.count': number, @@ -1257,7 +1356,7 @@ void { 'instance': number, 'items.error': string, - 'action': 'settings' | 'feedback' | 'open-on-gkdev' | 'refresh' | 'connect', + 'action': 'settings' | 'connect' | 'feedback' | 'open-on-gkdev' | 'refresh', 'groups.blocked.collapsed': boolean, 'groups.blocked.count': number, 'groups.count': number, @@ -1298,7 +1397,7 @@ void 'provider': string, 'repoPrivacy': 'private' | 'public' | 'local', 'repository.visibility': 'private' | 'public' | 'local', - 'source': 'account' | 'subscription' | 'graph' | 'patchDetails' | 'settings' | 'timeline' | 'home' | 'code-suggest' | 'cloud-patches' | 'commandPalette' | 'deeplink' | 'inspect' | 'inspect-overview' | 'integrations' | 'launchpad' | 'launchpad-indicator' | 'launchpad-view' | 'notification' | 'prompt' | 'quick-wizard' | 'remoteProvider' | 'startWork' | 'trial-indicator' | 'scm-input' | 'walkthrough' | 'whatsnew' | 'worktrees' + 'source': 'account' | 'subscription' | 'graph' | 'patchDetails' | 'settings' | 'timeline' | 'home' | 'view' | 'code-suggest' | 'associateIssueWithBranch' | 'cloud-patches' | 'commandPalette' | 'deeplink' | 'inspect' | 'inspect-overview' | 'integrations' | 'launchpad' | 'launchpad-indicator' | 'launchpad-view' | 'notification' | 'prompt' | 'quick-wizard' | 'remoteProvider' | 'startWork' | 'trial-indicator' | 'scm-input' | 'walkthrough' | 'whatsnew' | 'worktrees' } ``` @@ -1468,7 +1567,7 @@ void ```typescript { 'instance': number, - 'action': 'connect' | 'manage', + 'action': 'manage' | 'connect', 'connected': boolean, 'items.count': number } diff --git a/package.json b/package.json index aa575cf168b57..7ccda34b2f6ae 100644 --- a/package.json +++ b/package.json @@ -5746,6 +5746,11 @@ "category": "GitLens", "icon": "$(person-add)" }, + { + "command": "gitlens.associateIssueWithBranch", + "title": "Associate Issue with Branch...", + "category": "GitLens" + }, { "command": "gitlens.annotations.nextChange", "title": "Next Change", @@ -6302,6 +6307,11 @@ "title": "Add as Co-author", "icon": "$(person-add)" }, + { + "command": "gitlens.graph.associateIssueWithBranch", + "title": "Associate Issue with Branch...", + "enablement": "!operationInProgress" + }, { "command": "gitlens.graph.cherryPick", "title": "Cherry Pick Commit...", @@ -7553,6 +7563,11 @@ "title": "Add Co-authors...", "icon": "$(person-add)" }, + { + "command": "gitlens.views.associateIssueWithBranch", + "title": "Associate Issue with Branch...", + "enablement": "!operationInProgress" + }, { "command": "gitlens.views.addRemote", "title": "Add Remote...", @@ -9850,6 +9865,10 @@ "command": "gitlens.applyPatchFromClipboard", "when": "gitlens:enabled && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, + { + "command": "gitlens.associateIssueWithBranch", + "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, { "command": "gitlens.browseRepoAtRevision", "when": "!gitlens:hasVirtualFolders && gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/" @@ -10250,6 +10269,10 @@ "command": "gitlens.graph.addAuthor", "when": "false" }, + { + "command": "gitlens.graph.associateIssueWithBranch", + "when": "false" + }, { "command": "gitlens.graph.cherryPick", "when": "false" @@ -11206,6 +11229,10 @@ "command": "gitlens.views.applyChanges", "when": "false" }, + { + "command": "gitlens.views.associateIssueWithBranch", + "when": "false" + }, { "command": "gitlens.views.branches.copy", "when": "false" @@ -15205,6 +15232,11 @@ "when": "viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+(current|checkedout)\\b)(?!.*?\\b\\+closed\\b)/ && listMultiSelection && !gitlens:hasVirtualFolders && !gitlens:readonly && !gitlens:untrusted", "group": "1_gitlens_actions@7" }, + { + "command": "gitlens.views.associateIssueWithBranch", + "when": "viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+remote\\b)/ && !listMultiSelection && !gitlens:hasVirtualFolders && !gitlens:readonly && !gitlens:untrusted", + "group": "1_gitlens_actions@8" + }, { "command": "gitlens.views.createBranch", "when": "viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+closed\\b)/ && !listMultiSelection && !gitlens:hasVirtualFolders && !gitlens:readonly && !gitlens:untrusted", @@ -18091,6 +18123,11 @@ "when": "webviewItem =~ /gitlens:branch\\b(?!.*?\\b\\+(current|checkedout)\\b)/ && !gitlens:hasVirtualFolders && !gitlens:readonly && !gitlens:untrusted", "group": "1_gitlens_actions@7" }, + { + "command": "gitlens.graph.associateIssueWithBranch", + "when": "webviewItem =~ /gitlens:branch\\b(?!.*?\\b\\+remote\\b)/ && !gitlens:hasVirtualFolders && !gitlens:readonly && !gitlens:untrusted", + "group": "1_gitlens_actions@8" + }, { "command": "gitlens.graph.createBranch", "when": "webviewItem =~ /gitlens:branch\\b/ && !gitlens:hasVirtualFolders && !gitlens:readonly && !gitlens:untrusted", diff --git a/src/commands/quickWizard.ts b/src/commands/quickWizard.ts index faeb454bb6914..2856a9c982f20 100644 --- a/src/commands/quickWizard.ts +++ b/src/commands/quickWizard.ts @@ -1,18 +1,18 @@ import { GlCommand } from '../constants.commands'; import type { Container } from '../container'; import type { LaunchpadCommandArgs } from '../plus/launchpad/launchpad'; -import type { StartWorkCommandArgs } from '../plus/startWork/startWork'; +import type { AssociateIssueWithBranchCommandArgs, StartWorkCommandArgs } from '../plus/startWork/startWork'; import { command } from '../system/vscode/command'; import type { CommandContext } from './base'; import type { QuickWizardCommandArgsWithCompletion } from './quickWizard.base'; import { QuickWizardCommandBase } from './quickWizard.base'; -export type QuickWizardCommandArgs = LaunchpadCommandArgs | StartWorkCommandArgs; +export type QuickWizardCommandArgs = LaunchpadCommandArgs | StartWorkCommandArgs | AssociateIssueWithBranchCommandArgs; @command() export class QuickWizardCommand extends QuickWizardCommandBase { constructor(container: Container) { - super(container, [GlCommand.ShowLaunchpad, GlCommand.StartWork]); + super(container, [GlCommand.ShowLaunchpad, GlCommand.StartWork, GlCommand.AssociateIssueWithBranch]); } protected override preExecute( @@ -26,6 +26,9 @@ export class QuickWizardCommand extends QuickWizardCommandBase { case GlCommand.StartWork: return this.execute({ command: 'startWork', ...args }); + case GlCommand.AssociateIssueWithBranch: + return this.execute({ command: 'associateIssueWithBranch', ...args }); + default: return this.execute(args); } diff --git a/src/commands/quickWizard.utils.ts b/src/commands/quickWizard.utils.ts index be71c02688173..3b0a75e74ccdb 100644 --- a/src/commands/quickWizard.utils.ts +++ b/src/commands/quickWizard.utils.ts @@ -1,7 +1,7 @@ import type { StoredRecentUsage } from '../constants.storage'; import type { Container } from '../container'; import { LaunchpadCommand } from '../plus/launchpad/launchpad'; -import { StartWorkCommand } from '../plus/startWork/startWork'; +import { AssociateIssueWithBranchCommand, StartWorkCommand } from '../plus/startWork/startWork'; import { configuration } from '../system/vscode/configuration'; import { getContext } from '../system/vscode/context'; import { BranchGitCommand } from './git/branch'; @@ -117,6 +117,10 @@ export class QuickWizardRootStep implements QuickPickStep { if (args?.command === 'startWork') { this.hiddenItems.push(new StartWorkCommand(container, args)); } + + if (args?.command === 'associateIssueWithBranch') { + this.hiddenItems.push(new AssociateIssueWithBranchCommand(container, args)); + } } private _command: QuickCommand | undefined; diff --git a/src/constants.commands.ts b/src/constants.commands.ts index e28c35523557f..a69800fc4fd17 100644 --- a/src/constants.commands.ts +++ b/src/constants.commands.ts @@ -5,6 +5,7 @@ export const actionCommandPrefix = 'gitlens.action.'; export const enum GlCommand { AddAuthors = 'gitlens.addAuthors', + AssociateIssueWithBranch = 'gitlens.associateIssueWithBranch', BrowseRepoAtRevision = 'gitlens.browseRepoAtRevision', BrowseRepoAtRevisionInNewWindow = 'gitlens.browseRepoAtRevisionInNewWindow', BrowseRepoBeforeRevision = 'gitlens.browseRepoBeforeRevision', @@ -572,6 +573,7 @@ export type TreeViewCommands = `gitlens.views.${ | 'addAuthors' | 'addAuthor' | 'addAuthor.multi' + | 'associateIssueWithBranch' | 'openBranchOnRemote' | 'openBranchOnRemote.multi' | 'copyRemoteCommitUrl' @@ -697,6 +699,7 @@ type GraphWebviewCommands = `graph.${ | 'pull' | 'fetch' | 'pushWithForce' + | 'associateIssueWithBranch' | 'publishBranch' | 'switchToAnotherBranch' | 'createBranch' diff --git a/src/constants.telemetry.ts b/src/constants.telemetry.ts index 64170b0e39308..39dc580c5835d 100644 --- a/src/constants.telemetry.ts +++ b/src/constants.telemetry.ts @@ -218,6 +218,23 @@ export interface TelemetryEvents extends WebviewShowAbortedEvents, WebviewShownE /** Sent when the user chooses to manage integrations */ 'startWork/action': StartWorkActionEvent; + /** Sent when the user opens Start Work; use `instance` to correlate an Associate Issue with Branch "session" */ + 'associateIssueWithBranch/open': StartWorkEventDataBase; + /** Sent when the launchpad is opened; use `instance` to correlate an Associate Issue with Branch "session" */ + 'associateIssueWithBranch/opened': StartWorkConnectedEventData; + /** Sent when the user takes an action on an issue */ + 'associateIssueWithBranch/issue/action': StartWorkIssueActionEvent; + /** Sent when the user chooses an issue to associate with the branch in the second step */ + 'associateIssueWithBranch/issue/chosen': StartWorkIssueChosenEvent; + /** Sent when the user reaches the "connect an integration" step of Associate Issue with Branch */ + 'associateIssueWithBranch/steps/connect': StartWorkConnectedEventData; + /** Sent when the user reaches the "choose an issue" step of Associate Issue with Branch */ + 'associateIssueWithBranch/steps/issue': StartWorkConnectedEventData; + /** Sent when the user chooses to connect an integration */ + 'associateIssueWithBranch/title/action': StartWorkTitleActionEvent; + /** Sent when the user chooses to manage integrations */ + 'associateIssueWithBranch/action': StartWorkActionEvent; + /** Sent when the subscription is loaded */ subscription: SubscriptionEventData; @@ -861,6 +878,7 @@ export type TrackingContext = 'graph' | 'launchpad' | 'visual_file_history' | 'w export type Sources = | 'account' + | 'associateIssueWithBranch' | 'code-suggest' | 'cloud-patches' | 'commandPalette' @@ -884,6 +902,7 @@ export type Sources = | 'trial-indicator' | 'scm-input' | 'subscription' + | 'view' | 'walkthrough' | 'whatsnew' | 'worktrees'; diff --git a/src/plus/startWork/startWork.ts b/src/plus/startWork/startWork.ts index cab8d1ef5edff..708bed162907b 100644 --- a/src/plus/startWork/startWork.ts +++ b/src/plus/startWork/startWork.ts @@ -30,9 +30,12 @@ import type { IntegrationId } from '../../constants.integrations'; import { HostingIntegrationId, IssueIntegrationId } from '../../constants.integrations'; import type { Source, Sources, StartWorkTelemetryContext, TelemetryEvents } from '../../constants.telemetry'; import type { Container } from '../../container'; +import { addAssociatedIssueToBranch } from '../../git/models/branch.utils'; import type { Issue, IssueShape, SearchedIssue } from '../../git/models/issue'; import { getOrOpenIssueRepository } from '../../git/models/issue'; +import type { GitBranchReference } from '../../git/models/reference'; import type { Repository } from '../../git/models/repository'; +import { showBranchPicker } from '../../quickpicks/branchPicker'; import type { QuickPickItemOfT } from '../../quickpicks/items/common'; import { createQuickPickItemOfT } from '../../quickpicks/items/common'; import type { DirectiveQuickPickItem } from '../../quickpicks/items/directive'; @@ -43,6 +46,7 @@ import { some } from '../../system/iterable'; import { executeCommand } from '../../system/vscode/command'; import { configuration } from '../../system/vscode/configuration'; import { openUrl } from '../../system/vscode/utils'; +import { getIssueOwner } from '../integrations/providers/utils'; export type StartWorkItem = { item: SearchedIssue; @@ -70,11 +74,21 @@ function assertsStartWorkStepState(state: StepState): asserts state is St throw new Error('Missing item'); } -export interface StartWorkCommandArgs { - readonly command: 'startWork'; +export interface StartWorkBaseCommandArgs { + readonly command: 'startWork' | 'associateIssueWithBranch'; source?: Sources; } +export interface StartWorkOverrides { + ownSource?: 'startWork' | 'associateIssueWithBranch'; + placeholders?: { + localIntegrationConnect?: string; + cloudIntegrationConnectHasConnected?: string; + cloudIntegrationConnectNoConnected?: string; + issueSelection?: string; + }; +} + export const supportedStartWorkIntegrations = [ HostingIntegrationId.GitHub, HostingIntegrationId.GitLab, @@ -111,20 +125,35 @@ function isManageIntegrationsItem(item: unknown): item is ManageIntegrationsItem return item === manageIntegrationsItem; } -export class StartWorkCommand extends QuickCommand { +export abstract class StartWorkBaseCommand extends QuickCommand { private readonly source: Source; private readonly telemetryContext: StartWorkTelemetryContext | undefined; - - constructor(container: Container, args?: StartWorkCommandArgs) { - super(container, 'startWork', 'startWork', `Start Work\u00a0\u00a0${proBadge}`, { - description: 'Start work on an issue', + private readonly telemetryEventKey: 'startWork' | 'associateIssueWithBranch'; + protected abstract overrides?: StartWorkOverrides; + + constructor( + container: Container, + args?: StartWorkBaseCommandArgs, + key: string = 'startWork', + label: string = 'startWork', + title: string = `Start Work\u00a0\u00a0${proBadge}`, + description: string = 'Start work on an issue', + telemetryEventKey: 'startWork' | 'associateIssueWithBranch' = 'startWork', + ) { + super(container, key, label, title, { + description: description, }); + this.telemetryEventKey = telemetryEventKey; this.source = { source: args?.source ?? 'commandPalette' }; if (this.container.telemetry.enabled) { this.telemetryContext = { instance: instanceCounter.next() }; - this.container.telemetry.sendEvent('startWork/open', { ...this.telemetryContext }, this.source); + this.container.telemetry.sendEvent( + `${this.telemetryEventKey}/open`, + { ...this.telemetryContext }, + this.source, + ); } this.initialState = { @@ -152,7 +181,7 @@ export class StartWorkCommand extends QuickCommand { if (!hasConnectedIntegrations) { if (this.container.telemetry.enabled) { this.container.telemetry.sendEvent( - opened ? 'startWork/steps/connect' : 'startWork/opened', + opened ? `${this.telemetryEventKey}/steps/connect` : `${this.telemetryEventKey}/opened`, { ...context.telemetryContext!, connected: false, @@ -182,7 +211,7 @@ export class StartWorkCommand extends QuickCommand { if (state.counter < 1 || state.item == null) { if (this.container.telemetry.enabled) { this.container.telemetry.sendEvent( - opened ? 'startWork/steps/issue' : 'startWork/opened', + opened ? `${this.telemetryEventKey}/steps/issue` : `${this.telemetryEventKey}/opened`, { ...context.telemetryContext!, connected: true, @@ -198,7 +227,7 @@ export class StartWorkCommand extends QuickCommand { state.item = result; if (this.container.telemetry.enabled) { this.container.telemetry.sendEvent( - 'startWork/issue/chosen', + `${this.telemetryEventKey}/issue/chosen`, { ...context.telemetryContext!, ...buildItemTelemetryData(result), @@ -211,28 +240,8 @@ export class StartWorkCommand extends QuickCommand { assertsStartWorkStepState(state); - const issue = state.item.item.issue; - const repo = issue && (await this.getIssueRepositoryIfExists(issue)); - - const result = yield* getSteps( - this.container, - { - command: 'branch', - state: { - subcommand: 'create', - repo: repo, - name: issue ? `${slug(issue.id, { lower: false })}-${slug(issue.title)}` : undefined, - suggestNameOnly: true, - suggestRepoOnly: true, - confirmOptions: ['--switch', '--worktree'], - associateWithIssue: issue, - }, - }, - this.pickedVia, - ); - if (result !== StepResultBreak) { - state.counter = 0; - continue; + if (this.continuation) { + yield* this.continuation(state, context); } endSteps(state); @@ -241,7 +250,9 @@ export class StartWorkCommand extends QuickCommand { return state.counter < 0 ? StepResultBreak : undefined; } - private async getIssueRepositoryIfExists(issue: IssueShape | Issue): Promise { + protected abstract continuation?(state: StartWorkStepState, context: Context): StepGenerator; + + protected async getIssueRepositoryIfExists(issue: IssueShape | Issue): Promise { try { return await getOrOpenIssueRepository(this.container, issue); } catch { @@ -266,7 +277,7 @@ export class StartWorkCommand extends QuickCommand { createQuickPickItemOfT( { label: 'Connect to GitHub...', - detail: 'Will connect to GitHub to provide access your pull requests and issues', + detail: 'Will connect to GitHub to provide access to your pull requests and issues', }, integration, ), @@ -282,7 +293,9 @@ export class StartWorkCommand extends QuickCommand { confirmations, createDirectiveQuickPickItem(Directive.Cancel, false, { label: 'Cancel' }), { - placeholder: 'Connect an integration to view their issues in Start Work', + placeholder: + this.overrides?.placeholders?.localIntegrationConnect ?? + 'Connect an integration to view its issues in Start Work', buttons: [], ignoreFocusOut: false, }, @@ -303,7 +316,7 @@ export class StartWorkCommand extends QuickCommand { const integration = await this.container.integrations.get(id); let connected = integration.maybeConnected ?? (await integration.isConnected()); if (!connected) { - connected = await integration.connect('startWork'); + connected = await integration.connect(this.overrides?.ownSource ?? 'startWork'); } return connected; @@ -329,7 +342,7 @@ export class StartWorkCommand extends QuickCommand { { label: `Connect an ${hasConnectedIntegration ? 'Additional ' : ''}Integration...`, detail: hasConnectedIntegration - ? 'Connect additional integrations to view their issues in Start Work' + ? 'Connect additional integrations to view their issues' : 'Connect an integration to accelerate your work', picked: true, }, @@ -339,8 +352,10 @@ export class StartWorkCommand extends QuickCommand { createDirectiveQuickPickItem(Directive.Cancel, false, { label: 'Cancel' }), { placeholder: hasConnectedIntegration - ? 'Connect additional integrations to Start Work' - : 'Connect an integration to get started with Start Work', + ? this.overrides?.placeholders?.cloudIntegrationConnectHasConnected ?? + 'Connect additional integrations to Start Work' + : this.overrides?.placeholders?.cloudIntegrationConnectNoConnected ?? + 'Connect an integration to get started with Start Work', buttons: [], ignoreFocusOut: true, }, @@ -362,7 +377,7 @@ export class StartWorkCommand extends QuickCommand { const connected = await this.container.integrations.connectCloudIntegrations( { integrationIds: supportedStartWorkIntegrations }, { - source: 'startWork', + source: this.overrides?.ownSource ?? 'startWork', }, ); if (step.quickpick) { @@ -409,7 +424,7 @@ export class StartWorkCommand extends QuickCommand { return items; }; - function getItemsAndPlaceholder(): { + function getItemsAndPlaceholder(placeholderOverride?: string): { placeholder: string; items: (DirectiveQuickPickItem | QuickPickItemOfT)[]; } { @@ -424,7 +439,7 @@ export class StartWorkCommand extends QuickCommand { } return { - placeholder: 'Choose an issue to start working on', + placeholder: placeholderOverride ?? 'Choose an issue to start working on', items: [...getItems(context.result), createDirectiveQuickPickItem(Directive.Cancel)], }; } @@ -433,7 +448,7 @@ export class StartWorkCommand extends QuickCommand { quickpick.busy = true; try { await updateContextItems(this.container, context); - const { items, placeholder } = getItemsAndPlaceholder(); + const { items, placeholder } = getItemsAndPlaceholder(this.overrides?.placeholders?.issueSelection); quickpick.placeholder = placeholder; quickpick.items = items; } catch { @@ -492,7 +507,7 @@ export class StartWorkCommand extends QuickCommand { return StepResultBreak; } else if (isManageIntegrationsItem(element)) { this.sendActionTelemetry('manage', context); - executeCommand(GlCommand.PlusManageCloudIntegrations, { source: 'startWork' }); + executeCommand(GlCommand.PlusManageCloudIntegrations, { source: this.overrides?.ownSource ?? 'startWork' }); endSteps(state); return StepResultBreak; } @@ -506,7 +521,7 @@ export class StartWorkCommand extends QuickCommand { } private sendItemActionTelemetry(action: 'soft-open', item: StartWorkItem, context: Context) { - this.container.telemetry.sendEvent('startWork/issue/action', { + this.container.telemetry.sendEvent(`${this.telemetryEventKey}/issue/action`, { ...context.telemetryContext!, ...buildItemTelemetryData(item), action: action, @@ -518,7 +533,7 @@ export class StartWorkCommand extends QuickCommand { if (!this.container.telemetry.enabled) return; this.container.telemetry.sendEvent( - 'startWork/title/action', + `${this.telemetryEventKey}/title/action`, { ...context.telemetryContext!, connected: true, action: action }, this.source, ); @@ -528,13 +543,118 @@ export class StartWorkCommand extends QuickCommand { if (!this.container.telemetry.enabled) return; this.container.telemetry.sendEvent( - 'startWork/action', + `${this.telemetryEventKey}/action`, { ...context.telemetryContext!, connected: true, action: action }, this.source, ); } } +export interface StartWorkCommandArgs { + readonly command: 'startWork'; + source?: Sources; +} + +export class StartWorkCommand extends StartWorkBaseCommand { + overrides?: undefined; + + protected override async *continuation( + state: StartWorkStepState, + _context: Context, + ): AsyncStepResultGenerator { + const issue = state.item.item.issue; + const repo = issue && (await this.getIssueRepositoryIfExists(issue)); + + const result = yield* getSteps( + this.container, + { + command: 'branch', + state: { + subcommand: 'create', + repo: repo, + name: issue ? `${slug(issue.id, { lower: false })}-${slug(issue.title)}` : undefined, + suggestNameOnly: true, + suggestRepoOnly: true, + confirmOptions: ['--switch', '--worktree'], + associateWithIssue: issue, + }, + }, + this.pickedVia, + ); + if (result !== StepResultBreak) { + state.counter = 0; + } else { + endSteps(state); + } + } +} + +export interface AssociateIssueWithBranchCommandArgs { + readonly command: 'associateIssueWithBranch'; + branch?: GitBranchReference; + source?: Sources; +} + +export class AssociateIssueWithBranchCommand extends StartWorkBaseCommand { + private branch: GitBranchReference | undefined; + protected override overrides: StartWorkOverrides = { + ownSource: 'associateIssueWithBranch', + placeholders: { + cloudIntegrationConnectHasConnected: + 'Connect additional integrations to associate their issues with your branches', + cloudIntegrationConnectNoConnected: 'Connect an integration to associate its issues with your branches', + localIntegrationConnect: 'Connect an integration to associate its issues with your branches', + issueSelection: 'Choose an issue to associate with your branch', + }, + }; + + constructor(container: Container, args?: AssociateIssueWithBranchCommandArgs) { + super( + container, + { command: 'associateIssueWithBranch', source: args?.source ?? 'commandPalette' }, + 'associateIssueWithBranch', + 'associateIssueWithBranch', + `Associate Issue with Branch\u00a0\u00a0${proBadge}`, + 'Associate an issue with your branch', + 'associateIssueWithBranch', + ); + this.branch = args?.branch; + } + + // eslint-disable-next-line require-yield + protected override async *continuation( + state: StartWorkStepState, + _context: Context, + ): AsyncStepResultGenerator { + if (!this.container.git.openRepositories.length) { + return; + } + + const issue = state.item.item.issue; + + if (this.branch == null) { + this.branch = await showBranchPicker( + `Associate Issue with Branch\u00a0\u00a0${proBadge}`, + 'Choose a branch to associate the issue with', + this.container.git.openRepositories, + { filter: b => !b.remote }, + ); + } + + if (this.branch == null) { + return; + } + + const owner = getIssueOwner(issue); + if (owner == null) { + return; + } + + await addAssociatedIssueToBranch(this.container, this.branch, { ...issue, type: 'issue' }, owner); + endSteps(state); + } +} + async function updateContextItems(container: Container, context: Context) { context.connectedIntegrations = await getConnectedIntegrations(container); const connectedIntegrations = [...context.connectedIntegrations.keys()].filter(integrationId => diff --git a/src/plus/webviews/graph/graphWebview.ts b/src/plus/webviews/graph/graphWebview.ts index 5356c1d1a3b6e..c178162b78d53 100644 --- a/src/plus/webviews/graph/graphWebview.ts +++ b/src/plus/webviews/graph/graphWebview.ts @@ -125,6 +125,7 @@ import type { FeaturePreviewChangeEvent, SubscriptionChangeEvent } from '../../g import type { ConnectionStateChangeEvent } from '../../integrations/integrationService'; import { remoteProviderIdToIntegrationId } from '../../integrations/integrationService'; import { getPullRequestBranchDeepLink } from '../../launchpad/launchpadProvider'; +import type { AssociateIssueWithBranchCommandArgs } from '../../startWork/startWork'; import type { BranchState, DidChangeRefsVisibilityParams, @@ -511,6 +512,7 @@ export class GraphWebviewProvider implements WebviewProvider(GlCommand.AssociateIssueWithBranch, { + command: 'associateIssueWithBranch', + branch: ref, + source: 'graph', + }); + } + + return Promise.resolve(); + } + @log() private cherryPick(item?: GraphItemContext) { const ref = this.getGraphItemRef(item, 'revision'); diff --git a/src/quickpicks/branchPicker.ts b/src/quickpicks/branchPicker.ts index 39e1627a578f7..cba3287e2f8a4 100644 --- a/src/quickpicks/branchPicker.ts +++ b/src/quickpicks/branchPicker.ts @@ -9,13 +9,16 @@ import type { BranchQuickPickItem } from './items/gitWizard'; export async function showBranchPicker( title: string | undefined, placeholder?: string, - repository?: Repository, + repository?: Repository | Repository[], + options?: { + filter?: (b: GitBranch) => boolean; + }, ): Promise { if (repository == null) { return undefined; } - const items: BranchQuickPickItem[] = await getBranches(repository, {}); + const items: BranchQuickPickItem[] = await getBranches(repository, options ?? {}); if (items.length === 0) return undefined; const quickpick = window.createQuickPick(); diff --git a/src/views/viewCommands.ts b/src/views/viewCommands.ts index 510a6abdcce79..7f293391e699d 100644 --- a/src/views/viewCommands.ts +++ b/src/views/viewCommands.ts @@ -36,6 +36,7 @@ import { deletedOrMissing } from '../git/models/revision'; import { shortenRevision } from '../git/models/revision.utils'; import { showPatchesView } from '../plus/drafts/actions'; import { getPullRequestBranchDeepLink } from '../plus/launchpad/launchpadProvider'; +import type { AssociateIssueWithBranchCommandArgs } from '../plus/startWork/startWork'; import { showContributorsPicker } from '../quickpicks/contributorsPicker'; import { filterMap } from '../system/array'; import { log } from '../system/decorators/log'; @@ -246,6 +247,8 @@ export class ViewCommands implements Disposable { 'sequential', ), + registerViewCommand('gitlens.views.associateIssueWithBranch', n => this.associateIssueWithBranch(n), this), + registerViewCommand( 'gitlens.views.copyRemoteCommitUrl', (n, nodes) => this.openCommitOnRemote(n, nodes, true), @@ -1625,6 +1628,17 @@ export class ViewCommands implements Disposable { void node.triggerChange(true); } + + @log() + private async associateIssueWithBranch(node: BranchNode) { + if (!node.is('branch')) return Promise.resolve(); + + executeCommand(GlCommand.AssociateIssueWithBranch, { + command: 'associateIssueWithBranch', + branch: node.ref, + source: 'view', + }); + } } async function copyNode(type: ClipboardType, active: ViewNode | undefined, selection: ViewNode[]): Promise {