diff --git a/docs/telemetry-events.md b/docs/telemetry-events.md index f8e4028e27a69..61b0a00bf283f 100644 --- a/docs/telemetry-events.md +++ b/docs/telemetry-events.md @@ -1321,6 +1321,38 @@ void } ``` +### startWork/open + +> Sent when the user opens Start Work; use `instance` to correlate a StartWork "session" + +```typescript +{ + 'instance': number +} +``` + +### startWork/opened + +> Sent when the launchpad is opened; use `instance` to correlate a StartWork "session" + +```typescript +{ + 'instance': number, + 'connected': false | true +} +``` + +### startWork/steps/connect + +> Sent when the Start Work has "reloaded" (while open, e.g. user refreshed or back button) and is disconnected; use `instance` to correlate a Start Work "session" + +```typescript +{ + 'instance': number, + 'connected': false | true +} +``` + ### openReviewMode > Sent when a PR review was started in the inspect overview @@ -1331,7 +1363,7 @@ void 'repository.visibility': 'private' | 'public' | 'local', 'repoPrivacy': 'private' | 'public' | 'local', 'filesChanged': number, - 'source': 'graph' | 'patchDetails' | 'settings' | 'timeline' | 'home' | 'code-suggest' | 'account' | 'cloud-patches' | 'commandPalette' | 'deeplink' | 'inspect' | 'inspect-overview' | 'integrations' | 'launchpad' | 'launchpad-indicator' | 'launchpad-view' | 'notification' | 'prompt' | 'quick-wizard' | 'remoteProvider' | 'trial-indicator' | 'scm-input' | 'subscription' | 'walkthrough' | 'worktrees' + 'source': 'graph' | 'patchDetails' | 'settings' | 'timeline' | 'home' | 'code-suggest' | 'account' | 'cloud-patches' | 'commandPalette' | 'deeplink' | 'inspect' | 'inspect-overview' | 'integrations' | 'launchpad' | 'launchpad-indicator' | 'launchpad-view' | 'notification' | 'prompt' | 'quick-wizard' | 'remoteProvider' | 'startWork' | 'trial-indicator' | 'scm-input' | 'subscription' | 'walkthrough' | 'worktrees' } ``` diff --git a/package.json b/package.json index 6cdf5af080007..c79a374498c4e 100644 --- a/package.json +++ b/package.json @@ -5892,6 +5892,12 @@ "category": "GitLens", "icon": "$(rocket)" }, + { + "command": "gitlens.startWork", + "title": "Start Work", + "category": "GitLens", + "icon": "$(rocket)" + }, { "command": "gitlens.showLaunchpadView", "title": "Show Launchpad View", @@ -10350,6 +10356,10 @@ "command": "gitlens.showLaunchpad", "when": "gitlens:enabled" }, + { + "command": "gitlens.startWork", + "when": "gitlens:enabled" + }, { "command": "gitlens.showLaunchpadView", "when": "gitlens:enabled" @@ -19851,7 +19861,7 @@ "@gitkraken/provider-apis": "0.24.2", "@gitkraken/shared-web-components": "0.1.1-rc.15", "@gk-nzaytsev/fast-string-truncated-width": "1.1.0", - "@lit-labs/signals": "^0.1.1", + "@lit-labs/signals": "0.1.1", "@lit/context": "1.1.3", "@lit/react": "1.0.6", "@lit/task": "1.0.1", @@ -19877,7 +19887,8 @@ "path-browserify": "1.0.1", "react": "16.8.4", "react-dom": "16.8.4", - "signal-utils": "^0.20.0", + "signal-utils": "0.20.0", + "slug": "10.0.0", "sortablejs": "1.15.0" }, "devDependencies": { @@ -19891,6 +19902,7 @@ "@types/node": "18.15.0", "@types/react": "17.0.82", "@types/react-dom": "17.0.21", + "@types/slug": "5.0.9", "@types/sortablejs": "1.15.8", "@types/vscode": "1.82.0", "@typescript-eslint/parser": "8.13.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65fa6be42f35f..df075c885e8e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,7 +27,7 @@ importers: specifier: 1.1.0 version: 1.1.0 '@lit-labs/signals': - specifier: ^0.1.1 + specifier: 0.1.1 version: 0.1.1 '@lit/context': specifier: 1.1.3 @@ -105,15 +105,18 @@ importers: specifier: 16.8.4 version: 16.8.4(react@16.8.4) signal-utils: - specifier: ^0.20.0 + specifier: 0.20.0 version: 0.20.0(signal-polyfill@0.2.1) + slug: + specifier: 10.0.0 + version: 10.0.0 sortablejs: specifier: 1.15.0 version: 1.15.0 devDependencies: '@eamodio/eslint-lite-webpack-plugin': specifier: 0.1.0 - version: 0.1.0(@swc/core@1.9.1)(esbuild@0.24.0)(eslint@9.14.0)(webpack-cli@5.1.4)(webpack@5.96.1) + version: 0.1.0(@swc/core@1.9.1)(esbuild@0.24.0)(eslint@9.14.0)(webpack-cli@5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.96.1))(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)) '@eslint/js': specifier: 9.14.0 version: 9.14.0 @@ -141,6 +144,9 @@ importers: '@types/react-dom': specifier: 17.0.21 version: 17.0.21 + '@types/slug': + specifier: 5.0.9 + version: 5.0.9 '@types/sortablejs': specifier: 1.15.8 version: 1.15.8 @@ -167,22 +173,22 @@ importers: version: 1.0.0-rc.12 circular-dependency-plugin: specifier: 5.2.2 - version: 5.2.2(webpack@5.96.1) + version: 5.2.2(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)) clean-webpack-plugin: specifier: 4.0.0 - version: 4.0.0(webpack@5.96.1) + version: 4.0.0(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)) copy-webpack-plugin: specifier: 12.0.2 - version: 12.0.2(webpack@5.96.1) + version: 12.0.2(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)) csp-html-webpack-plugin: specifier: 5.1.0 - version: 5.1.0(html-webpack-plugin@5.6.3(webpack@5.96.1))(webpack@5.96.1) + version: 5.1.0(html-webpack-plugin@5.6.3(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)))(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)) css-loader: specifier: 7.1.2 - version: 7.1.2(webpack@5.96.1) + version: 7.1.2(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)) css-minimizer-webpack-plugin: specifier: 7.0.0 - version: 7.0.0(esbuild@0.24.0)(webpack@5.96.1) + version: 7.0.0(esbuild@0.24.0)(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)) cssnano-preset-advanced: specifier: 7.0.6 version: 7.0.6(postcss@8.4.47) @@ -191,7 +197,7 @@ importers: version: 0.24.0 esbuild-loader: specifier: 4.2.2 - version: 4.2.2(webpack@5.96.1) + version: 4.2.2(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)) esbuild-node-externals: specifier: 1.15.0 version: 1.15.0(esbuild@0.24.0) @@ -218,7 +224,7 @@ importers: version: 2.2.0(eslint@9.14.0) fork-ts-checker-webpack-plugin: specifier: 6.5.3 - version: 6.5.3(eslint@9.14.0)(typescript@5.7.1-rc)(webpack@5.96.1) + version: 6.5.3(eslint@9.14.0)(typescript@5.7.1-rc)(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)) glob: specifier: 11.0.0 version: 11.0.0 @@ -227,13 +233,13 @@ importers: version: 15.12.0 html-loader: specifier: 5.1.0 - version: 5.1.0(webpack@5.96.1) + version: 5.1.0(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)) html-webpack-plugin: specifier: 5.6.3 - version: 5.6.3(webpack@5.96.1) + version: 5.6.3(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)) image-minimizer-webpack-plugin: specifier: 4.1.0 - version: 4.1.0(sharp@0.32.6)(svgo@3.3.2)(webpack@5.96.1) + version: 4.1.0(sharp@0.32.6)(svgo@3.3.2)(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)) license-checker-rseidelsohn: specifier: 4.4.2 version: 4.4.2 @@ -242,7 +248,7 @@ importers: version: 1.5.0 mini-css-extract-plugin: specifier: 2.9.2 - version: 2.9.2(webpack@5.96.1) + version: 2.9.2(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)) mocha: specifier: 10.8.2 version: 10.8.2 @@ -260,7 +266,7 @@ importers: version: 1.80.6 sass-loader: specifier: 16.0.3 - version: 16.0.3(sass-embedded@1.77.8)(sass@1.80.6)(webpack@5.96.1) + version: 16.0.3(sass-embedded@1.77.8)(sass@1.80.6)(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)) schema-utils: specifier: 4.2.0 version: 4.2.0 @@ -272,10 +278,10 @@ importers: version: 3.3.2 terser-webpack-plugin: specifier: 5.3.10 - version: 5.3.10(@swc/core@1.9.1)(esbuild@0.24.0)(webpack@5.96.1) + version: 5.3.10(@swc/core@1.9.1)(esbuild@0.24.0)(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)) ts-loader: specifier: 9.5.1 - version: 9.5.1(typescript@5.7.1-rc)(webpack@5.96.1) + version: 9.5.1(typescript@5.7.1-rc)(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)) typescript: specifier: 5.7.1-rc version: 5.7.1-rc @@ -1088,6 +1094,9 @@ packages: '@types/scheduler@0.16.8': resolution: {integrity: sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==} + '@types/slug@5.0.9': + resolution: {integrity: sha512-6Yp8BSplP35Esa/wOG1wLNKiqXevpQTEF/RcL/NV6BBQaMmZh4YlDwCgrrFSoUE4xAGvnKd5c+lkQJmPrBAzfQ==} + '@types/sortablejs@1.15.8': resolution: {integrity: sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==} @@ -4627,6 +4636,10 @@ packages: slide@1.1.6: resolution: {integrity: sha512-NwrtjCg+lZoqhFU8fOwl4ay2ei8PaqCBOUV3/ektPY9trO1yQ1oXEfmHAhKArUVUr/hOHvy5f6AdP17dCM0zMw==} + slug@10.0.0: + resolution: {integrity: sha512-M8s2PWOUeSCdD4S1NH5lCzXg2zFV1fozrtfr0FSKl65x+EF1rUowj+/vyFlnHgxPxWzT+DL0VXKfYc1DHJoymg==} + hasBin: true + slugify@1.6.6: resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==} engines: {node: '>=8.0.0'} @@ -5373,10 +5386,10 @@ snapshots: '@discoveryjs/json-ext@0.5.7': {} - '@eamodio/eslint-lite-webpack-plugin@0.1.0(@swc/core@1.9.1)(esbuild@0.24.0)(eslint@9.14.0)(webpack-cli@5.1.4)(webpack@5.96.1)': + '@eamodio/eslint-lite-webpack-plugin@0.1.0(@swc/core@1.9.1)(esbuild@0.24.0)(eslint@9.14.0)(webpack-cli@5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.96.1))(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4))': dependencies: '@types/eslint': 9.6.1 - '@types/webpack': 5.28.5(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4) + '@types/webpack': 5.28.5(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.96.1)) eslint: 9.14.0 fast-glob: 3.3.2 minimatch: 10.0.1 @@ -6011,13 +6024,15 @@ snapshots: '@types/scheduler@0.16.8': {} + '@types/slug@5.0.9': {} + '@types/sortablejs@1.15.8': {} '@types/trusted-types@2.0.7': {} '@types/vscode@1.82.0': {} - '@types/webpack@5.28.5(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)': + '@types/webpack@5.28.5(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.96.1))': dependencies: '@types/node': 18.15.0 tapable: 2.2.1 @@ -6305,17 +6320,17 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@xtuc/long': 4.2.2 - '@webpack-cli/configtest@2.1.1(webpack-cli@5.1.4)(webpack@5.96.1)': + '@webpack-cli/configtest@2.1.1(webpack-cli@5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.96.1))(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4))': dependencies: webpack: 5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.96.1) - '@webpack-cli/info@2.0.2(webpack-cli@5.1.4)(webpack@5.96.1)': + '@webpack-cli/info@2.0.2(webpack-cli@5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.96.1))(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4))': dependencies: webpack: 5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.96.1) - '@webpack-cli/serve@2.0.5(webpack-cli@5.1.4)(webpack@5.96.1)': + '@webpack-cli/serve@2.0.5(webpack-cli@5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.96.1))(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4))': dependencies: webpack: 5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.96.1) @@ -6776,7 +6791,7 @@ snapshots: ci-info@3.9.0: {} - circular-dependency-plugin@5.2.2(webpack@5.96.1): + circular-dependency-plugin@5.2.2(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)): dependencies: webpack: 5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4) @@ -6788,7 +6803,7 @@ snapshots: clean-stack@2.2.0: {} - clean-webpack-plugin@4.0.0(webpack@5.96.1): + clean-webpack-plugin@4.0.0(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)): dependencies: del: 4.1.1 webpack: 5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4) @@ -6893,7 +6908,7 @@ snapshots: depd: 2.0.0 keygrip: 1.1.0 - copy-webpack-plugin@12.0.2(webpack@5.96.1): + copy-webpack-plugin@12.0.2(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)): dependencies: fast-glob: 3.3.2 glob-parent: 6.0.2 @@ -6925,10 +6940,10 @@ snapshots: dependencies: custom-event: 1.0.0 - csp-html-webpack-plugin@5.1.0(html-webpack-plugin@5.6.3(webpack@5.96.1))(webpack@5.96.1): + csp-html-webpack-plugin@5.1.0(html-webpack-plugin@5.6.3(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)))(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)): dependencies: cheerio: 1.0.0-rc.12 - html-webpack-plugin: 5.6.3(webpack@5.96.1) + html-webpack-plugin: 5.6.3(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)) lodash: 4.17.21 webpack: 5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4) @@ -6936,7 +6951,7 @@ snapshots: dependencies: postcss: 8.4.47 - css-loader@7.1.2(webpack@5.96.1): + css-loader@7.1.2(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)): dependencies: icss-utils: 5.1.0(postcss@8.4.47) postcss: 8.4.47 @@ -6949,7 +6964,7 @@ snapshots: optionalDependencies: webpack: 5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4) - css-minimizer-webpack-plugin@7.0.0(esbuild@0.24.0)(webpack@5.96.1): + css-minimizer-webpack-plugin@7.0.0(esbuild@0.24.0)(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)): dependencies: '@jridgewell/trace-mapping': 0.3.25 cssnano: 7.0.6(postcss@8.4.47) @@ -7449,7 +7464,7 @@ snapshots: is-symbol: 1.0.4 optional: true - esbuild-loader@4.2.2(webpack@5.96.1): + esbuild-loader@4.2.2(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)): dependencies: esbuild: 0.24.0 get-tsconfig: 4.8.1 @@ -7520,7 +7535,7 @@ snapshots: debug: 4.3.7(supports-color@8.1.1) enhanced-resolve: 5.17.1 eslint: 9.14.0 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.13.0(eslint@9.14.0)(typescript@5.7.1-rc))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.14.0) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.13.0(eslint@9.14.0)(typescript@5.7.1-rc))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.13.0(eslint@9.14.0)(typescript@5.7.1-rc))(eslint-plugin-import-x@4.4.0(eslint@9.14.0)(typescript@5.7.1-rc))(eslint-plugin-import@2.29.1)(eslint@9.14.0))(eslint@9.14.0) fast-glob: 3.3.2 get-tsconfig: 4.8.1 is-bun-module: 1.2.1 @@ -7534,7 +7549,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.13.0(eslint@9.14.0)(typescript@5.7.1-rc))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.14.0): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.13.0(eslint@9.14.0)(typescript@5.7.1-rc))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.13.0(eslint@9.14.0)(typescript@5.7.1-rc))(eslint-plugin-import-x@4.4.0(eslint@9.14.0)(typescript@5.7.1-rc))(eslint-plugin-import@2.29.1)(eslint@9.14.0))(eslint@9.14.0): dependencies: debug: 3.2.7 optionalDependencies: @@ -7576,7 +7591,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.14.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.13.0(eslint@9.14.0)(typescript@5.7.1-rc))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.14.0) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.13.0(eslint@9.14.0)(typescript@5.7.1-rc))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.13.0(eslint@9.14.0)(typescript@5.7.1-rc))(eslint-plugin-import-x@4.4.0(eslint@9.14.0)(typescript@5.7.1-rc))(eslint-plugin-import@2.29.1)(eslint@9.14.0))(eslint@9.14.0) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3 @@ -7760,7 +7775,7 @@ snapshots: cross-spawn: 7.0.5 signal-exit: 4.1.0 - fork-ts-checker-webpack-plugin@6.5.3(eslint@9.14.0)(typescript@5.7.1-rc)(webpack@5.96.1): + fork-ts-checker-webpack-plugin@6.5.3(eslint@9.14.0)(typescript@5.7.1-rc)(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)): dependencies: '@babel/code-frame': 7.26.2 '@types/json-schema': 7.0.15 @@ -8010,7 +8025,7 @@ snapshots: html-escaper@2.0.2: {} - html-loader@5.1.0(webpack@5.96.1): + html-loader@5.1.0(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)): dependencies: html-minifier-terser: 7.2.0 parse5: 7.2.1 @@ -8036,7 +8051,7 @@ snapshots: relateurl: 0.2.7 terser: 5.36.0 - html-webpack-plugin@5.6.3(webpack@5.96.1): + html-webpack-plugin@5.6.3(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)): dependencies: '@types/html-minifier-terser': 6.1.0 html-minifier-terser: 6.1.0 @@ -8135,7 +8150,7 @@ snapshots: ignore@5.3.2: {} - image-minimizer-webpack-plugin@4.1.0(sharp@0.32.6)(svgo@3.3.2)(webpack@5.96.1): + image-minimizer-webpack-plugin@4.1.0(sharp@0.32.6)(svgo@3.3.2)(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)): dependencies: schema-utils: 4.2.0 serialize-javascript: 6.0.2 @@ -8774,7 +8789,7 @@ snapshots: min-indent@1.0.1: {} - mini-css-extract-plugin@2.9.2(webpack@5.96.1): + mini-css-extract-plugin@2.9.2(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)): dependencies: schema-utils: 4.2.0 tapable: 2.2.1 @@ -9844,7 +9859,7 @@ snapshots: sass-embedded-win32-ia32: 1.77.8 sass-embedded-win32-x64: 1.77.8 - sass-loader@16.0.3(sass-embedded@1.77.8)(sass@1.80.6)(webpack@5.96.1): + sass-loader@16.0.3(sass-embedded@1.77.8)(sass@1.80.6)(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)): dependencies: neo-async: 2.6.2 optionalDependencies: @@ -9984,6 +9999,8 @@ snapshots: slide@1.1.6: {} + slug@10.0.0: {} + slugify@1.6.6: {} smart-buffer@4.2.0: {} @@ -10233,7 +10250,7 @@ snapshots: mkdirp: 1.0.4 yallist: 4.0.0 - terser-webpack-plugin@5.3.10(@swc/core@1.9.1)(esbuild@0.24.0)(webpack@5.96.1): + terser-webpack-plugin@5.3.10(@swc/core@1.9.1)(esbuild@0.24.0)(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)): dependencies: '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 @@ -10289,7 +10306,7 @@ snapshots: dependencies: typescript: 5.7.1-rc - ts-loader@9.5.1(typescript@5.7.1-rc)(webpack@5.96.1): + ts-loader@9.5.1(typescript@5.7.1-rc)(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)): dependencies: chalk: 4.1.2 enhanced-resolve: 5.17.1 @@ -10509,9 +10526,9 @@ snapshots: webpack-cli@5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.96.1): dependencies: '@discoveryjs/json-ext': 0.5.7 - '@webpack-cli/configtest': 2.1.1(webpack-cli@5.1.4)(webpack@5.96.1) - '@webpack-cli/info': 2.0.2(webpack-cli@5.1.4)(webpack@5.96.1) - '@webpack-cli/serve': 2.0.5(webpack-cli@5.1.4)(webpack@5.96.1) + '@webpack-cli/configtest': 2.1.1(webpack-cli@5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.96.1))(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)) + '@webpack-cli/info': 2.0.2(webpack-cli@5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.96.1))(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)) + '@webpack-cli/serve': 2.0.5(webpack-cli@5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.96.1))(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)) colorette: 2.0.20 commander: 10.0.1 cross-spawn: 7.0.5 @@ -10566,7 +10583,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.10(@swc/core@1.9.1)(esbuild@0.24.0)(webpack@5.96.1) + terser-webpack-plugin: 5.3.10(@swc/core@1.9.1)(esbuild@0.24.0)(webpack@5.96.1(@swc/core@1.9.1)(esbuild@0.24.0)(webpack-cli@5.1.4)) watchpack: 2.4.2 webpack-sources: 3.2.3 optionalDependencies: diff --git a/src/commands/git/branch.ts b/src/commands/git/branch.ts index 9cd2e93b5a417..c70f675cf9caa 100644 --- a/src/commands/git/branch.ts +++ b/src/commands/git/branch.ts @@ -63,6 +63,11 @@ interface CreateState { flags: CreateFlags[]; suggestNameOnly?: boolean; + suggestRepoOnly?: boolean; +} + +function isCreateState(state: Partial | undefined): state is Partial { + return state?.subcommand === 'create'; } type DeleteFlags = '--force' | '--remotes'; @@ -172,6 +177,10 @@ export class BranchGitCommand extends QuickCommand { counter++; } + if (args.state.suggestRepoOnly && args.state.repo != null) { + counter--; + } + break; case 'delete': case 'prune': @@ -251,7 +260,12 @@ export class BranchGitCommand extends QuickCommand { state.subcommand, ); - if (state.counter < 2 || state.repo == null || typeof state.repo === 'string') { + if ( + state.counter < 2 || + state.repo == null || + typeof state.repo === 'string' || + (isCreateState(state) && state.suggestRepoOnly) + ) { skippedStepTwo = false; if (context.repos.length === 1) { skippedStepTwo = true; @@ -359,7 +373,7 @@ export class BranchGitCommand extends QuickCommand { state.reference = result; } - if (state.counter < 4 || state.name == null) { + if (state.counter < 4 || state.name == null || state.suggestNameOnly) { const result = yield* inputBranchNameStep(state, context, { titleContext: ` from ${getReferenceLabel(state.reference, { capitalize: true, diff --git a/src/commands/quickWizard.ts b/src/commands/quickWizard.ts index 4ac108aa2574f..2e993fd17ec79 100644 --- a/src/commands/quickWizard.ts +++ b/src/commands/quickWizard.ts @@ -1,17 +1,18 @@ import { Commands } from '../constants.commands'; import type { Container } from '../container'; import type { LaunchpadCommandArgs } from '../plus/launchpad/launchpad'; +import type { 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; +export type QuickWizardCommandArgs = LaunchpadCommandArgs | StartWorkCommandArgs; @command() export class QuickWizardCommand extends QuickWizardCommandBase { constructor(container: Container) { - super(container, [Commands.ShowLaunchpad]); + super(container, [Commands.ShowLaunchpad, Commands.StartWork]); } protected override preExecute( @@ -22,6 +23,9 @@ export class QuickWizardCommand extends QuickWizardCommandBase { case Commands.ShowLaunchpad: return this.execute({ command: 'launchpad', ...args }); + case Commands.StartWork: + return this.execute({ command: 'startWork', ...args }); + default: return this.execute(args); } diff --git a/src/commands/quickWizard.utils.ts b/src/commands/quickWizard.utils.ts index 859369b03f96b..40a4b8f13b110 100644 --- a/src/commands/quickWizard.utils.ts +++ b/src/commands/quickWizard.utils.ts @@ -1,6 +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 { configuration } from '../system/vscode/configuration'; import { getContext } from '../system/vscode/context'; import { BranchGitCommand } from './git/branch'; @@ -112,6 +113,10 @@ export class QuickWizardRootStep implements QuickPickStep { if (args?.command === 'launchpad') { this.hiddenItems.push(new LaunchpadCommand(container, args)); } + + if (args?.command === 'startWork') { + this.hiddenItems.push(new StartWorkCommand(container)); + } } private _command: QuickCommand | undefined; diff --git a/src/constants.commands.ts b/src/constants.commands.ts index 64c1a51f417a1..e778ce93411d8 100644 --- a/src/constants.commands.ts +++ b/src/constants.commands.ts @@ -223,6 +223,7 @@ export const enum Commands { ShowTimelineView = 'gitlens.showTimelineView', ShowWorktreesView = 'gitlens.showWorktreesView', ShowWorkspacesView = 'gitlens.showWorkspacesView', + StartWork = 'gitlens.startWork', StashApply = 'gitlens.stashApply', StashSave = 'gitlens.stashSave', StashSaveFiles = 'gitlens.stashSaveFiles', diff --git a/src/constants.telemetry.ts b/src/constants.telemetry.ts index 1d9be449af62f..4f63a757c39e0 100644 --- a/src/constants.telemetry.ts +++ b/src/constants.telemetry.ts @@ -311,6 +311,17 @@ export type TelemetryEvents = { duration: number; }; + /** Sent when the user opens Start Work; use `instance` to correlate a StartWork "session" */ + 'startWork/open': StartWorkEventDataBase; + /** Sent when the launchpad is opened; use `instance` to correlate a StartWork "session" */ + 'startWork/opened': StartWorkEventData & { + connected: boolean; + }; + /** Sent when the Start Work has "reloaded" (while open, e.g. user refreshed or back button) and is disconnected; use `instance` to correlate a Start Work "session" */ + 'startWork/steps/connect': StartWorkEventData & { + connected: boolean; + }; + /** Sent when a PR review was started in the inspect overview */ openReviewMode: { provider: string; @@ -451,6 +462,14 @@ export type CommandEventData = webview?: string; }; +export type StartWorkTelemetryContext = StartWorkEventDataBase; + +type StartWorkEventDataBase = { + instance: number; +}; + +type StartWorkEventData = StartWorkEventDataBase; + export type LaunchpadTelemetryContext = LaunchpadEventData; type LaunchpadEventDataBase = { @@ -620,6 +639,7 @@ export type Sources = | 'quick-wizard' | 'remoteProvider' | 'settings' + | 'startWork' | 'timeline' | 'trial-indicator' | 'scm-input' diff --git a/src/git/models/__tests__/pullRequest.test.ts b/src/git/models/__tests__/pullRequest.utils.test.ts similarity index 99% rename from src/git/models/__tests__/pullRequest.test.ts rename to src/git/models/__tests__/pullRequest.utils.test.ts index a2da2e0606edb..adeaec4f240b8 100644 --- a/src/git/models/__tests__/pullRequest.test.ts +++ b/src/git/models/__tests__/pullRequest.utils.test.ts @@ -1,6 +1,6 @@ import * as assert from 'assert'; import { suite, test } from 'mocha'; -import { getPullRequestIdentityValuesFromSearch } from '../pullRequest'; +import { getPullRequestIdentityValuesFromSearch } from '../pullRequest.utils'; suite('Test GitHub PR URL parsing to identity: getPullRequestIdentityValuesFromSearch()', () => { function t(message: string, query: string, prNumber: string | undefined, ownerAndRepo?: string) { diff --git a/src/git/models/issue.ts b/src/git/models/issue.ts index a7ed52499d3f3..9359d98268187 100644 --- a/src/git/models/issue.ts +++ b/src/git/models/issue.ts @@ -1,6 +1,10 @@ -import { ColorThemeKind, ThemeColor, ThemeIcon, window } from 'vscode'; +import { ColorThemeKind, ThemeColor, ThemeIcon, Uri, window } from 'vscode'; +import { Schemes } from '../../constants'; import type { Colors } from '../../constants.colors'; +import type { Container } from '../../container'; +import type { RepositoryIdentityDescriptor } from '../../gk/models/repositoryIdentities'; import type { ProviderReference } from './remoteProvider'; +import type { Repository } from './repository'; export type IssueOrPullRequestType = 'issue' | 'pullrequest'; export type IssueOrPullRequestState = 'opened' | 'closed' | 'merged'; @@ -45,6 +49,7 @@ export interface IssueRepository { owner: string; repo: string; accessLevel?: RepositoryAccessLevel; + url?: string; } export interface IssueShape extends IssueOrPullRequest { @@ -52,6 +57,7 @@ export interface IssueShape extends IssueOrPullRequest { assignees: IssueMember[]; repository?: IssueRepository; labels?: IssueLabel[]; + body?: string; } export interface SearchedIssue { @@ -216,6 +222,7 @@ export function serializeIssue(value: IssueShape): IssueShape { : { owner: value.repository.owner, repo: value.repository.repo, + url: value.repository.url, }, assignees: value.assignees.map(assignee => ({ id: assignee.id, @@ -232,6 +239,7 @@ export function serializeIssue(value: IssueShape): IssueShape { })), commentsCount: value.commentsCount, thumbsUpCount: value.thumbsUpCount, + body: value.body, }; return serialized; } @@ -256,5 +264,70 @@ export class Issue implements IssueShape { public readonly labels?: IssueLabel[], public readonly commentsCount?: number, public readonly thumbsUpCount?: number, + public readonly body?: string, ) {} } + +export type IssueRepositoryIdentityDescriptor = RequireSomeWithProps< + RequireSome, 'provider'>, + 'provider', + 'id' | 'domain' | 'repoDomain' | 'repoName' +> & + RequireSomeWithProps, 'remote'>, 'remote', 'domain'>; + +export function getRepositoryIdentityForIssue(issue: IssueShape | Issue): IssueRepositoryIdentityDescriptor { + if (issue.repository == null) throw new Error('Missing repository'); + + return { + remote: { + url: issue.repository.url, + domain: issue.provider.domain, + }, + name: `${issue.repository.owner}/${issue.repository.repo}`, + provider: { + id: issue.provider.id, + domain: issue.provider.domain, + repoDomain: issue.repository.owner, + repoName: issue.repository.repo, + repoOwnerDomain: issue.repository.owner, + }, + }; +} + +export function getVirtualUriForIssue(issue: IssueShape | Issue): Uri | undefined { + if (issue.repository == null) throw new Error('Missing repository'); + if (issue.provider.id !== 'github') return undefined; + + const uri = Uri.parse(issue.repository.url ?? issue.url); + return uri.with({ scheme: Schemes.Virtual, authority: 'github', path: uri.path }); +} + +export async function getOrOpenIssueRepository( + container: Container, + issue: IssueShape | Issue, + options?: { promptIfNeeded?: boolean; skipVirtual?: boolean }, +): Promise { + const identity = getRepositoryIdentityForIssue(issue); + let repo = await container.repositoryIdentity.getRepository(identity, { + openIfNeeded: true, + keepOpen: false, + prompt: false, + }); + + if (repo == null && !options?.skipVirtual) { + const virtualUri = getVirtualUriForIssue(issue); + if (virtualUri != null) { + repo = await container.git.getOrOpenRepository(virtualUri, { closeOnOpen: true, detectNested: false }); + } + } + + if (repo == null && options?.promptIfNeeded) { + repo = await container.repositoryIdentity.getRepository(identity, { + openIfNeeded: true, + keepOpen: false, + prompt: true, + }); + } + + return repo; +} diff --git a/src/git/models/pullRequest.ts b/src/git/models/pullRequest.ts index 637f9eb14b52b..06caee34082fe 100644 --- a/src/git/models/pullRequest.ts +++ b/src/git/models/pullRequest.ts @@ -7,6 +7,7 @@ import { formatDate, fromNow } from '../../system/date'; import { memoize } from '../../system/decorators/memoize'; import type { LeftRightCommitCountResult } from '../gitProvider'; import type { IssueOrPullRequest, IssueRepository, IssueOrPullRequestState as PullRequestState } from './issue'; +import type { PullRequestURLIdentity } from './pullRequest.utils'; import { createRevisionRange, shortenRevision } from './reference'; import type { ProviderReference } from './remoteProvider'; import type { Repository } from './repository'; @@ -417,45 +418,6 @@ export async function getOpenedPullRequestRepo( return repo; } -export type PullRequestURLIdentity = { - ownerAndRepo?: string; - prNumber?: string; -}; - -export function getPullRequestIdentityValuesFromSearch(search: string): PullRequestURLIdentity { - let ownerAndRepo: string | undefined = undefined; - let prNumber: string | undefined = undefined; - - let match = search.match(/([^/]+\/[^/]+)\/pull\/(\d+)/); // with org and rep name - if (match != null) { - ownerAndRepo = match[1]; - prNumber = match[2]; - } - - if (prNumber == null) { - match = search.match(/(?:\/|^)pull\/(\d+)/); // without repo name - if (match != null) { - prNumber = match[1]; - } - } - - if (prNumber == null) { - match = search.match(/(?:\/)(\d+)/); // any number starting with "/" - if (match != null) { - prNumber = match[1]; - } - } - - if (prNumber == null) { - match = search.match(/^#?(\d+)$/); // just a number or with a leading "#" - if (match != null) { - prNumber = match[1]; - } - } - - return { ownerAndRepo: ownerAndRepo, prNumber: prNumber }; -} - export function doesPullRequestSatisfyRepositoryURLIdentity( pr: EnrichablePullRequest | undefined, { ownerAndRepo, prNumber }: PullRequestURLIdentity, diff --git a/src/git/models/pullRequest.utils.ts b/src/git/models/pullRequest.utils.ts new file mode 100644 index 0000000000000..0f6bf01d819b3 --- /dev/null +++ b/src/git/models/pullRequest.utils.ts @@ -0,0 +1,42 @@ +// pullRequest.ts pulls many dependencies through Container and some of them break the unit tests. +// To avoid this file has been created that can collect more simple functions which +// don't require Container and can be tested. + +export type PullRequestURLIdentity = { + ownerAndRepo?: string; + prNumber?: string; +}; + +export function getPullRequestIdentityValuesFromSearch(search: string): PullRequestURLIdentity { + let ownerAndRepo: string | undefined = undefined; + let prNumber: string | undefined = undefined; + + let match = search.match(/([^/]+\/[^/]+)\/pull\/(\d+)/); // with org and rep name + if (match != null) { + ownerAndRepo = match[1]; + prNumber = match[2]; + } + + if (prNumber == null) { + match = search.match(/(?:\/|^)pull\/(\d+)/); // without repo name + if (match != null) { + prNumber = match[1]; + } + } + + if (prNumber == null) { + match = search.match(/(?:\/)(\d+)/); // any number starting with "/" + if (match != null) { + prNumber = match[1]; + } + } + + if (prNumber == null) { + match = search.match(/^#?(\d+)$/); // just a number or with a leading "#" + if (match != null) { + prNumber = match[1]; + } + } + + return { ownerAndRepo: ownerAndRepo, prNumber: prNumber }; +} diff --git a/src/plus/integrations/integrationService.ts b/src/plus/integrations/integrationService.ts index 2f29ceeef016d..9afd51586bf4f 100644 --- a/src/plus/integrations/integrationService.ts +++ b/src/plus/integrations/integrationService.ts @@ -42,7 +42,7 @@ import type { SupportedIssueIntegrationIds, SupportedSelfHostedIntegrationIds, } from './integration'; -import { isSelfHostedIntegrationId } from './providers/models'; +import { isHostingIntegrationId, isSelfHostedIntegrationId } from './providers/models'; import type { ProvidersApi } from './providers/providersApi'; export interface ConnectionStateChangeEvent { @@ -554,19 +554,60 @@ export class IntegrationService implements Disposable { args: { 0: integrationIds => (integrationIds?.length ? integrationIds.join(',') : ''), 1: false }, }) async getMyIssues( - integrationIds?: HostingIntegrationId[], - cancellation?: CancellationToken, + integrationIds?: (SupportedHostingIntegrationIds | SupportedIssueIntegrationIds)[], + options?: { openRepositoriesOnly?: boolean; cancellation?: CancellationToken }, ): Promise { const integrations: Map = new Map(); - for (const integrationId of integrationIds?.length ? integrationIds : Object.values(HostingIntegrationId)) { + const hostingIntegrationIds = integrationIds?.filter( + id => id in HostingIntegrationId, + ) as SupportedHostingIntegrationIds[]; + const openRemotesByIntegrationId = new Map(); + for (const repository of this.container.git.openRepositories) { + const remotes = await repository.git.getRemotes(); + if (remotes.length === 0) continue; + for (const remote of remotes) { + const remoteIntegration = await remote.getIntegration(); + if (remoteIntegration == null) continue; + for (const integrationId of hostingIntegrationIds?.length + ? hostingIntegrationIds + : Object.values(HostingIntegrationId)) { + if ( + remoteIntegration.id === integrationId && + remote.provider?.owner != null && + remote.provider?.repoName != null + ) { + const descriptor = { + key: `${remote.provider.owner}/${remote.provider.repoName}`, + owner: remote.provider.owner, + name: remote.provider.repoName, + }; + if (openRemotesByIntegrationId.has(integrationId)) { + openRemotesByIntegrationId.get(integrationId)?.push(descriptor); + } else { + openRemotesByIntegrationId.set(integrationId, [descriptor]); + } + } + } + } + } + for (const integrationId of integrationIds?.length + ? integrationIds + : [...Object.values(HostingIntegrationId), ...Object.values(IssueIntegrationId)]) { const integration = await this.get(integrationId); if (integration == null) continue; - integrations.set(integration, undefined); + integrations.set( + integration, + options?.openRepositoriesOnly && + isHostingIntegrationId(integrationId) && + openRemotesByIntegrationId.has(integrationId) + ? openRemotesByIntegrationId.get(integrationId) + : undefined, + ); } if (integrations.size === 0) return undefined; - return this.getMyIssuesCore(integrations, cancellation); + return this.getMyIssuesCore(integrations, options?.cancellation); } private async getMyIssuesCore( diff --git a/src/plus/integrations/providers/github.ts b/src/plus/integrations/providers/github.ts index fcc556c28d1e6..4145d3617b4c2 100644 --- a/src/plus/integrations/providers/github.ts +++ b/src/plus/integrations/providers/github.ts @@ -201,6 +201,7 @@ abstract class GitHubIntegrationBase extends { repos: repos?.map(r => `${r.owner}/${r.name}`), baseUrl: this.apiBaseUrl, + includeBody: true, }, cancellation, ); diff --git a/src/plus/integrations/providers/github/github.ts b/src/plus/integrations/providers/github/github.ts index 50aa36f73b523..7e1d867a8bab4 100644 --- a/src/plus/integrations/providers/github/github.ts +++ b/src/plus/integrations/providers/github/github.ts @@ -190,6 +190,7 @@ repository { login } viewerPermission + url } `; @@ -2933,7 +2934,14 @@ export class GitHubApi implements Disposable { async searchMyIssues( provider: Provider, token: string, - options?: { search?: string; user?: string; repos?: string[]; baseUrl?: string; avatarSize?: number }, + options?: { + search?: string; + user?: string; + repos?: string[]; + baseUrl?: string; + avatarSize?: number; + includeBody?: boolean; + }, cancellation?: CancellationToken, ): Promise { const scope = getLogScope(); @@ -2950,6 +2958,14 @@ export class GitHubApi implements Disposable { }; } + const issueFragement = `${gqIssueFragment}${ + options?.includeBody + ? ` + body + ` + : '' + }`; + const query = `query searchMyIssues( $authored: String! $assigned: String! @@ -2959,21 +2975,21 @@ export class GitHubApi implements Disposable { authored: search(first: 100, query: $authored, type: ISSUE) { nodes { ... on Issue { - ${gqIssueFragment} + ${issueFragement} } } } assigned: search(first: 100, query: $assigned, type: ISSUE) { nodes { ... on Issue { - ${gqIssueFragment} + ${issueFragement} } } } mentioned: search(first: 100, query: $mentioned, type: ISSUE) { nodes { ... on Issue { - ${gqIssueFragment} + ${issueFragement} } } } diff --git a/src/plus/integrations/providers/github/models.ts b/src/plus/integrations/providers/github/models.ts index 3979832407619..47017a55c1f5f 100644 --- a/src/plus/integrations/providers/github/models.ts +++ b/src/plus/integrations/providers/github/models.ts @@ -133,7 +133,9 @@ export interface GitHubIssue extends Omit ({ id: assignee.login, @@ -436,6 +439,7 @@ export function fromGitHubIssue(value: GitHubIssue, provider: Provider): Issue { })), value.comments?.totalCount, value.reactions?.totalCount, + value.body, ); } diff --git a/src/plus/integrations/providers/models.ts b/src/plus/integrations/providers/models.ts index bd3ce2858201a..47160355aa4eb 100644 --- a/src/plus/integrations/providers/models.ts +++ b/src/plus/integrations/providers/models.ts @@ -90,6 +90,15 @@ export function isSelfHostedIntegrationId(id: IntegrationId): id is SelfHostedIn return selfHostedIntegrationIds.includes(id as SelfHostedIntegrationId); } +export function isHostingIntegrationId(id: IntegrationId): id is HostingIntegrationId { + return [ + HostingIntegrationId.GitHub, + HostingIntegrationId.GitLab, + HostingIntegrationId.Bitbucket, + HostingIntegrationId.AzureDevOps, + ].includes(id as HostingIntegrationId); +} + export enum PullRequestFilter { Author = 'author', Assignee = 'assignee', diff --git a/src/plus/launchpad/launchpad.ts b/src/plus/launchpad/launchpad.ts index f4751e7505aa7..25115f0079320 100644 --- a/src/plus/launchpad/launchpad.ts +++ b/src/plus/launchpad/launchpad.ts @@ -42,10 +42,8 @@ import { HostingIntegrationId, SelfHostedIntegrationId } from '../../constants.i import type { LaunchpadTelemetryContext, Source, Sources, TelemetryEvents } from '../../constants.telemetry'; import type { Container } from '../../container'; import { PlusFeatures } from '../../features'; -import { - doesPullRequestSatisfyRepositoryURLIdentity, - getPullRequestIdentityValuesFromSearch, -} from '../../git/models/pullRequest'; +import { doesPullRequestSatisfyRepositoryURLIdentity } from '../../git/models/pullRequest'; +import { getPullRequestIdentityValuesFromSearch } from '../../git/models/pullRequest.utils'; import type { QuickPickItemOfT } from '../../quickpicks/items/common'; import { createQuickPickItemOfT, createQuickPickSeparator } from '../../quickpicks/items/common'; import type { DirectiveQuickPickItem } from '../../quickpicks/items/directive'; diff --git a/src/plus/launchpad/launchpadProvider.ts b/src/plus/launchpad/launchpadProvider.ts index ca58018f53c96..cdabd88c22db3 100644 --- a/src/plus/launchpad/launchpadProvider.ts +++ b/src/plus/launchpad/launchpadProvider.ts @@ -19,9 +19,9 @@ import type { PullRequest, SearchedPullRequest } from '../../git/models/pullRequ import { getComparisonRefsForPullRequest, getOrOpenPullRequestRepository, - getPullRequestIdentityValuesFromSearch, getRepositoryIdentityForPullRequest, } from '../../git/models/pullRequest'; +import { getPullRequestIdentityValuesFromSearch } from '../../git/models/pullRequest.utils'; import type { GitRemote } from '../../git/models/remote'; import type { Repository } from '../../git/models/repository'; import type { CodeSuggestionCounts, Draft } from '../../gk/models/drafts'; diff --git a/src/plus/startWork/startWork.ts b/src/plus/startWork/startWork.ts new file mode 100644 index 0000000000000..3dd27b2a362c8 --- /dev/null +++ b/src/plus/startWork/startWork.ts @@ -0,0 +1,454 @@ +import slug from 'slug'; +import type { QuickPick } from 'vscode'; +import { Uri } from 'vscode'; +import type { + AsyncStepResultGenerator, + PartialStepState, + StepGenerator, + StepResultGenerator, + StepSelection, + StepState, +} from '../../commands/quickCommand'; +import { + canPickStepContinue, + createPickStep, + endSteps, + freezeStep, + QuickCommand, + StepResultBreak, +} from '../../commands/quickCommand'; +import { OpenOnGitHubQuickInputButton } from '../../commands/quickCommand.buttons'; +import { getSteps } from '../../commands/quickWizard.utils'; +import { proBadge } from '../../constants'; +import type { IntegrationId } from '../../constants.integrations'; +import { HostingIntegrationId, IssueIntegrationId } from '../../constants.integrations'; +import type { Source, Sources, StartWorkTelemetryContext } from '../../constants.telemetry'; +import type { Container } from '../../container'; +import type { Issue, IssueShape, SearchedIssue } from '../../git/models/issue'; +import { getOrOpenIssueRepository } from '../../git/models/issue'; +import type { Repository } from '../../git/models/repository'; +import type { QuickPickItemOfT } from '../../quickpicks/items/common'; +import { createQuickPickItemOfT } from '../../quickpicks/items/common'; +import type { DirectiveQuickPickItem } from '../../quickpicks/items/directive'; +import { createDirectiveQuickPickItem, Directive } from '../../quickpicks/items/directive'; +import { getScopedCounter } from '../../system/counter'; +import { fromNow } from '../../system/date'; +import { some } from '../../system/iterable'; +import { configuration } from '../../system/vscode/configuration'; +import { openUrl } from '../../system/vscode/utils'; + +export type StartWorkItem = { + item: SearchedIssue; +}; + +export type StartWorkResult = { items: StartWorkItem[] }; + +interface Context { + result: StartWorkResult; + title: string; + telemetryContext: StartWorkTelemetryContext | undefined; + connectedIntegrations: Map; +} + +interface State { + item?: StartWorkItem; + action?: StartWorkAction; + inWorktree?: boolean; +} + +export type StartWorkAction = 'start'; + +export interface StartWorkCommandArgs { + readonly command: 'startWork'; + source?: Sources; +} + +export const supportedStartWorkIntegrations = [HostingIntegrationId.GitHub, IssueIntegrationId.Jira]; +export type SupportedStartWorkIntegrationIds = (typeof supportedStartWorkIntegrations)[number]; +const instanceCounter = getScopedCounter(); + +export class StartWorkCommand 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', + }); + + 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.initialState = { + counter: 0, + }; + } + + protected async *steps(state: PartialStepState): StepGenerator { + if (this.container.git.isDiscoveringRepositories) { + await this.container.git.isDiscoveringRepositories; + } + + const context: Context = { + result: { items: [] }, + title: this.title, + telemetryContext: this.telemetryContext, + connectedIntegrations: await this.getConnectedIntegrations(), + }; + + const opened = false; + while (this.canStepsContinue(state)) { + context.title = this.title; + + if (state.counter < 1) { + const result = yield* this.selectCommandStep(state); + if (result === StepResultBreak) continue; + state.action = result.action; + state.inWorktree = result.inWorktree; + } + + if (state.counter < 2 && !state.action) { + const hasConnectedIntegrations = [...context.connectedIntegrations.values()].some(c => c); + if (!hasConnectedIntegrations) { + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent( + opened ? 'startWork/steps/connect' : 'startWork/opened', + { + ...context.telemetryContext!, + connected: false, + }, + this.source, + ); + } + const isUsingCloudIntegrations = configuration.get('cloudIntegrations.enabled', undefined, false); + const result = isUsingCloudIntegrations + ? yield* this.confirmCloudIntegrationsConnectStep(state, context) + : yield* this.confirmLocalIntegrationConnectStep(state, context); + if (result === StepResultBreak) { + return result; + } + } + + await updateContextItems(this.container, context); + const result = yield* this.pickIssueStep(state, context); + if (result === StepResultBreak) continue; + if (typeof result !== 'string') { + state.item = result; + state.action = 'start'; + } else { + state.action = result; + } + } + + const issue = state.item?.item?.issue; + const repo = issue && (await this.getIssueRepositoryIfExists(issue)); + + if (typeof state.action === 'string') { + switch (state.action) { + case 'start': { + 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, + flags: state.inWorktree ? ['--worktree'] : ['--switch'], + }, + confirm: false, + }, + this.pickedVia, + ); + if (result === StepResultBreak) { + endSteps(state); + } else { + state.counter--; + state.action = undefined; + } + break; + } + } + } + } + + return state.counter < 0 ? StepResultBreak : undefined; + } + + private *selectCommandStep( + state: StepState, + ): StepResultGenerator<{ action?: StartWorkAction; inWorktree?: boolean }> { + const step = createPickStep({ + placeholder: 'Start work by creating a new branch', + items: [ + createQuickPickItemOfT('Create a Branch', { + action: 'start', + }), + createQuickPickItemOfT('Create a Branch in a Worktree', { action: 'start', inWorktree: true }), + createQuickPickItemOfT('Create a Branch from an Issue', {}), + createQuickPickItemOfT('Create a Branch from an Issue in a Worktree', { inWorktree: true }), + ], + }); + const selection: StepSelection = yield step; + return canPickStepContinue(step, state, selection) ? selection[0].item : StepResultBreak; + } + + private async getIssueRepositoryIfExists(issue: IssueShape | Issue): Promise { + try { + return await getOrOpenIssueRepository(this.container, issue); + } catch { + return undefined; + } + } + + private async *confirmLocalIntegrationConnectStep( + state: StepState, + context: Context, + ): AsyncStepResultGenerator<{ connected: boolean | IntegrationId; resume: () => void }> { + const confirmations: (QuickPickItemOfT | DirectiveQuickPickItem)[] = []; + + for (const integration of supportedStartWorkIntegrations) { + if (context.connectedIntegrations.get(integration)) { + continue; + } + switch (integration) { + case HostingIntegrationId.GitHub: + confirmations.push( + createQuickPickItemOfT( + { + label: 'Connect to GitHub...', + detail: 'Will connect to GitHub to provide access your pull requests and issues', + }, + integration, + ), + ); + break; + default: + break; + } + } + + const step = this.createConfirmStep( + `${this.title} \u00a0\u2022\u00a0 Connect an Integration`, + confirmations, + createDirectiveQuickPickItem(Directive.Cancel, false, { label: 'Cancel' }), + { + placeholder: 'Connect an integration to view their issues in Start Work', + buttons: [], + ignoreFocusOut: false, + }, + ); + + // Note: This is a hack to allow the quickpick to stay alive after the user finishes connecting the integration. + // Otherwise it disappears. + let freeze!: () => Disposable; + step.onDidActivate = qp => { + freeze = () => freezeStep(step, qp); + }; + + const selection: StepSelection = yield step; + if (canPickStepContinue(step, state, selection)) { + const resume = freeze(); + const chosenIntegrationId = selection[0].item; + const connected = await this.ensureIntegrationConnected(chosenIntegrationId); + return { connected: connected ? chosenIntegrationId : false, resume: () => resume[Symbol.dispose]() }; + } + + return StepResultBreak; + } + + private async ensureIntegrationConnected(id: IntegrationId) { + const integration = await this.container.integrations.get(id); + let connected = integration.maybeConnected ?? (await integration.isConnected()); + if (!connected) { + connected = await integration.connect('startWork'); + } + + return connected; + } + + private async *confirmCloudIntegrationsConnectStep( + state: StepState, + context: Context, + ): AsyncStepResultGenerator<{ connected: boolean | IntegrationId; resume: () => void }> { + // TODO: This step is almost an exact copy of the similar one from launchpad.ts. Do we want to do anything about it? Maybe to move it to an util function with ability to parameterize labels? + const hasConnectedIntegration = some(context.connectedIntegrations.values(), c => c); + const step = this.createConfirmStep( + `${this.title} \u00a0\u2022\u00a0 Connect an ${hasConnectedIntegration ? 'Additional ' : ''}Integration`, + [ + createQuickPickItemOfT( + { + label: `Connect an ${hasConnectedIntegration ? 'Additional ' : ''}Integration...`, + detail: hasConnectedIntegration + ? 'Connect additional integrations to view their issues in Start Work' + : 'Connect an integration to accelerate your work', + picked: true, + }, + true, + ), + ], + createDirectiveQuickPickItem(Directive.Cancel, false, { label: 'Cancel' }), + { + placeholder: hasConnectedIntegration + ? 'Connect additional integrations to Start Work' + : 'Connect an integration to get started with Start Work', + buttons: [], + ignoreFocusOut: true, + }, + ); + + // Note: This is a hack to allow the quickpick to stay alive after the user finishes connecting the integration. + // Otherwise it disappears. + let freeze!: () => Disposable; + let quickpick!: QuickPick; + step.onDidActivate = qp => { + quickpick = qp; + freeze = () => freezeStep(step, qp); + }; + + const selection: StepSelection = yield step; + + if (canPickStepContinue(step, state, selection)) { + const previousPlaceholder = quickpick.placeholder; + quickpick.placeholder = 'Connecting integrations...'; + quickpick.ignoreFocusOut = true; + const resume = freeze(); + const connected = await this.container.integrations.connectCloudIntegrations( + { integrationIds: supportedStartWorkIntegrations }, + { + source: 'startWork', + }, + ); + quickpick.placeholder = previousPlaceholder; + return { connected: connected, resume: () => resume[Symbol.dispose]() }; + } + + return StepResultBreak; + } + + private *pickIssueStep( + state: StepState, + context: Context, + ): StepResultGenerator { + const buildIssueItem = (i: StartWorkItem) => { + const buttons = i.item.issue.url ? [OpenOnGitHubQuickInputButton] : []; + return { + label: + i.item.issue.title.length > 60 ? `${i.item.issue.title.substring(0, 60)}...` : i.item.issue.title, + // description: `${i.repoAndOwner}#${i.id}, by @${i.author}`, + description: `\u00a0 ${i.item.issue.repository?.owner ?? ''}/${i.item.issue.repository?.repo ?? ''}#${ + i.item.issue.id + } \u00a0`, + detail: ` ${fromNow(i.item.issue.updatedDate)} by @${i.item.issue.author.name}`, + iconPath: i.item.issue.author?.avatarUrl != null ? Uri.parse(i.item.issue.author.avatarUrl) : undefined, + item: i, + picked: i.item.issue.id === state.item?.item?.issue.id, + buttons: buttons, + }; + }; + + const getItems = (result: StartWorkResult) => { + const items: QuickPickItemOfT[] = []; + + if (result.items?.length) { + items.push(...result.items.map(buildIssueItem)); + } + + return items; + }; + + function getItemsAndPlaceholder(): { + placeholder: string; + items: QuickPickItemOfT[]; + } { + if (!context.result.items.length) { + return { + placeholder: 'No issues found. Start work anyway.', + items: [ + createQuickPickItemOfT( + state.inWorktree ? 'Create a branch on a worktree' : 'Create a branch', + 'start', + ), + ], + }; + } + + return { + placeholder: 'Choose an item to focus on', + items: getItems(context.result), + }; + } + + const { items, placeholder } = getItemsAndPlaceholder(); + + const step = createPickStep<(typeof items)[0]>({ + title: context.title, + placeholder: placeholder, + matchOnDescription: true, + matchOnDetail: true, + items: items, + onDidClickItemButton: (_quickpick, button, { item }) => { + if (button === OpenOnGitHubQuickInputButton && typeof item !== 'string') { + this.open(item); + return true; + } + return false; + }, + }); + + const selection: StepSelection = yield step; + if (!canPickStepContinue(step, state, selection)) { + return StepResultBreak; + } + const element = selection[0]; + return typeof element.item === 'string' ? element.item : { ...element.item }; + } + + private startWork(state: PartialStepState, item?: StartWorkItem) { + state.action = 'start'; + if (item != null) { + state.item = item; + } + } + + private open(item: StartWorkItem): void { + if (item.item.issue.url == null) return; + void openUrl(item.item.issue.url); + } + + private async getConnectedIntegrations(): Promise> { + const connected = new Map(); + await Promise.allSettled( + supportedStartWorkIntegrations.map(async integrationId => { + const integration = await this.container.integrations.get(integrationId); + const isConnected = integration.maybeConnected ?? (await integration.isConnected()); + const hasAccess = isConnected && (await integration.access()); + connected.set(integrationId, hasAccess); + }), + ); + + return connected; + } +} + +async function updateContextItems(container: Container, context: Context) { + const connectedIntegrationsMap = context.connectedIntegrations; + const connectedIntegrations = [...connectedIntegrationsMap.keys()].filter(integrationId => + Boolean(connectedIntegrationsMap.get(integrationId)), + ); + context.result = { + items: + (await container.integrations.getMyIssues(connectedIntegrations, { openRepositoriesOnly: true }))?.map( + i => ({ + item: i, + }), + ) ?? [], + }; +} diff --git a/src/webviews/apps/plus/home/components/launchpad.ts b/src/webviews/apps/plus/home/components/launchpad.ts index db9adcb535cc9..b51d64995c3fc 100644 --- a/src/webviews/apps/plus/home/components/launchpad.ts +++ b/src/webviews/apps/plus/home/components/launchpad.ts @@ -3,9 +3,9 @@ import { SignalWatcher } from '@lit-labs/signals'; import type { TemplateResult } from 'lit'; import { css, html, LitElement, nothing } from 'lit'; import { customElement, state } from 'lit/decorators.js'; -import type { BranchGitCommandArgs } from '../../../../../commands/git/branch'; import { Commands } from '../../../../../constants.commands'; import type { LaunchpadCommandArgs } from '../../../../../plus/launchpad/launchpad'; +import type { StartWorkCommandArgs } from '../../../../../plus/startWork/startWork'; import { createCommandLink } from '../../../../../system/commands'; import { pluralize } from '../../../../../system/string'; import type { GetLaunchpadSummaryResponse, State } from '../../../../home/protocol'; @@ -108,13 +108,7 @@ export class GlLaunchpad extends SignalWatcher(LitElement) { }); get startWorkCommand() { - return createCommandLink(Commands.GitCommandsBranch, { - state: { - subcommand: 'create', - }, - command: 'branch', - confirm: true, - }); + return createCommandLink(Commands.StartWork, { command: 'startWork' }); } override connectedCallback() {