diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index d1e4847917..0000000000 --- a/.eslintignore +++ /dev/null @@ -1,5 +0,0 @@ -build/* -dist/* -src/test/* -**/@types/* -webpack.config.js diff --git a/.eslintrc.base.json b/.eslintrc.base.json deleted file mode 100644 index c5229a7d53..0000000000 --- a/.eslintrc.base.json +++ /dev/null @@ -1,268 +0,0 @@ -{ - "env": { - "es6": true - }, - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "plugin:@typescript-eslint/recommended-requiring-type-checking", - "plugin:import/errors", - "plugin:import/warnings", - "plugin:import/typescript" - ], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": 2019, - "sourceType": "module", - "ecmaFeatures": { - "impliedStrict": true - } - }, - "plugins": ["import", "@typescript-eslint"], - "reportUnusedDisableDirectives": true, - "root": true, - "rules": { - "new-parens": "error", - "no-async-promise-executor": "off", - "no-console": "off", - "no-constant-condition": ["warn", { "checkLoops": false }], - "no-caller": "error", - "no-case-declarations": "off", // TODO@eamodio revisit - "no-debugger": "warn", - "no-dupe-class-members": "off", - "no-duplicate-imports": "error", - "no-else-return": "off", // TODO@eamodio revisit - "no-empty": "off", // TODO@eamodio revisit - //"no-empty": ["warn", { "allowEmptyCatch": true }], - "no-eval": "error", - "no-ex-assign": "warn", - "no-extend-native": "error", - "no-extra-bind": "error", - "no-extra-boolean-cast": "off", // TODO@eamodio revisit - "no-floating-decimal": "error", - "no-implicit-coercion": "off", - "no-implied-eval": "error", - // Turn off until fix for: https://github.com/typescript-eslint/typescript-eslint/issues/239 - "no-inner-declarations": "off", - "no-lone-blocks": "error", - "no-lonely-if": "off", - "no-loop-func": "error", - "no-multi-spaces": "off", - "no-prototype-builtins": "off", - "no-return-assign": "error", - "no-return-await": "off", // TODO@eamodio revisit - "no-self-compare": "error", - "no-sequences": "error", - "no-template-curly-in-string": "warn", - "no-throw-literal": "error", - "no-unneeded-ternary": "error", - "no-use-before-define": "off", - "no-useless-call": "error", - "no-useless-catch": "error", - "no-useless-computed-key": "error", - "no-useless-concat": "error", - "no-useless-escape": "off", - "no-useless-rename": "error", - "no-useless-return": "off", - "no-var": "error", - "no-with": "error", - "no-restricted-syntax": [ - "error", - { - "selector": "BinaryExpression[operator='in']", - "message": "Avoid using the 'in' operator for type checks." - } - ], - "object-shorthand": "off", - "one-var": "off", // TODO@eamodio revisit - // "one-var": ["error", "never"], - "prefer-arrow-callback": "off", // TODO@eamodio revisit - "prefer-const": "off", - "prefer-numeric-literals": "error", - "prefer-object-spread": "error", - "prefer-rest-params": "error", - "prefer-spread": "error", - "prefer-template": "off", // TODO@eamodio revisit - "quotes": ["error", "single", { "avoidEscape": true, "allowTemplateLiterals": true }], - // Turn off until fix for: https://github.com/eslint/eslint/issues/11899 - "require-atomic-updates": "off", - "semi": ["error", "always"], - "semi-style": ["error", "last"], - "sort-imports": [ - "error", - { - "ignoreCase": true, - "ignoreDeclarationSort": true, - "ignoreMemberSort": false, - "memberSyntaxSortOrder": ["none", "all", "multiple", "single"] - } - ], - "yoda": "error", - "import/export": "off", - "import/extensions": ["error", "never"], - "import/named": "off", - "import/namespace": "off", - "import/newline-after-import": "warn", - "import/no-cycle": "off", - "import/no-dynamic-require": "error", - "import/no-default-export": "off", // TODO@eamodio revisit - "import/no-duplicates": "error", - "import/no-self-import": "error", - "import/no-unresolved": ["warn", { "ignore": ["vscode", "ghpr", "git", "extensionApi", "@octokit/rest", "@octokit/types"] }], - "import/order": [ - "warn", - { - "groups": ["builtin", "external", "internal", ["parent", "sibling", "index"]], - "newlines-between": "ignore", - "alphabetize": { - "order": "asc", - "caseInsensitive": true - } - } - ], - "@typescript-eslint/await-thenable": "error", - "@typescript-eslint/ban-types": "off", // TODO@eamodio revisit - // "@typescript-eslint/ban-types": [ - // "error", - // { - // "extendDefaults": false, - // "types": { - // "String": { - // "message": "Use string instead", - // "fixWith": "string" - // }, - // "Boolean": { - // "message": "Use boolean instead", - // "fixWith": "boolean" - // }, - // "Number": { - // "message": "Use number instead", - // "fixWith": "number" - // }, - // "Symbol": { - // "message": "Use symbol instead", - // "fixWith": "symbol" - // }, - // "Function": { - // "message": "The `Function` type accepts any function-like value.\nIt provides no type safety when calling the function, which can be a common source of bugs.\nIt also accepts things like class declarations, which will throw at runtime as they will not be called with `new`.\nIf you are expecting the function to accept certain arguments, you should explicitly define the function shape." - // }, - // "Object": { - // "message": "The `Object` type actually means \"any non-nullish value\", so it is marginally better than `unknown`.\n- If you want a type meaning \"any object\", you probably want `Record` instead.\n- If you want a type meaning \"any value\", you probably want `unknown` instead." - // }, - // "{}": { - // "message": "`{}` actually means \"any non-nullish value\".\n- If you want a type meaning \"any object\", you probably want `object` or `Record` instead.\n- If you want a type meaning \"any value\", you probably want `unknown` instead.", - // "fixWith": "object" - // } - // // "object": { - // // "message": "The `object` type is currently hard to use ([see this issue](https://github.com/microsoft/TypeScript/issues/21732)).\nConsider using `Record` instead, as it allows you to more easily inspect and use the keys." - // // } - // } - // } - // ], - "@typescript-eslint/consistent-type-assertions": [ - "warn", - { - "assertionStyle": "as", - "objectLiteralTypeAssertions": "allow-as-parameter" - } - ], - "@typescript-eslint/explicit-function-return-type": "off", - "@typescript-eslint/explicit-member-accessibility": "off", - "@typescript-eslint/explicit-module-boundary-types": "off", // TODO@eamodio revisit - // "@typescript-eslint/naming-convention": [ - // "error", - // { - // "selector": "variable", - // "format": ["camelCase", "PascalCase"], - // "leadingUnderscore": "allow", - // "filter": { - // "regex": "^_$", - // "match": false - // } - // }, - // { - // "selector": "variableLike", - // "format": ["camelCase"], - // "leadingUnderscore": "allow", - // "filter": { - // "regex": "^_$", - // "match": false - // } - // }, - // { - // "selector": "memberLike", - // "modifiers": ["private"], - // "format": ["camelCase"], - // "leadingUnderscore": "allow" - // }, - // { - // "selector": "memberLike", - // "modifiers": ["private", "readonly"], - // "format": ["camelCase", "PascalCase"], - // "leadingUnderscore": "allow" - // }, - // { - // "selector": "memberLike", - // "modifiers": ["static", "readonly"], - // "format": ["camelCase", "PascalCase"] - // }, - // { - // "selector": "interface", - // "format": ["PascalCase"] - // // "custom": { - // // "regex": "^I[A-Z]", - // // "match": false - // // } - // } - // ], - "@typescript-eslint/no-empty-function": "off", - "@typescript-eslint/no-empty-interface": "error", - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-floating-promises": "off", // TODO@eamodio revisit - "@typescript-eslint/no-implied-eval": "error", - "@typescript-eslint/no-inferrable-types": "off", // TODO@eamodio revisit - // "@typescript-eslint/no-inferrable-types": ["warn", { "ignoreParameters": true, "ignoreProperties": true }], - "@typescript-eslint/no-misused-promises": ["error", { "checksConditionals": false, "checksVoidReturn": false }], - "@typescript-eslint/no-namespace": "off", - "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/no-parameter-properties": "off", - "@typescript-eslint/no-redundant-type-constituents": "off", - "@typescript-eslint/no-this-alias": "off", - "@typescript-eslint/no-unnecessary-condition": "off", - "@typescript-eslint/no-unnecessary-type-assertion": "off", // TODO@eamodio revisit - "@typescript-eslint/no-unsafe-argument": "off", - "@typescript-eslint/no-unsafe-assignment": "off", // TODO@eamodio revisit - "@typescript-eslint/no-unsafe-call": "off", // TODO@eamodio revisit - "@typescript-eslint/no-unsafe-enum-comparison": "off", - "@typescript-eslint/no-unsafe-member-access": "off", // TODO@eamodio revisit - "@typescript-eslint/no-unsafe-return": "off", // TODO@eamodio revisit - "@typescript-eslint/no-unused-expressions": ["warn", { "allowShortCircuit": true }], - "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], - // "@typescript-eslint/no-unused-vars": [ - // "warn", - // { - // "args": "after-used", - // "argsIgnorePattern": "^_", - // "ignoreRestSiblings": true, - // "varsIgnorePattern": "^_$" - // } - // ], - "@typescript-eslint/no-use-before-define": "off", - "@typescript-eslint/prefer-regexp-exec": "off", // TODO@eamodio revisit - "@typescript-eslint/prefer-nullish-coalescing": "off", - "@typescript-eslint/prefer-optional-chain": "off", - "@typescript-eslint/require-await": "off", // TODO@eamodio revisit - "@typescript-eslint/restrict-plus-operands": "error", - "@typescript-eslint/restrict-template-expressions": "off", // TODO@eamodio revisit - // "@typescript-eslint/restrict-template-expressions": [ - // "error", - // { "allowAny": true, "allowBoolean": true, "allowNumber": true, "allowNullish": true } - // ], - "@typescript-eslint/strict-boolean-expressions": "off", - // "@typescript-eslint/strict-boolean-expressions": [ - // "warn", - // { "allowNullableBoolean": true, "allowNullableNumber": true, "allowNullableString": true } - // ], - "@typescript-eslint/unbound-method": "off" // Too many bugs right now: https://github.com/typescript-eslint/typescript-eslint/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+unbound-method - } -} diff --git a/.eslintrc.browser.json b/.eslintrc.browser.json deleted file mode 100644 index bd2d1e8008..0000000000 --- a/.eslintrc.browser.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": [".eslintrc.base.json"], - "env": { - "worker": true - }, - "parserOptions": { - "project": "tsconfig.browser.json" - } -} diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index a914162418..0000000000 --- a/.eslintrc.js +++ /dev/null @@ -1,36 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -'use strict'; - -const RULES_DIR = require('eslint-plugin-rulesdir'); -RULES_DIR.RULES_DIR = './build/eslint-rules'; - -module.exports = { - extends: ['.eslintrc.base.json'], - env: { - browser: true, - node: true - }, - parserOptions: { - project: 'tsconfig.eslint.json' - }, - plugins: ['rulesdir'], - overrides: [ - { - files: ['webviews/**/*.ts', 'webviews/**/*.tsx'], - rules: { - 'rulesdir/public-methods-well-defined-types': 'error' - } - }, - { - files: ['**/*.ts', '**/*.tsx'], - rules: { - 'rulesdir/no-any-except-union-method-signature': 'error', - 'rulesdir/no-pr-in-user-strings': 'error' - } - } - ] -}; diff --git a/.eslintrc.node.json b/.eslintrc.node.json deleted file mode 100644 index 14e0614015..0000000000 --- a/.eslintrc.node.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": [".eslintrc.base.json"], - "env": { - "node": true - }, - "parserOptions": { - "project": "tsconfig.json" - } -} diff --git a/.eslintrc.webviews.json b/.eslintrc.webviews.json deleted file mode 100644 index ae8e5d7124..0000000000 --- a/.eslintrc.webviews.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": [".eslintrc.base.json"], - "env": { - "browser": true - }, - "parserOptions": { - "project": "tsconfig.webviews.json" - } -} diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7413725c41..0c48ce8f75 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -5,6 +5,7 @@ - **Strictness**: Some strictness is disabled in `tsconfig.base.json` (e.g., `strictNullChecks: false`), but new code should avoid unsafe patterns. - **Testing**: Place tests under `src/test`. Do not include test code in production files. - **Localization**: Use `%key%` syntax for strings that require localization. See `package.nls.json`. +- **Regular Expressions**: Use named capture groups for clarity when working with complex regex patterns. ## Extension-Specific Practices - **VS Code API**: Use the official VS Code API for all extension points. Register commands, views, and menus via `package.json`. diff --git a/.vscodeignore b/.vscodeignore index 3c9c0e53af..ec86cb30a1 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -29,8 +29,7 @@ azure-pipeline.* yarn.lock **/*.map **/*.svg -!**/output.svg -!**/pr_webview.svg +!**/git-pull-request_webview.svg **/*.ts *.vsix **/*.bak diff --git a/CHANGELOG.md b/CHANGELOG.md index bef448933a..ba42edc6e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,81 @@ # Changelog +## 0.122.0 + +### Changes + +- Auto-generated PR descriptions (via `githubPullRequests.pullRequestDescription`) will respect the repository PR template if there is one. +- Icons in the Pull Requests view now render with codicons instead of Unicode characters. +- Drafts in the Pull Requests view now render in italics instead of having a `[DRAFT]` prefix. + +![Pull Requests view showing codicon labels and italic draft PR titles](./documentation/changelog/0.122.0/pr-labels.png) + +- Emoji completions for `:smile:` style emojis are now available in review comments. + +![Emoji completions in review comments](./documentation/changelog/0.122.0/emoji-completions.gif) + +- [Markdown alert syntax](https://github.com/orgs/community/discussions/16925) is now rendered in review comments. + +![Markdown alerts in review comments](./documentation/changelog/0.122.0/markdown-alerts.png) + +- Opening an empty commit from a pull request webview shows an editor with a message instead of showing a notification. +- Pull requests can be opened from from a url, for example: `vscode-insiders://github.vscode-pull-request-github/checkout-pull-request?uri=https://github.com/microsoft/vscode-css-languageservice/pull/460` +- Icons are up-to-date with VS Code's latest icons. +- If you start a review and want to cancel it, there's now a "Cancel Review" button in the pull request webview. + +![Cancel review button](./documentation/changelog/0.122.0/cancel-review.png) + +### Fixes + +- Reactions to code comments are not showing up (Web). https://github.com/microsoft/vscode-pull-request-github/issues/2195 +- Editing a comment freezes VS Code. https://github.com/microsoft/vscode/issues/274455 +- Github Pull Request tab won't open if branch names are reused. https://github.com/microsoft/vscode-pull-request-github/issues/8007 +- Icons are misaligned. https://github.com/microsoft/vscode-pull-request-github/issues/7998 +- "Git is not installed or otherwise not available" even though it is. https://github.com/microsoft/vscode-pull-request-github/issues/5454 + +**_Thank You_** + +* [@bendrucker (Ben Drucker)](https://github.com/bendrucker): Enable all LLM tools in prompts (agent mode) [PR #6956](https://github.com/microsoft/vscode-pull-request-github/pull/6956) +* [@gerardbalaoro (Gerard Balaoro)](https://github.com/gerardbalaoro): Make branch list timeout configurable (#2840) [PR #7927](https://github.com/microsoft/vscode-pull-request-github/pull/7927) +* [@wankun-tcj](https://github.com/wankun-tcj): Fix avatar display issue in Pull Request tree view [PR #7851](https://github.com/microsoft/vscode-pull-request-github/pull/7851) + +## 0.120.2 + +### Fixes + +- Unable to open PR webview within VSCode. https://github.com/microsoft/vscode-pull-request-github/issues/8028 + +## 0.120.1 + +### Fixes + +- Extension cannot find git repo when VS Code didn't open the git root directory. https://github.com/microsoft/vscode-pull-request-github/issues/7964 + +## 0.120.0 + +### Changes + +- The `#openPullRequest` tool recognizes open PR diffs and PR files as being the "open pull request". +- All Copilot PR notifications can be marked as ready using the right-click context menu on the Copilot section header in the Pull Requests view. +- The setting `githubIssues.issueAvatarDisplay` can be used to control whether the first assignee's avatar or the author's avatar is shown in the Issues view. +- Instead of always running the pull request queries that back the Pull Requests view when refreshing, we now check to see if there are new PRs in the repo before running the queries. This should reduce API usage when there are no new PRs. +- The "Copy link" action is back near the PR title in the pull request description webview. +- You can configure that the default branch is pulled when you're "done" with a PR using `"githubPullRequests.postDone": "checkoutDefaultBranchAndPull"`. + +### Fixes + +- Unable to get list of users to assign them to a pull request. https://github.com/microsoft/vscode-pull-request-github/issues/7908 +- Error notifications when using GitHub Enterprise Server. https://github.com/microsoft/vscode-pull-request-github/issues/7901 +- Ignore worktrees that aren't in one of the workspace folders. https://github.com/microsoft/vscode-pull-request-github/issues/7896 +- Typing "#" and then Enter or Tab opens the GitHub issue queries settings. https://github.com/microsoft/vscode-pull-request-github/issues/7838 +- Unexpected branch switching when githubIssues.useBranchForIssues = off. https://github.com/microsoft/vscode-pull-request-github/issues/7827 +- Extension enters rapid refresh loop, causing high API usage and rate limiting. https://github.com/microsoft/vscode-pull-request-github/issues/7816 +- GitHub PR view highlights all repos with Copilot notification. https://github.com/microsoft/vscode-pull-request-github/issues/7852 +- Wrong commit is checked out when local branch exists with the same name. https://github.com/microsoft/vscode-pull-request-github/issues/7702 +- Visual Label not provided for "Title" and "Description" field. https://github.com/microsoft/vscode-pull-request-github/issues/7595 +- VSCode unresponsive during GitHub Pull Requests PR checkout (large number of files changed). https://github.com/microsoft/vscode-pull-request-github/issues/6952 +- extension explodes and kicks back out to GITHUB: LOGIN when non github repos are in working directory (specifically codeberg). https://github.com/microsoft/vscode-pull-request-github/issues/6945 + ## 0.118.2 ### Fixes diff --git a/azure-pipeline.nightly.yml b/azure-pipeline.nightly.yml index 6840ab5736..28d5448034 100644 --- a/azure-pipeline.nightly.yml +++ b/azure-pipeline.nightly.yml @@ -3,7 +3,7 @@ trigger: none pr: none schedules: - - cron: '0 4 * * Mon-Thu' + - cron: '0 4 * * Mon-Fri' displayName: Nightly Release Schedule always: true branches: @@ -31,6 +31,8 @@ extends: l10nSourcePaths: ./src + nodeVersion: "20.x" + buildSteps: - script: yarn install --frozen-lockfile --check-files displayName: Install dependencies diff --git a/azure-pipeline.pr.yml b/azure-pipeline.pr.yml index 3f86e93a24..029cfe80f3 100644 --- a/azure-pipeline.pr.yml +++ b/azure-pipeline.pr.yml @@ -2,7 +2,7 @@ jobs: - job: test_suite displayName: Test suite pool: - vmImage: 'macos-13' + vmImage: 'macos-15' steps: - template: scripts/ci/common-setup.yml diff --git a/azure-pipeline.release.yml b/azure-pipeline.release.yml index 8833bff0cb..1f8bb2c0d6 100644 --- a/azure-pipeline.release.yml +++ b/azure-pipeline.release.yml @@ -28,6 +28,8 @@ extends: l10nSourcePaths: ./src + nodeVersion: "20.x" + buildSteps: - script: yarn install --frozen-lockfile --check-files displayName: Install dependencies diff --git a/build/eslint-rules/index.js b/build/eslint-rules/index.js index 162d2780e0..85a144f2e8 100644 --- a/build/eslint-rules/index.js +++ b/build/eslint-rules/index.js @@ -5,8 +5,9 @@ 'use strict'; -module.exports = { +exports.rules = { 'public-methods-well-defined-types': require('./public-methods-well-defined-types'), 'no-any-except-union-method-signature': require('./no-any-except-union-method-signature'), 'no-pr-in-user-strings': require('./no-pr-in-user-strings'), + 'no-cast-to-any': require('./no-cast-to-any') }; \ No newline at end of file diff --git a/build/eslint-rules/no-cast-to-any.js b/build/eslint-rules/no-cast-to-any.js new file mode 100644 index 0000000000..9ffd303b7a --- /dev/null +++ b/build/eslint-rules/no-cast-to-any.js @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +module.exports = { + + create(context) { + return { + 'TSTypeAssertion[typeAnnotation.type="TSAnyKeyword"], TSAsExpression[typeAnnotation.type="TSAnyKeyword"]': (node) => { + context.report({ + node, + message: `Avoid casting to 'any' type. Consider using a more specific type or type guards for better type safety.` + }); + } + }; + } +}; \ No newline at end of file diff --git a/build/eslint-rules/public-methods-well-defined-types.js b/build/eslint-rules/public-methods-well-defined-types.js index d5d8cc5d35..8f1b18084e 100644 --- a/build/eslint-rules/public-methods-well-defined-types.js +++ b/build/eslint-rules/public-methods-well-defined-types.js @@ -59,9 +59,11 @@ module.exports = { // Type references with inline type arguments: Promise<{x: string}>, Array<{y: number}> case 'TSTypeReference': - // Check if any type arguments contain inline types - if (typeNode.typeParameters && typeNode.typeParameters.params) { - return typeNode.typeParameters.params.some(isInlineType); + // ESLint 9 / @typescript-eslint v8 may expose generic instantiations on `typeArguments` instead of `typeParameters`. + // Support both shapes defensively. + const typeArgs = typeNode.typeParameters || typeNode.typeArguments; + if (typeArgs && typeArgs.params) { + return typeArgs.params.some(isInlineType); } return false; diff --git a/build/filters.js b/build/filters.js index 7f8f92eaa1..1425e36c2f 100644 --- a/build/filters.js +++ b/build/filters.js @@ -64,6 +64,7 @@ module.exports.copyrightFilter = [ '!tsconfig.json', '!tsconfig.test.json', '!tsconfig.webviews.json', + '!tsconfig.scripts.json', '!tsfmt.json', '!**/queries*.gql', '!**/*.yml', diff --git a/build/update-codicons.ts b/build/update-codicons.ts new file mode 100644 index 0000000000..1419385c93 --- /dev/null +++ b/build/update-codicons.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as https from 'https'; + +const CODICONS_DIR = path.join(__dirname, '..', 'resources', 'icons', 'codicons'); +const BASE_URL = 'https://raw.githubusercontent.com/microsoft/vscode-codicons/refs/heads/mrleemurray/new-icons/src/icons'; + +interface UpdateResult { + filename: string; + status: 'updated' | 'unchanged' | 'error'; + error?: string; +} + +function readLocalIconFilenames(): string[] { + return fs.readdirSync(CODICONS_DIR).filter(f => f.endsWith('.svg')); +} + +function fetchRemoteIcon(filename: string): Promise { + const url = `${BASE_URL}/${encodeURIComponent(filename)}`; + return new Promise((resolve, reject) => { + https.get(url, res => { + const { statusCode } = res; + if (statusCode !== 200) { + res.resume(); // drain + return reject(new Error(`Failed to fetch ${filename}: HTTP ${statusCode}`)); + } + let data = ''; + res.setEncoding('utf8'); + res.on('data', chunk => { data += chunk; }); + res.on('end', () => resolve(data)); + }).on('error', reject); + }); +} + +async function updateIcon(filename: string): Promise { + const localPath = path.join(CODICONS_DIR, filename); + const oldContent = fs.readFileSync(localPath, 'utf8'); + try { + const newContent = await fetchRemoteIcon(filename); + if (normalize(oldContent) === normalize(newContent)) { + return { filename, status: 'unchanged' }; + } + fs.writeFileSync(localPath, newContent, 'utf8'); + return { filename, status: 'updated' }; + } catch (err: any) { + return { filename, status: 'error', error: err?.message ?? String(err) }; + } +} + +function normalize(svg: string): string { + return svg.replace(/\r\n?/g, '\n').trim(); +} + +async function main(): Promise { + const icons = readLocalIconFilenames(); + if (!icons.length) { + console.log('No codicon SVGs found to update.'); + return; + } + console.log(`Updating ${icons.length} codicon(s) from upstream...`); + + const concurrency = 8; + const queue = icons.slice(); + const results: UpdateResult[] = []; + + async function worker(): Promise { + while (queue.length) { + const file = queue.shift(); + if (!file) { + break; + } + const result = await updateIcon(file); + results.push(result); + if (result.status === 'updated') { + console.log(` ✔ ${file} updated`); + } else if (result.status === 'unchanged') { + console.log(` • ${file} unchanged`); + } else { + // allow-any-unicode-next-line + console.warn(` ✖ ${file} ${result.error}`); + } + } + } + + const workers = Array.from({ length: Math.min(concurrency, icons.length) }, () => worker()); + await Promise.all(workers); + + const updated = results.filter(r => r.status === 'updated').length; + const unchanged = results.filter(r => r.status === 'unchanged').length; + const errored = results.filter(r => r.status === 'error').length; + console.log(`Done. Updated: ${updated}, Unchanged: ${unchanged}, Errors: ${errored}.`); + if (errored) { + process.exitCode = 1; + } +} + +main().catch(err => { + console.error(err?.stack || err?.message || String(err)); + process.exit(1); +}); + +export { }; // ensure this file is treated as a module diff --git a/common/sessionParsing.ts b/common/sessionParsing.ts index d413fe32de..0ba92071da 100644 --- a/common/sessionParsing.ts +++ b/common/sessionParsing.ts @@ -320,8 +320,10 @@ export function parseSessionLogs(rawText: string): SessionResponseLogChunk[] { const parts = rawText .split(/\r?\n/) .filter(part => part.startsWith('data: ')) - .map(part => part.slice('data: '.length).trim()) - .map(part => JSON.parse(part)); + .map(part => { + const trimmed = part.slice('data: '.length).trim(); + return JSON.parse(trimmed); + }); return parts as SessionResponseLogChunk[]; } diff --git a/common/views.ts b/common/views.ts index 68dda2a5a7..707db9097b 100644 --- a/common/views.ts +++ b/common/views.ts @@ -3,10 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { RemoteInfo } from './types'; import { ClosedEvent, CommentEvent } from '../src/common/timelineEvent'; import { GithubItemStateEnum, IAccount, ILabel, IMilestone, IProject, ITeam, MergeMethod, MergeMethodsAvailability } from '../src/github/interface'; import { DisplayLabel, PreReviewState } from '../src/github/views'; -import { RemoteInfo } from './types'; export interface CreateParams { availableBaseRemotes: RemoteInfo[]; diff --git a/documentation/IssueFeatures.md b/documentation/IssueFeatures.md index 5cd6efef5f..72abd5f90e 100644 --- a/documentation/IssueFeatures.md +++ b/documentation/IssueFeatures.md @@ -1,8 +1,15 @@ We've added some experimental GitHub issue features. -# Code actions +# Code actions and CodeLens -Wherever there is a `TODO` comment in your code, the **Create Issue from Comment** code action will show. This takes your text selection, and creates a GitHub issue with the selection as a permalink in the issue body. It also inserts the issue number after the `TODO`. +Wherever there is a `TODO` comment in your code, two actions are available: + +1. **CodeLens**: Clickable actions appear directly above the TODO comment line for quick access +2. **Code actions**: The same actions are available via the lightbulb quick fix menu + +Both provide two options: +- **Create Issue from Comment**: Takes your text selection and creates a GitHub issue with the selection as a permalink in the issue body. It also inserts the issue number after the `TODO`. +- **Delegate to coding agent**: Starts a Copilot coding agent session to work on the TODO task (when available) ![Create Issue from Comment](images/createIssueFromComment.gif) diff --git a/documentation/changelog/0.122.0/cancel-review.png b/documentation/changelog/0.122.0/cancel-review.png new file mode 100644 index 0000000000..ac3473fe06 Binary files /dev/null and b/documentation/changelog/0.122.0/cancel-review.png differ diff --git a/documentation/changelog/0.122.0/emoji-completions.gif b/documentation/changelog/0.122.0/emoji-completions.gif new file mode 100644 index 0000000000..e38f3323e6 Binary files /dev/null and b/documentation/changelog/0.122.0/emoji-completions.gif differ diff --git a/documentation/changelog/0.122.0/markdown-alerts.png b/documentation/changelog/0.122.0/markdown-alerts.png new file mode 100644 index 0000000000..c222af3ad2 Binary files /dev/null and b/documentation/changelog/0.122.0/markdown-alerts.png differ diff --git a/documentation/changelog/0.122.0/pr-labels.png b/documentation/changelog/0.122.0/pr-labels.png new file mode 100644 index 0000000000..dbb3355397 Binary files /dev/null and b/documentation/changelog/0.122.0/pr-labels.png differ diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000000..2f509573d0 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,284 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +import js from '@eslint/js'; +import tsparser from '@typescript-eslint/parser'; +import * as importPlugin from 'eslint-plugin-import'; +import { defineConfig } from 'eslint/config'; +import rulesdir from './build/eslint-rules/index.js'; +import tseslint from 'typescript-eslint'; +import globals from 'globals'; + +export default defineConfig([ + // Global ignore patterns + { + ignores: [ + 'build', + 'dist/**/*', + 'out/**/*', + 'src/@types/**/*.d.ts', + 'src/api/api*.d.ts', + 'src/test/**', + '**/*.{js,mjs,cjs}', + '.vscode-test/**/*' + ] + }, + + // Base configuration for all TypeScript files + { + files: ['**/*.{ts,tsx,mts,cts}'], + languageOptions: { + parser: tsparser, + parserOptions: { + ecmaVersion: 2019, + sourceType: 'module', + project: 'tsconfig.base.json' + }, + }, + plugins: { + 'import': /** @type {any} */(importPlugin), + 'rulesdir': /** @type {any} */(rulesdir), + '@typescript-eslint': tseslint.plugin, + }, + settings: { + // Let plugin-import resolve TS paths (including d.ts, type packages, etc.) + 'import/resolver': { + typescript: { + project: [ + 'tsconfig.base.json', + 'tsconfig.json', + 'tsconfig.webviews.json' + ], + alwaysTryTypes: true + }, + node: { + extensions: ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.d.ts'] + } + }, + // For rules like import/extensions (list everything you consider "module" extensions) + 'import/extensions': ['.js', '.mjs', '.cjs', '.ts', '.tsx'] + }, + rules: { + // ESLint recommended rules + ...js.configs.recommended.rules, + + // Custom rules + 'new-parens': 'error', + 'no-async-promise-executor': 'off', + 'no-console': 'off', + 'no-constant-condition': ['warn', { 'checkLoops': false }], + 'no-caller': 'error', + 'no-case-declarations': 'off', // TODO@alexr00 revisit + 'no-debugger': 'warn', + 'no-dupe-class-members': 'off', + 'no-duplicate-imports': 'error', + 'no-else-return': 'off', // TODO@alexr00 revisit + 'no-empty': 'off', // TODO@alexr00 revisit + 'no-eval': 'error', + 'no-ex-assign': 'warn', + 'no-extend-native': 'error', + 'no-extra-bind': 'error', + 'no-extra-boolean-cast': 'off', // TODO@alexr00 revisit + 'no-floating-decimal': 'error', + 'no-implicit-coercion': 'off', + 'no-implied-eval': 'error', + 'no-inner-declarations': 'off', + 'no-lone-blocks': 'error', + 'no-lonely-if': 'off', + 'no-loop-func': 'error', + 'no-multi-spaces': 'off', + 'no-prototype-builtins': 'off', + 'no-return-assign': 'error', + 'no-return-await': 'off', // TODO@alexr00 revisit + 'no-self-compare': 'error', + 'no-sequences': 'error', + 'no-template-curly-in-string': 'warn', + 'no-throw-literal': 'error', + 'no-undef': 'off', + 'no-unneeded-ternary': 'error', + 'no-use-before-define': 'off', + 'no-useless-call': 'error', + 'no-useless-catch': 'error', + 'no-useless-computed-key': 'error', + 'no-useless-concat': 'error', + 'no-useless-escape': 'off', + 'no-useless-rename': 'error', + 'no-useless-return': 'off', + 'no-var': 'error', + 'no-with': 'error', + 'no-redeclare': 'off', + 'no-restricted-syntax': [ + 'error', + { + 'selector': 'BinaryExpression[operator=\'in\']', + 'message': 'Avoid using the \'in\' operator for type checks.' + } + ], + 'no-unused-vars': "off", // Disable the base rule so we can use the TS version + 'object-shorthand': 'off', + 'one-var': 'off', // TODO@alexr00 revisit + 'prefer-arrow-callback': 'off', // TODO@alexr00 revisit + 'prefer-const': 'off', + 'prefer-numeric-literals': 'error', + 'prefer-object-spread': 'error', + 'prefer-rest-params': 'error', + 'prefer-spread': 'error', + 'prefer-template': 'off', // TODO@alexr00 revisit + 'quotes': ['error', 'single', { 'avoidEscape': true, 'allowTemplateLiterals': true }], + 'require-atomic-updates': 'off', + 'semi': ['error', 'always'], + 'semi-style': ['error', 'last'], + 'yoda': 'error', + 'sort-imports': [ + 'error', + { + 'ignoreCase': true, + 'ignoreDeclarationSort': true, + 'ignoreMemberSort': false, + 'memberSyntaxSortOrder': ['none', 'all', 'multiple', 'single'] + } + ], + + // Import plugin rules + 'import/export': 'off', + 'import/extensions': ['error', 'ignorePackages', { + js: 'never', + mjs: 'never', + cjs: 'never', + ts: 'never', + tsx: 'never' + }], + 'import/named': 'off', + 'import/namespace': 'off', + 'import/newline-after-import': 'warn', + 'import/no-cycle': 'off', + 'import/no-dynamic-require': 'error', + 'import/no-default-export': 'off', // TODO@alexr00 revisit + 'import/no-duplicates': 'error', + 'import/no-self-import': 'error', + 'import/no-unresolved': ['warn', { 'ignore': ['vscode', 'ghpr', 'git', 'extensionApi', '@octokit/rest', '@octokit/types'] }], + 'import/order': [ + 'warn', + { + 'groups': ['builtin', 'external', 'internal', ['parent', 'sibling', 'index']], + 'newlines-between': 'ignore', + 'alphabetize': { + 'order': 'asc', + 'caseInsensitive': true + } + } + ], + + // TypeScript ESLint rules + '@typescript-eslint/await-thenable': 'error', + '@typescript-eslint/ban-types': 'off', // TODO@alexr00 revisit + + '@typescript-eslint/consistent-type-assertions': [ + 'warn', + { + 'assertionStyle': 'as', + 'objectLiteralTypeAssertions': 'allow-as-parameter' + } + ], + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-member-accessibility': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', // TODO@alexr00 revisit + + '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/no-empty-interface': 'error', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-floating-promises': 'off', // TODO@alexr00 revisit + '@typescript-eslint/no-implied-eval': 'error', + '@typescript-eslint/no-inferrable-types': 'off', // TODO@alexr00 revisit + '@typescript-eslint/no-misused-promises': ['error', { 'checksConditionals': false, 'checksVoidReturn': false }], + '@typescript-eslint/no-namespace': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + "@typescript-eslint/no-redeclare": ["error", { "ignoreDeclarationMerge": true }], + '@typescript-eslint/no-redundant-type-constituents': 'off', + '@typescript-eslint/no-this-alias': 'off', + '@typescript-eslint/no-unnecessary-condition': 'off', + '@typescript-eslint/no-unnecessary-type-assertion': 'off', // TODO@alexr00 revisit + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', // TODO@alexr00 revisit + '@typescript-eslint/no-unsafe-call': 'off', // TODO@alexr00 revisit + '@typescript-eslint/no-unsafe-enum-comparison': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', // TODO@alexr00 revisit + '@typescript-eslint/no-unsafe-return': 'off', // TODO@alexr00 revisit + '@typescript-eslint/no-unused-expressions': ['warn', { 'allowShortCircuit': true }], + '@typescript-eslint/no-unused-vars': ['error', { 'argsIgnorePattern': '^_', caughtErrors: 'none' }], + '@typescript-eslint/no-use-before-define': 'off', + '@typescript-eslint/prefer-regexp-exec': 'off', // TODO@alexr00 revisit + '@typescript-eslint/prefer-nullish-coalescing': 'off', + '@typescript-eslint/prefer-optional-chain': 'off', + '@typescript-eslint/require-await': 'off', // TODO@alexr00 revisit + '@typescript-eslint/restrict-plus-operands': 'error', + '@typescript-eslint/restrict-template-expressions': 'off', // TODO@alexr00 revisit + '@typescript-eslint/strict-boolean-expressions': 'off', + '@typescript-eslint/unbound-method': 'off', + + // Custom rules + 'rulesdir/no-any-except-union-method-signature': 'error', + 'rulesdir/no-pr-in-user-strings': 'error', + 'rulesdir/no-cast-to-any': 'error', + } + }, + + // Node.js environment specific config (exclude browser-specific files) + { + files: ['src/**/*.ts', '!src/env/browser/**/*'], + languageOptions: { + parser: tsparser, + parserOptions: { + ecmaVersion: 2019, + sourceType: 'module', + project: 'tsconfig.json' + }, + globals: { + ...globals.node, + ...globals.mocha, + 'RequestInit': true, + 'NodeJS': true, + 'Thenable': true, + }, + }, + }, + + // Browser environment specific config + { + files: ['src/env/browser/**/*.ts'], + languageOptions: { + parser: tsparser, + parserOptions: { + ecmaVersion: 2019, + sourceType: 'module', + project: 'tsconfig.json' + }, + globals: { + ...globals.browser, + 'Thenable': true, + }, + } + }, + + // Webviews + { + files: ['webviews/**/*.{ts,tsx}'], + languageOptions: { + parser: tsparser, + parserOptions: { + ecmaVersion: 2019, + sourceType: 'module', + project: 'tsconfig.webviews.json' + }, + globals: { + ...globals.browser, + 'JSX': true, + }, + }, + rules: { + 'rulesdir/public-methods-well-defined-types': 'error' + }, + }, +]); \ No newline at end of file diff --git a/package.json b/package.json index 7595953681..8304d17063 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "activeComment", "chatParticipantAdditions", "chatParticipantPrivate", - "chatSessionsProvider@2", + "chatSessionsProvider@3", "codiconDecoration", "codeActionRanges", "commentingRangeHint", @@ -29,18 +29,19 @@ "contribEditorContentMenu", "contribShareMenu", "diffCommand", - "languageModelDataPart", "languageModelToolResultAudience", + "markdownAlertSyntax", "quickDiffProvider", "remoteCodingAgents", "shareProvider", "tokenInformation", + "treeItemMarkdownLabel", "treeViewMarkdownMessage" ], - "version": "0.118.0", + "version": "0.120.0", "publisher": "GitHub", "engines": { - "vscode": "^1.105.0" + "vscode": "^1.106.0" }, "categories": [ "Other", @@ -76,7 +77,7 @@ "name": "copilot", "displayName": "GitHub Copilot coding agent", "description": "Delegate tasks to the GitHub Copilot coding agent. The agent works asynchronously to implement changes, iterates via chat, and can create or update pull requests as needed.", - "when": "config.chat.agentSessionsViewLocation && config.chat.agentSessionsViewLocation != 'disabled'", + "when": "config.chat.agentSessionsViewLocation && config.chat.agentSessionsViewLocation != 'disabled' && config.github.copilot.chat.advanced.copilotCodingAgentV0.enabled", "capabilities": { "supportsFileAttachments": true } @@ -89,7 +90,7 @@ "displayName": "GitHub Copilot coding agent", "description": "Copilot coding agent is a remote, autonomous software development agent. Developers delegate tasks to the agent, which iterates on pull requests based on feedback and reviews.", "followUpRegex": "open-pull-request-webview.*((%7B.*?%7D)|(\\{.*?\\}))", - "when": "config.githubPullRequests.codingAgent.enabled && config.githubPullRequests.codingAgent.uiIntegration && copilotCodingAgentAssignable" + "when": "config.githubPullRequests.codingAgent.enabled && config.githubPullRequests.codingAgent.uiIntegration && copilotCodingAgentAssignable && config.github.copilot.chat.advanced.copilotCodingAgentV0.enabled" } ], "chatParticipants": [ @@ -175,6 +176,12 @@ "description": "%githubPullRequests.logLevel.description%", "markdownDeprecationMessage": "%githubPullRequests.logLevel.markdownDeprecationMessage%" }, + "githubPullRequests.branchListTimeout": { + "type": "number", + "default": 5000, + "minimum": 1000, + "markdownDescription": "%githubPullRequests.branchListTimeout.description%" + }, "githubPullRequests.remotes": { "type": "array", "default": [ @@ -462,6 +469,11 @@ "%githubPullRequests.postDone.checkoutDefaultBranchAndPull%" ] }, + "githubPullRequests.autoStashOnCheckout": { + "type": "boolean", + "description": "%githubPullRequests.autoStashOnCheckout.description%", + "default": false + }, "githubPullRequests.defaultCommentType": { "type": "string", "enum": [ @@ -603,13 +615,17 @@ "default": [ "TODO", "todo", - "BUG", "FIXME", "ISSUE", "HACK" ], "description": "%githubIssues.createIssueTriggers.description%" }, + "githubPullRequests.codingAgent.codeLens": { + "type": "boolean", + "default": true, + "description": "%githubPullRequests.codingAgent.codeLens.description%" + }, "githubIssues.createInsertFormat": { "type": "string", "enum": [ @@ -956,7 +972,7 @@ }, { "command": "pr.markAllCopilotNotificationsAsRead", - "title": "Mark All as Read", + "title": "%command.pr.markAllCopilotNotificationsAsRead.title%", "category": "%command.pull.request.category%", "enablement": "viewItem == copilot-query-with-notifications" }, @@ -1783,11 +1799,6 @@ "category": "%command.notifications.category%", "icon": "$(gear)" }, - { - "command": "codingAgent.openSessionLog", - "title": "%command.codingAgent.openSessionLog.title%", - "category": "%command.pull.request.category%" - }, { "command": "pr.refreshChatSessions", "title": "%command.pr.refreshChatSessions.title%", @@ -1830,9 +1841,14 @@ }, { "view": "pr:github", - "when": "gitNotInstalled", + "when": "gitNotInstalled && config.git.enabled != false", "contents": "%welcome.github.noGit.contents%" }, + { + "view": "pr:github", + "when": "gitNotInstalled && config.git.enabled == false", + "contents": "%welcome.github.noGitDisabled.contents%" + }, { "view": "github:login", "when": "ReposManagerStateContext == NeedsAuthentication && !github:hasGitHubRemotes && gitOpenRepositoryCount", @@ -2607,6 +2623,14 @@ { "command": "pr.preferredCodingAgentGitHubRemote", "when": "false" + }, + { + "command": "pr.closeChatSessionPullRequest", + "when": "false" + }, + { + "command": "pr.cancelCodingAgent", + "when": "false" } ], "view/title": [ @@ -3127,6 +3151,13 @@ "group": "1_modification@5" } ], + "scm/repository": [ + { + "command": "pr.create", + "when": "scmProvider =~ /^git|^remoteHub:github/ && scmProviderRootUri in github:reposNotInReviewMode", + "group": "inline" + } + ], "comments/commentThread/context": [ { "command": "pr.createComment", @@ -3515,12 +3546,12 @@ "chat/chatSessions": [ { "command": "pr.openChanges", - "when": "chatSessionType == copilot-swe-agent", + "when": "chatSessionType == copilot-swe-agent || chatSessionType == copilot-cloud-agent", "group": "inline" }, { "command": "pr.checkoutChatSessionPullRequest", - "when": "chatSessionType == copilot-swe-agent", + "when": "chatSessionType == copilot-swe-agent || chatSessionType == copilot-cloud-agent", "group": "context" }, { @@ -3536,12 +3567,10 @@ ], "chat/multiDiff/context": [ { - "command": "pr.checkoutFromDescription", - "when": "chatSessionType == copilot-swe-agent" + "command": "pr.checkoutFromDescription" }, { - "command": "pr.applyChangesFromDescription", - "when": "chatSessionType == copilot-swe-agent" + "command": "pr.applyChangesFromDescription" } ] }, @@ -3681,7 +3710,7 @@ "displayName": "%languageModelTools.github-pull-request_issue_fetch.displayName%", "modelDescription": "Get a GitHub issue/PR's details as a JSON object.", "icon": "$(info)", - "canBeReferencedInPrompt": false, + "canBeReferencedInPrompt": true, "inputSchema": { "type": "object", "properties": { @@ -3937,7 +3966,7 @@ "displayName": "%languageModelTools.github-pull-request_suggest-fix.displayName%", "modelDescription": "Summarize and suggest a fix for a GitHub issue.", "icon": "$(info)", - "canBeReferencedInPrompt": false, + "canBeReferencedInPrompt": true, "inputSchema": { "type": "object", "properties": { @@ -3984,7 +4013,7 @@ "displayName": "%languageModelTools.github-pull-request_formSearchQuery.displayName%", "modelDescription": "Converts natural language to a GitHub search query. Should ALWAYS be called before doing a search.", "icon": "$(search)", - "canBeReferencedInPrompt": false, + "canBeReferencedInPrompt": true, "inputSchema": { "type": "object", "properties": { @@ -4028,7 +4057,7 @@ "displayName": "%languageModelTools.github-pull-request_doSearch.displayName%", "modelDescription": "Execute a GitHub search given a well formed GitHub search query. Call github-pull-request_formSearchQuery first to get good search syntax and pass the exact result in as the 'query'.", "icon": "$(search)", - "canBeReferencedInPrompt": false, + "canBeReferencedInPrompt": true, "inputSchema": { "type": "object", "properties": { @@ -4074,7 +4103,7 @@ "displayName": "%languageModelTools.github-pull-request_renderIssues.displayName%", "modelDescription": "Render issue items from an issue search in a markdown table. The markdown table will be displayed directly to the user by the tool. No further display should be done after this!", "icon": "$(paintcan)", - "canBeReferencedInPrompt": false, + "canBeReferencedInPrompt": true, "inputSchema": { "type": "object", "properties": { @@ -4164,18 +4193,18 @@ "type": "number", "description": "The number of comments on the issue." } - } - }, - "required": [ - "title", - "number", - "url", - "state", - "createdAt", - "author", - "commentCount", - "reactionCount" - ] + }, + "required": [ + "title", + "number", + "url", + "state", + "createdAt", + "author", + "commentCount", + "reactionCount" + ] + } }, "totalIssues": { "type": "number", @@ -4230,8 +4259,7 @@ "watch:test": "tsc -w -p tsconfig.test.json", "compile:node": "webpack --mode development --config-name extension:node --config-name webviews", "compile:web": "webpack --mode development --config-name extension:webworker --config-name webviews", - "lint": "eslint --fix --cache --config .eslintrc.js --ignore-pattern src/env/browser/**/* \"{src,webviews}/**/*.{ts,tsx}\"", - "lint:browser": "eslint --fix --cache --cache-location .eslintcache.browser --config .eslintrc.browser.json --ignore-pattern src/env/node/**/* \"{src,webviews}/**/*.{ts,tsx}\"", + "lint": "eslint --fix --cache . --ext .ts,.tsx", "package": "npx vsce package --yarn", "test": "yarn run test:preprocess && node ./out/src/test/runTests.js", "test:preprocess": "yarn run compile:test && yarn run test:preprocess-gql && yarn run test:preprocess-svg && yarn run test:preprocess-fixtures", @@ -4244,9 +4272,11 @@ "watch": "webpack --watch --mode development --env esbuild", "watch:web": "webpack --watch --mode development --config-name extension:webworker --config-name webviews", "hygiene": "node ./build/hygiene.js", - "prepare": "husky install" + "prepare": "husky install", + "update:codicons": "npx ts-node --project tsconfig.scripts.json build/update-codicons.ts" }, "devDependencies": { + "@eslint/js": "^9.36.0", "@shikijs/monaco": "^3.7.0", "@types/chai": "^4.1.4", "@types/glob": "7.1.3", @@ -4260,8 +4290,8 @@ "@types/temp": "0.8.34", "@types/vscode": "1.103.0", "@types/webpack-env": "^1.16.0", - "@typescript-eslint/eslint-plugin": "6.10.0", - "@typescript-eslint/parser": "6.10.0", + "@typescript-eslint/eslint-plugin": "^8.44.0", + "@typescript-eslint/parser": "^8.44.0", "@vscode/dts": "^0.4.1", "@vscode/test-cli": "^0.0.11", "@vscode/test-electron": "^2.5.2", @@ -4272,13 +4302,14 @@ "crypto-browserify": "3.12.0", "css-loader": "7.1.2", "esbuild-loader": "4.2.2", - "eslint": "7.22.0", - "eslint-cli": "1.1.1", - "eslint-plugin-import": "2.22.1", + "eslint": "^9.36.0", + "eslint-import-resolver-typescript": "^4.4.4", + "eslint-plugin-import": "2.31.0", "eslint-plugin-rulesdir": "^0.2.2", "event-stream": "^4.0.1", "fork-ts-checker-webpack-plugin": "9.1.0", "glob": "7.1.6", + "globals": "^16.4.0", "graphql": "15.5.0", "graphql-tag": "2.11.0", "gulp-filter": "^7.0.0", @@ -4306,14 +4337,17 @@ "terser-webpack-plugin": "5.1.1", "timers-browserify": "^2.0.12", "ts-loader": "9.5.2", + "ts-node": "^10.9.2", "tty": "1.0.1", "typescript": "^5.9.2", + "typescript-eslint": "^8.44.0", "typescript-formatter": "^7.2.2", "vinyl-fs": "^3.0.3", "webpack": "5.94.0", "webpack-cli": "4.2.0" }, "dependencies": { + "@joaomoreno/unique-names-generator": "^5.2.0", "@octokit/rest": "22.0.0", "@octokit/types": "14.1.0", "@vscode/codicons": "^0.0.36", @@ -4347,4 +4381,4 @@ "string_decoder": "^1.3.0" }, "license": "MIT" -} +} \ No newline at end of file diff --git a/package.nls.json b/package.nls.json index 59e94e4b82..20caa3c7fb 100644 --- a/package.nls.json +++ b/package.nls.json @@ -20,6 +20,7 @@ "{Locked='](command:workbench.action.setLogLevel)'}" ] }, + "githubPullRequests.branchListTimeout.description": "Maximum time in milliseconds to wait when fetching the list of branches for pull request creation. Repositories with thousands of branches may need a higher value to ensure all branches (including the default branch) are retrieved. Minimum value is 1000 (1 second).", "githubPullRequests.codingAgent.description": "Enables integration with the asynchronous Copilot coding agent. The '#copilotCodingAgent' tool will be available in agent mode when this setting is enabled.", "githubPullRequests.codingAgent.uiIntegration.description": "Enables UI integration within VS Code to create new coding agent sessions.", "githubPullRequests.codingAgent.autoCommitAndPush.description": "Allow automatic git operations (commit, push) to be performed when starting a coding agent session", @@ -80,6 +81,7 @@ "githubPullRequests.postDone.description": "The action to take after using the 'checkout default branch' or 'delete branch' actions on a currently checked out pull request.", "githubPullRequests.postDone.checkoutDefaultBranch": "Checkout the default branch of the repository", "githubPullRequests.postDone.checkoutDefaultBranchAndPull": "Checkout the default branch of the repository and pull the latest changes", + "githubPullRequests.autoStashOnCheckout.description": "Automatically stash changes when checking out a pull request, and pop the stash when checking out the default branch.", "githubPullRequests.defaultCommentType.description": "The default comment type to use when submitting a comment and there is no active review", "githubPullRequests.defaultCommentType.single": "Submits the comment as a single comment that will be immediately visible to other users", "githubPullRequests.defaultCommentType.review": "Submits the comment as a review comment that will be visible to other users once the review is submitted", @@ -104,6 +106,7 @@ "githubIssues.ignoreMilestones.description": "An array of milestones titles to never show issues from.", "githubIssues.createIssueTriggers.description": "Strings that will cause the 'Create issue from comment' code action to show.", "githubIssues.createIssueTriggers.items": "String that enables the 'Create issue from comment' code action. Should not contain whitespace.", + "githubPullRequests.codingAgent.codeLens.description": "Show CodeLens actions above TODO comments for delegating to coding agent.", "githubIssues.createInsertFormat.description": "Controls whether an issue number (ex. #1234) or a full url (ex. https://github.com/owner/name/issues/1234) is inserted when the Create Issue code action is run.", "githubIssues.issueCompletions.enabled.description": "Controls whether completion suggestions are shown for issues.", "githubIssues.userCompletions.enabled.description": "Controls whether completion suggestions are shown for users.", @@ -189,6 +192,7 @@ "command.pr.pickOnVscodeDev.title": "Checkout Pull Request on vscode.dev", "command.pr.exit.title": "Checkout Default Branch", "command.pr.dismissNotification.title": "Dismiss Notification", + "command.pr.markAllCopilotNotificationsAsRead.title": "Dismiss All Copilot Notifications", "command.pr.merge.title": "Merge Pull Request", "command.pr.readyForReview.title": "Mark Pull Request Ready For Review", "command.pr.openPullRequestOnGitHub.title": "Open Pull Request on GitHub", @@ -329,7 +333,6 @@ "command.notifications.markPullRequestsAsDone.title": "Mark Pull Requests as Done", "command.notifications.configureNotificationsViewlet.title": "Configure...", "command.notification.chatSummarizeNotification.title": "Summarize With Copilot", - "command.codingAgent.openSessionLog.title": "Open Coding Agent Session Log", "command.pr.checkoutChatSessionPullRequest.title": "Checkout Pull Request", "command.pr.closeChatSessionPullRequest.title": "Close Pull Request", "command.pr.preferredCodingAgentGitHubRemote.title": "Set Preferred GitHub Remote", @@ -342,7 +345,14 @@ "{Locked='](command:pr.signin)'}" ] }, - "welcome.github.noGit.contents": "Git is not installed or otherwise not available. Install git or fix your git installation and then reload.", + "welcome.github.noGit.contents": "Git is not installed or otherwise not available. Install git or fix your git installation and then reload. If you have just enabled git in your settings, reload to continue.", + "welcome.github.noGitDisabled.contents": { + "message": "Git has been disabled in your settings. Enable git then reload.\n[Enable Git](command:workbench.action.openSettings?%5B%22git.enabled%22%5D)", + "comment": [ + "Do not translate what's inside of (...). It is link syntax.", + "{Locked='](command:workbench.action.openSettings?%5B%22git.enabled%22%5D)'}" + ] + }, "welcome.github.loginNoEnterprise.contents": { "message": "You have not yet signed in with GitHub\n[Sign in](command:pr.signinNoEnterprise)", "comment": [ diff --git a/resources/icons/alert.svg b/resources/icons/alert.svg deleted file mode 100644 index 3c493a4c29..0000000000 --- a/resources/icons/alert.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/assignee.svg b/resources/icons/assignee.svg deleted file mode 100644 index 2bb8758146..0000000000 --- a/resources/icons/assignee.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/resources/icons/check.svg b/resources/icons/check.svg deleted file mode 100644 index f94e91f51b..0000000000 --- a/resources/icons/check.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/chevron.svg b/resources/icons/chevron.svg deleted file mode 100644 index f6f3e9e3eb..0000000000 --- a/resources/icons/chevron.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/chevron_down.svg b/resources/icons/chevron_down.svg deleted file mode 100644 index d369b3dc9d..0000000000 --- a/resources/icons/chevron_down.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/close.svg b/resources/icons/close.svg deleted file mode 100644 index f8af265cc4..0000000000 --- a/resources/icons/close.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/codicons/account.svg b/resources/icons/codicons/account.svg new file mode 100644 index 0000000000..851e0511fb --- /dev/null +++ b/resources/icons/codicons/account.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/add.svg b/resources/icons/codicons/add.svg new file mode 100644 index 0000000000..3d5d0221c4 --- /dev/null +++ b/resources/icons/codicons/add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/check-all.svg b/resources/icons/codicons/check-all.svg new file mode 100644 index 0000000000..3028a6c57e --- /dev/null +++ b/resources/icons/codicons/check-all.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/check.svg b/resources/icons/codicons/check.svg new file mode 100644 index 0000000000..6217cb9572 --- /dev/null +++ b/resources/icons/codicons/check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/chevron-down.svg b/resources/icons/codicons/chevron-down.svg new file mode 100644 index 0000000000..4c4a6d1369 --- /dev/null +++ b/resources/icons/codicons/chevron-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/circle-filled.svg b/resources/icons/codicons/circle-filled.svg new file mode 100644 index 0000000000..3e224dabc8 --- /dev/null +++ b/resources/icons/codicons/circle-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/close.svg b/resources/icons/codicons/close.svg new file mode 100644 index 0000000000..033334911e --- /dev/null +++ b/resources/icons/codicons/close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/comment.svg b/resources/icons/codicons/comment.svg new file mode 100644 index 0000000000..6430691a6b --- /dev/null +++ b/resources/icons/codicons/comment.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/copilot.svg b/resources/icons/codicons/copilot.svg new file mode 100644 index 0000000000..6d52a36692 --- /dev/null +++ b/resources/icons/codicons/copilot.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/copy.svg b/resources/icons/codicons/copy.svg new file mode 100644 index 0000000000..39a62984ea --- /dev/null +++ b/resources/icons/codicons/copy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/edit.svg b/resources/icons/codicons/edit.svg new file mode 100644 index 0000000000..7642adb9f7 --- /dev/null +++ b/resources/icons/codicons/edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/error.svg b/resources/icons/codicons/error.svg new file mode 100644 index 0000000000..1e2337f80d --- /dev/null +++ b/resources/icons/codicons/error.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/feedback.svg b/resources/icons/codicons/feedback.svg new file mode 100644 index 0000000000..2de89fd2ae --- /dev/null +++ b/resources/icons/codicons/feedback.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/git-commit.svg b/resources/icons/codicons/git-commit.svg new file mode 100644 index 0000000000..ecce26c503 --- /dev/null +++ b/resources/icons/codicons/git-commit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/git-compare.svg b/resources/icons/codicons/git-compare.svg new file mode 100644 index 0000000000..193a80cf96 --- /dev/null +++ b/resources/icons/codicons/git-compare.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/git-merge.svg b/resources/icons/codicons/git-merge.svg new file mode 100644 index 0000000000..63dbdc36e0 --- /dev/null +++ b/resources/icons/codicons/git-merge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/git-pull-request-closed.svg b/resources/icons/codicons/git-pull-request-closed.svg new file mode 100644 index 0000000000..bce2914a6e --- /dev/null +++ b/resources/icons/codicons/git-pull-request-closed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/git-pull-request-draft.svg b/resources/icons/codicons/git-pull-request-draft.svg new file mode 100644 index 0000000000..0afee6e0e3 --- /dev/null +++ b/resources/icons/codicons/git-pull-request-draft.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/git-pull-request.svg b/resources/icons/codicons/git-pull-request.svg new file mode 100644 index 0000000000..47a216d753 --- /dev/null +++ b/resources/icons/codicons/git-pull-request.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/github-project.svg b/resources/icons/codicons/github-project.svg new file mode 100644 index 0000000000..d240cf2cf6 --- /dev/null +++ b/resources/icons/codicons/github-project.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/issues.svg b/resources/icons/codicons/issues.svg new file mode 100644 index 0000000000..7de219baea --- /dev/null +++ b/resources/icons/codicons/issues.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/loading.svg b/resources/icons/codicons/loading.svg new file mode 100644 index 0000000000..57a717a150 --- /dev/null +++ b/resources/icons/codicons/loading.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/merge.svg b/resources/icons/codicons/merge.svg new file mode 100644 index 0000000000..2692deecee --- /dev/null +++ b/resources/icons/codicons/merge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/milestone.svg b/resources/icons/codicons/milestone.svg new file mode 100644 index 0000000000..3d2f9db353 --- /dev/null +++ b/resources/icons/codicons/milestone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/pass.svg b/resources/icons/codicons/pass.svg new file mode 100644 index 0000000000..0abbfb2a92 --- /dev/null +++ b/resources/icons/codicons/pass.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/quote.svg b/resources/icons/codicons/quote.svg new file mode 100644 index 0000000000..6903c03730 --- /dev/null +++ b/resources/icons/codicons/quote.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/request-changes.svg b/resources/icons/codicons/request-changes.svg new file mode 100644 index 0000000000..749801beeb --- /dev/null +++ b/resources/icons/codicons/request-changes.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/settings-gear.svg b/resources/icons/codicons/settings-gear.svg new file mode 100644 index 0000000000..cdc25f1e9d --- /dev/null +++ b/resources/icons/codicons/settings-gear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/skip.svg b/resources/icons/codicons/skip.svg new file mode 100644 index 0000000000..f9dcd2df81 --- /dev/null +++ b/resources/icons/codicons/skip.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/sparkle.svg b/resources/icons/codicons/sparkle.svg new file mode 100644 index 0000000000..cacf8d2a88 --- /dev/null +++ b/resources/icons/codicons/sparkle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/stop-circle.svg b/resources/icons/codicons/stop-circle.svg new file mode 100644 index 0000000000..9970ad8c97 --- /dev/null +++ b/resources/icons/codicons/stop-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/sync.svg b/resources/icons/codicons/sync.svg new file mode 100644 index 0000000000..1767194bbf --- /dev/null +++ b/resources/icons/codicons/sync.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/tag.svg b/resources/icons/codicons/tag.svg new file mode 100644 index 0000000000..788540b4ef --- /dev/null +++ b/resources/icons/codicons/tag.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/tasklist.svg b/resources/icons/codicons/tasklist.svg new file mode 100644 index 0000000000..c9b951ff1c --- /dev/null +++ b/resources/icons/codicons/tasklist.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/three-bars.svg b/resources/icons/codicons/three-bars.svg new file mode 100644 index 0000000000..b31880865e --- /dev/null +++ b/resources/icons/codicons/three-bars.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/trash.svg b/resources/icons/codicons/trash.svg new file mode 100644 index 0000000000..fd1c66aa77 --- /dev/null +++ b/resources/icons/codicons/trash.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/codicons/warning.svg b/resources/icons/codicons/warning.svg new file mode 100644 index 0000000000..9400358a2d --- /dev/null +++ b/resources/icons/codicons/warning.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/comment.svg b/resources/icons/comment.svg deleted file mode 100644 index 672b889b3b..0000000000 --- a/resources/icons/comment.svg +++ /dev/null @@ -1,4 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/commit_icon.svg b/resources/icons/commit_icon.svg deleted file mode 100644 index dc1d10c63f..0000000000 --- a/resources/icons/commit_icon.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/copilot.svg b/resources/icons/copilot.svg deleted file mode 100644 index 2e5f639e35..0000000000 --- a/resources/icons/copilot.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/copy.svg b/resources/icons/copy.svg deleted file mode 100644 index e01cf5b0a2..0000000000 --- a/resources/icons/copy.svg +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/resources/icons/dark/pr_webview.svg b/resources/icons/dark/git-pull-request_webview.svg similarity index 100% rename from resources/icons/dark/pr_webview.svg rename to resources/icons/dark/git-pull-request_webview.svg diff --git a/resources/icons/delete.svg b/resources/icons/delete.svg deleted file mode 100644 index 4bebdd27c4..0000000000 --- a/resources/icons/delete.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/dot.svg b/resources/icons/dot.svg deleted file mode 100644 index 0394588aec..0000000000 --- a/resources/icons/dot.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/edit.svg b/resources/icons/edit.svg deleted file mode 100644 index b02c84f152..0000000000 --- a/resources/icons/edit.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/resources/icons/error.svg b/resources/icons/error.svg deleted file mode 100644 index 4cfb5bbfab..0000000000 --- a/resources/icons/error.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/gear.svg b/resources/icons/gear.svg deleted file mode 100644 index d4b13f9797..0000000000 --- a/resources/icons/gear.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/pr_webview.svg b/resources/icons/git-pull-request_webview.svg similarity index 100% rename from resources/icons/pr_webview.svg rename to resources/icons/git-pull-request_webview.svg diff --git a/resources/icons/github-project.svg b/resources/icons/github-project.svg deleted file mode 100644 index cf8b1ddf03..0000000000 --- a/resources/icons/github-project.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/resources/icons/issue.svg b/resources/icons/issue.svg deleted file mode 100644 index 666a3baac7..0000000000 --- a/resources/icons/issue.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/issue_closed.svg b/resources/icons/issue_closed.svg deleted file mode 100644 index 6a6c314255..0000000000 --- a/resources/icons/issue_closed.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/label.svg b/resources/icons/label.svg deleted file mode 100644 index 06c7fb55ea..0000000000 --- a/resources/icons/label.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/loading.svg b/resources/icons/loading.svg deleted file mode 100644 index 4e3e640e03..0000000000 --- a/resources/icons/loading.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/merge_icon.svg b/resources/icons/merge_icon.svg deleted file mode 100644 index 6d3f716696..0000000000 --- a/resources/icons/merge_icon.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/merge_method.svg b/resources/icons/merge_method.svg deleted file mode 100644 index b8596218f1..0000000000 --- a/resources/icons/merge_method.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/milestone.svg b/resources/icons/milestone.svg deleted file mode 100644 index 58ac83a825..0000000000 --- a/resources/icons/milestone.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/output.svg b/resources/icons/output.svg deleted file mode 100644 index 41f7853c24..0000000000 --- a/resources/icons/output.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/plus.svg b/resources/icons/plus.svg deleted file mode 100644 index 4d9389336b..0000000000 --- a/resources/icons/plus.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/pr.svg b/resources/icons/pr.svg deleted file mode 100644 index 6d59036b31..0000000000 --- a/resources/icons/pr.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/pr_base.svg b/resources/icons/pr_base.svg deleted file mode 100644 index 862a547917..0000000000 --- a/resources/icons/pr_base.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/pr_closed.svg b/resources/icons/pr_closed.svg deleted file mode 100644 index 4fb8a73d1f..0000000000 --- a/resources/icons/pr_closed.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/resources/icons/pr_draft.svg b/resources/icons/pr_draft.svg deleted file mode 100644 index d0cf9b3008..0000000000 --- a/resources/icons/pr_draft.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/resources/icons/pr_merge.svg b/resources/icons/pr_merge.svg deleted file mode 100644 index cf26950f84..0000000000 --- a/resources/icons/pr_merge.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/quote.svg b/resources/icons/quote.svg deleted file mode 100644 index 1c71b75363..0000000000 --- a/resources/icons/quote.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/reactions/confused.png b/resources/icons/reactions/confused.png deleted file mode 100644 index 77734d4036..0000000000 Binary files a/resources/icons/reactions/confused.png and /dev/null differ diff --git a/resources/icons/reactions/eyes.png b/resources/icons/reactions/eyes.png deleted file mode 100644 index b303dee34d..0000000000 Binary files a/resources/icons/reactions/eyes.png and /dev/null differ diff --git a/resources/icons/reactions/heart.png b/resources/icons/reactions/heart.png deleted file mode 100644 index 277e3e8d48..0000000000 Binary files a/resources/icons/reactions/heart.png and /dev/null differ diff --git a/resources/icons/reactions/hooray.png b/resources/icons/reactions/hooray.png deleted file mode 100644 index c3cce94d50..0000000000 Binary files a/resources/icons/reactions/hooray.png and /dev/null differ diff --git a/resources/icons/reactions/laugh.png b/resources/icons/reactions/laugh.png deleted file mode 100644 index a3abe00904..0000000000 Binary files a/resources/icons/reactions/laugh.png and /dev/null differ diff --git a/resources/icons/reactions/rocket.png b/resources/icons/reactions/rocket.png deleted file mode 100644 index ea8fbcce70..0000000000 Binary files a/resources/icons/reactions/rocket.png and /dev/null differ diff --git a/resources/icons/reactions/thumbs_down.png b/resources/icons/reactions/thumbs_down.png deleted file mode 100644 index ee2c24ee89..0000000000 Binary files a/resources/icons/reactions/thumbs_down.png and /dev/null differ diff --git a/resources/icons/reactions/thumbs_up.png b/resources/icons/reactions/thumbs_up.png deleted file mode 100644 index bcca0f1ba5..0000000000 Binary files a/resources/icons/reactions/thumbs_up.png and /dev/null differ diff --git a/resources/icons/request_changes.svg b/resources/icons/request_changes.svg deleted file mode 100644 index c412bb8546..0000000000 --- a/resources/icons/request_changes.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/resources/icons/reviewer.svg b/resources/icons/reviewer.svg deleted file mode 100644 index e83e580fd1..0000000000 --- a/resources/icons/reviewer.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/settings.svg b/resources/icons/settings.svg deleted file mode 100644 index 4e7e022bcf..0000000000 --- a/resources/icons/settings.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/icons/skip.svg b/resources/icons/skip.svg deleted file mode 100644 index b7368b71f2..0000000000 --- a/resources/icons/skip.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/sparkle.svg b/resources/icons/sparkle.svg deleted file mode 100644 index 442e6cc389..0000000000 --- a/resources/icons/sparkle.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/stop-circle.svg b/resources/icons/stop-circle.svg deleted file mode 100644 index 4f39984fa2..0000000000 --- a/resources/icons/stop-circle.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/sync.svg b/resources/icons/sync.svg deleted file mode 100644 index 63c0090a6c..0000000000 --- a/resources/icons/sync.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/tasklist.svg b/resources/icons/tasklist.svg deleted file mode 100644 index efc81525d0..0000000000 --- a/resources/icons/tasklist.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/three-bars.svg b/resources/icons/three-bars.svg deleted file mode 100644 index f49db2a735..0000000000 --- a/resources/icons/three-bars.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/scripts/ci/common-setup.yml b/scripts/ci/common-setup.yml index cad46bba53..8cb20e9f94 100644 --- a/scripts/ci/common-setup.yml +++ b/scripts/ci/common-setup.yml @@ -2,7 +2,7 @@ steps: - task: NodeTool@0 displayName: Upgrade Node inputs: - versionSpec: '16.x' + versionSpec: "20.x" - script: yarn install --frozen-lockfile --check-files displayName: Install dependencies diff --git a/src/@types/git.d.ts b/src/@types/git.d.ts index 702e382fee..718d8f4b6b 100644 --- a/src/@types/git.d.ts +++ b/src/@types/git.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Uri, Event, Disposable, ProviderResult, Command, CancellationToken } from 'vscode'; +import { Uri, Event, Disposable, ProviderResult, Command, CancellationToken, SourceControlHistoryItem } from 'vscode'; export { ProviderResult } from 'vscode'; export interface Git { @@ -16,7 +16,8 @@ export interface InputBox { export const enum ForcePushMode { Force, - ForceWithLease + ForceWithLease, + ForceWithLeaseIfIncludes, } export const enum RefType { @@ -29,12 +30,14 @@ export interface Ref { readonly type: RefType; readonly name?: string; readonly commit?: string; + readonly commitDetails?: Commit; readonly remote?: string; } export interface UpstreamRef { readonly remote: string; readonly name: string; + readonly commit?: string; } export interface Branch extends Ref { @@ -43,6 +46,12 @@ export interface Branch extends Ref { readonly behind?: number; } +export interface CommitShortStat { + readonly files: number; + readonly insertions: number; + readonly deletions: number; +} + export interface Commit { readonly hash: string; readonly message: string; @@ -51,6 +60,7 @@ export interface Commit { readonly authorName?: string; readonly authorEmail?: string; readonly commitDate?: Date; + readonly shortStat?: CommitShortStat; } export interface Submodule { @@ -78,6 +88,8 @@ export const enum Status { UNTRACKED, IGNORED, INTENT_TO_ADD, + INTENT_TO_RENAME, + TYPE_CHANGED, ADDED_BY_US, ADDED_BY_THEM, @@ -103,6 +115,7 @@ export interface Change { export interface RepositoryState { readonly HEAD: Branch | undefined; + readonly refs: Ref[]; readonly remotes: Remote[]; readonly submodules: Submodule[]; readonly rebaseCommit: Commit | undefined; @@ -110,6 +123,7 @@ export interface RepositoryState { readonly mergeChanges: Change[]; readonly indexChanges: Change[]; readonly workingTreeChanges: Change[]; + readonly untrackedChanges: Change[]; readonly onDidChange: Event; } @@ -126,6 +140,16 @@ export interface LogOptions { /** Max number of log entries to retrieve. If not specified, the default is 32. */ readonly maxEntries?: number; readonly path?: string; + /** A commit range, such as "0a47c67f0fb52dd11562af48658bc1dff1d75a38..0bb4bdea78e1db44d728fd6894720071e303304f" */ + readonly range?: string; + readonly reverse?: boolean; + readonly sortByAuthorDate?: boolean; + readonly shortStats?: boolean; + readonly author?: string; + readonly grep?: string; + readonly refNames?: string[]; + readonly maxParents?: number; + readonly skip?: number; } export interface CommitOptions { @@ -155,10 +179,27 @@ export interface FetchOptions { depth?: number; } +export interface InitOptions { + defaultBranch?: string; +} + +export interface CloneOptions { + parentPath?: Uri; + /** + * ref is only used if the repository cache is missed. + */ + ref?: string; + recursive?: boolean; + /** + * If no postCloneAction is provided, then the users setting for git.openAfterClone is used. + */ + postCloneAction?: 'none'; +} + export interface RefQuery { readonly contains?: string; readonly count?: number; - readonly pattern?: string; + readonly pattern?: string | string[]; readonly sort?: 'alphabetically' | 'committerdate'; } @@ -173,9 +214,13 @@ export interface Repository { readonly state: RepositoryState; readonly ui: RepositoryUIState; + readonly onDidCommit: Event; + readonly onDidCheckout: Event; + getConfigs(): Promise<{ key: string; value: string; }[]>; getConfig(key: string): Promise; setConfig(key: string, value: string): Promise; + unsetConfig(key: string): Promise; getGlobalConfig(key: string): Promise; getObjectDetails(treeish: string, path: string): Promise<{ mode: string, object: string, size: number }>; @@ -211,9 +256,11 @@ export interface Repository { getBranchBase(name: string): Promise; setBranchUpstream(name: string, upstream: string): Promise; + checkIgnore(paths: string[]): Promise>; + getRefs(query: RefQuery, cancellationToken?: CancellationToken): Promise; - getMergeBase(ref1: string, ref2: string): Promise; + getMergeBase(ref1: string, ref2: string): Promise; tag(name: string, upstream: string): Promise; deleteTag(name: string): Promise; @@ -236,6 +283,10 @@ export interface Repository { commit(message: string, opts?: CommitOptions): Promise; merge(ref: string): Promise; mergeAbort(): Promise; + + applyStash(index?: number): Promise; + popStash(index?: number): Promise; + dropStash(index?: number): Promise; } export interface RemoteSource { @@ -276,6 +327,38 @@ export interface PushErrorHandler { handlePushError(repository: Repository, remote: Remote, refspec: string, error: Error & { gitErrorCode: GitErrorCodes }): Promise; } +export interface BranchProtection { + readonly remote: string; + readonly rules: BranchProtectionRule[]; +} + +export interface BranchProtectionRule { + readonly include?: string[]; + readonly exclude?: string[]; +} + +export interface BranchProtectionProvider { + onDidChangeBranchProtection: Event; + provideBranchProtection(): BranchProtection[]; +} + +export interface AvatarQueryCommit { + readonly hash: string; + readonly authorName?: string; + readonly authorEmail?: string; +} + +export interface AvatarQuery { + readonly commits: AvatarQueryCommit[]; + readonly size: number; +} + +export interface SourceControlHistoryItemDetailsProvider { + provideAvatar(repository: Repository, query: AvatarQuery): ProviderResult>; + provideHoverCommands(repository: Repository): ProviderResult; + provideMessageLinks(repository: Repository, message: string): ProviderResult; +} + export type APIState = 'uninitialized' | 'initialized'; export interface PublishEvent { @@ -294,14 +377,24 @@ export interface GitAPI { toGitUri(uri: Uri, ref: string): Uri; getRepository(uri: Uri): Repository | null; - init(root: Uri): Promise; - openRepository(root: Uri): Promise + getRepositoryRoot(uri: Uri): Promise; + getRepositoryWorkspace(uri: Uri): Promise; + init(root: Uri, options?: InitOptions): Promise; + /** + * Checks the cache of known cloned repositories, and clones if the repository is not found. + * Make sure to pass `postCloneAction` 'none' if you want to have the uri where you can find the repository returned. + * @returns The URI of a folder or workspace file which, when opened, will open the cloned repository. + */ + clone(uri: Uri, options?: CloneOptions): Promise; + openRepository(root: Uri): Promise; registerRemoteSourcePublisher(publisher: RemoteSourcePublisher): Disposable; registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable; registerCredentialsProvider(provider: CredentialsProvider): Disposable; registerPostCommitCommandsProvider(provider: PostCommitCommandsProvider): Disposable; registerPushErrorHandler(handler: PushErrorHandler): Disposable; + registerBranchProtectionProvider(root: Uri, provider: BranchProtectionProvider): Disposable; + registerSourceControlHistoryItemDetailsProvider(provider: SourceControlHistoryItemDetailsProvider): Disposable; } export interface GitExtension { @@ -319,21 +412,25 @@ export interface GitExtension { * @param version Version number. * @returns API instance */ - getAPI(version: 1): GitAPI; + getAPI(version: 1): API; } export const enum GitErrorCodes { BadConfigFile = 'BadConfigFile', + BadRevision = 'BadRevision', AuthenticationFailed = 'AuthenticationFailed', NoUserNameConfigured = 'NoUserNameConfigured', NoUserEmailConfigured = 'NoUserEmailConfigured', NoRemoteRepositorySpecified = 'NoRemoteRepositorySpecified', NotAGitRepository = 'NotAGitRepository', + NotASafeGitRepository = 'NotASafeGitRepository', NotAtRepositoryRoot = 'NotAtRepositoryRoot', Conflict = 'Conflict', StashConflict = 'StashConflict', UnmergedChanges = 'UnmergedChanges', PushRejected = 'PushRejected', + ForcePushWithLeaseRejected = 'ForcePushWithLeaseRejected', + ForcePushWithLeaseIfIncludesRejected = 'ForcePushWithLeaseIfIncludesRejected', RemoteConnectionError = 'RemoteConnectionError', DirtyWorkTree = 'DirtyWorkTree', CantOpenResource = 'CantOpenResource', @@ -361,5 +458,10 @@ export const enum GitErrorCodes { EmptyCommitMessage = 'EmptyCommitMessage', BranchFastForwardRejected = 'BranchFastForwardRejected', BranchNotYetBorn = 'BranchNotYetBorn', - TagConflict = 'TagConflict' + TagConflict = 'TagConflict', + CherryPickEmpty = 'CherryPickEmpty', + CherryPickConflict = 'CherryPickConflict', + WorktreeContainsChanges = 'WorktreeContainsChanges', + WorktreeAlreadyExists = 'WorktreeAlreadyExists', + WorktreeBranchAlreadyUsed = 'WorktreeBranchAlreadyUsed' } diff --git a/src/@types/vscode.proposed.chatParticipantAdditions.d.ts b/src/@types/vscode.proposed.chatParticipantAdditions.d.ts index 2cb4168b43..f4ce44cd9e 100644 --- a/src/@types/vscode.proposed.chatParticipantAdditions.d.ts +++ b/src/@types/vscode.proposed.chatParticipantAdditions.d.ts @@ -6,7 +6,7 @@ declare module 'vscode' { export interface ChatParticipant { - onDidPerformAction: Event; + readonly onDidPerformAction: Event; } /** @@ -103,6 +103,7 @@ declare module 'vscode' { isConfirmed?: boolean; isComplete?: boolean; toolSpecificData?: ChatTerminalToolInvocationData; + fromSubAgent?: boolean; constructor(toolName: string, toolCallId: string, isError?: boolean); } @@ -151,15 +152,29 @@ declare module 'vscode' { */ title: string; + /** + * Whether the multi diff editor should be read-only. + * When true, users cannot open individual files or interact with file navigation. + */ + readOnly?: boolean; + /** * Create a new ChatResponseMultiDiffPart. * @param value Array of file diff entries. * @param title The title for the multi diff editor. + * @param readOnly Optional flag to make the multi diff editor read-only. */ - constructor(value: ChatResponseDiffEntry[], title: string); + constructor(value: ChatResponseDiffEntry[], title: string, readOnly?: boolean); } - export type ExtendedChatResponsePart = ChatResponsePart | ChatResponseTextEditPart | ChatResponseNotebookEditPart | ChatResponseConfirmationPart | ChatResponseCodeCitationPart | ChatResponseReferencePart2 | ChatResponseMovePart | ChatResponseExtensionsPart | ChatResponsePullRequestPart | ChatPrepareToolInvocationPart | ChatToolInvocationPart | ChatResponseMultiDiffPart | ChatResponseThinkingProgressPart; + export class ChatResponseExternalEditPart { + uris: Uri[]; + callback: () => Thenable; + applied: Thenable; + constructor(uris: Uri[], callback: () => Thenable); + } + + export type ExtendedChatResponsePart = ChatResponsePart | ChatResponseTextEditPart | ChatResponseNotebookEditPart | ChatResponseConfirmationPart | ChatResponseCodeCitationPart | ChatResponseReferencePart2 | ChatResponseMovePart | ChatResponseExtensionsPart | ChatResponsePullRequestPart | ChatPrepareToolInvocationPart | ChatToolInvocationPart | ChatResponseMultiDiffPart | ChatResponseThinkingProgressPart | ChatResponseExternalEditPart; export class ChatResponseWarningPart { value: MarkdownString; constructor(value: string | MarkdownString); @@ -293,6 +308,14 @@ declare module 'vscode' { notebookEdit(target: Uri, isDone: true): void; + /** + * Makes an external edit to one or more resources. Changes to the + * resources made within the `callback` and before it resolves will be + * tracked as agent edits. This can be used to track edits made from + * external tools that don't generate simple {@link textEdit textEdits}. + */ + externalEdit(target: Uri | Uri[], callback: () => Thenable): Thenable; + markdownWithVulnerabilities(value: string | MarkdownString, vulnerabilities: ChatVulnerability[]): void; codeblockUri(uri: Uri, isEdit?: boolean): void; push(part: ChatResponsePart | ChatResponseTextEditPart | ChatResponseWarningPart | ChatResponseProgressPart2): void; @@ -441,7 +464,7 @@ declare module 'vscode' { * Event that fires when a request is paused or unpaused. * Chat requests are initially unpaused in the {@link requestHandler}. */ - onDidChangePauseState: Event; + readonly onDidChangePauseState: Event; } export interface ChatParticipantPauseStateEvent { @@ -646,7 +669,14 @@ declare module 'vscode' { } export interface ChatRequest { - modeInstructions?: string; - modeInstructionsToolReferences?: readonly ChatLanguageModelToolReference[]; + readonly modeInstructions?: string; + readonly modeInstructions2?: ChatRequestModeInstructions; + } + + export interface ChatRequestModeInstructions { + readonly name: string; + readonly content: string; + readonly toolReferences?: readonly ChatLanguageModelToolReference[]; + readonly metadata?: Record; } } diff --git a/src/@types/vscode.proposed.chatParticipantPrivate.d.ts b/src/@types/vscode.proposed.chatParticipantPrivate.d.ts index eb42d52c92..398dce07fd 100644 --- a/src/@types/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/@types/vscode.proposed.chatParticipantPrivate.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 10 +// version: 11 declare module 'vscode' { @@ -87,6 +87,8 @@ declare module 'vscode' { * Events for edited files in this session collected since the last request. */ readonly editedFileEvents?: ChatRequestEditedFileEvent[]; + + readonly isSubagent?: boolean; } export enum ChatRequestEditedFileEventKind { @@ -187,6 +189,8 @@ declare module 'vscode' { isQuotaExceeded?: boolean; + isRateLimited?: boolean; + level?: ChatErrorLevel; code?: string; @@ -219,6 +223,10 @@ declare module 'vscode' { chatSessionId?: string; chatInteractionId?: string; terminalCommand?: string; + /** + * Lets us add some nicer UI to toolcalls that came from a sub-agent, but in the long run, this should probably just be rendered in a similar way to thinking text + tool call groups + */ + fromSubAgent?: boolean; } export interface LanguageModelToolInvocationPrepareOptions { @@ -233,12 +241,13 @@ declare module 'vscode' { export interface PreparedToolInvocation { pastTenseMessage?: string | MarkdownString; - presentation?: 'hidden' | undefined; + presentation?: 'hidden' | 'hiddenAfterComplete' | undefined; } export class ExtendedLanguageModelToolResult extends LanguageModelToolResult { toolResultMessage?: string | MarkdownString; toolResultDetails?: Array; + toolMetadata?: unknown; } // #region Chat participant detection @@ -278,4 +287,24 @@ declare module 'vscode' { } // #endregion + + // #region LanguageModelProxyProvider + + /** + * Duplicated so that this proposal and languageModelProxy can be independent. + */ + export interface LanguageModelProxy extends Disposable { + readonly uri: Uri; + readonly key: string; + } + + export interface LanguageModelProxyProvider { + provideModelProxy(forExtensionId: string, token: CancellationToken): ProviderResult; + } + + export namespace lm { + export function registerLanguageModelProxyProvider(provider: LanguageModelProxyProvider): Disposable; + } + + // #endregion } diff --git a/src/@types/vscode.proposed.chatSessionsProvider.d.ts b/src/@types/vscode.proposed.chatSessionsProvider.d.ts index 05b2b054ac..489e5f952c 100644 --- a/src/@types/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/@types/vscode.proposed.chatSessionsProvider.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 2 +// version: 3 declare module 'vscode' { /** @@ -35,6 +35,14 @@ declare module 'vscode' { */ readonly onDidChangeChatSessionItems: Event; + /** + * Provides a list of chat sessions. + */ + // TODO: Do we need a flag to try auth if needed? + provideChatSessionItems(token: CancellationToken): ProviderResult; + + // #region Unstable parts of API + /** * Event that the provider can fire to signal that the current (original) chat session should be replaced with a new (modified) chat session. * The UI can use this information to gracefully migrate the user to the new session. @@ -61,18 +69,16 @@ declare module 'vscode' { metadata?: any; }, token: CancellationToken): ProviderResult; - /** - * Provides a list of chat sessions. - */ - // TODO: Do we need a flag to try auth if needed? - provideChatSessionItems(token: CancellationToken): ProviderResult; + // #endregion } export interface ChatSessionItem { /** - * Unique identifier for the chat session. + * The resource associated with the chat session. + * + * This is uniquely identifies the chat session and is used to open the chat session. */ - id: string; + resource: Uri; /** * Human readable name of the session shown in the UI @@ -117,6 +123,11 @@ declare module 'vscode' { * Statistics about the chat session. */ statistics?: { + /** + * Number of files edited during the session. + */ + files: number; + /** * Number of insertions made during the session. */ @@ -139,6 +150,14 @@ declare module 'vscode' { // TODO: link request + response to encourage correct usage? readonly history: ReadonlyArray; + /** + * Options configured for this session as key-value pairs. + * Keys correspond to option group IDs (e.g., 'models', 'subagents') + * and values are the selected option item IDs. + * TODO: Strongly type the keys + */ + readonly options?: Record; + /** * Callback invoked by the editor for a currently running response. This allows the session to push items for the * current response and stream these in as them come in. The current response will be considered complete once the @@ -158,14 +177,46 @@ declare module 'vscode' { readonly requestHandler: ChatRequestHandler | undefined; } + /** + * Provides the content for a chat session rendered using the native chat UI. + */ export interface ChatSessionContentProvider { /** - * Resolves a chat session into a full `ChatSession` object. + * Provides the chat session content for a given uri. + * + * The returned {@linkcode ChatSession} is used to populate the history of the chat UI. * - * @param sessionId The id of the chat session to open. + * @param resource The URI of the chat session to resolve. * @param token A cancellation token that can be used to cancel the operation. + * + * @return The {@link ChatSession chat session} associated with the given URI. + */ + provideChatSessionContent(resource: Uri, token: CancellationToken): Thenable | ChatSession; + + /** + * @param resource Identifier of the chat session being updated. + * @param updates Collection of option identifiers and their new values. Only the options that changed are included. + * @param token A cancellation token that can be used to cancel the notification if the session is disposed. + */ + provideHandleOptionsChange?(resource: Uri, updates: ReadonlyArray, token: CancellationToken): void; + + /** + * Called as soon as you register (call me once) + * @param token */ - provideChatSessionContent(sessionId: string, token: CancellationToken): Thenable | ChatSession; + provideChatSessionProviderOptions?(token: CancellationToken): Thenable | ChatSessionProviderOptions; + } + + export interface ChatSessionOptionUpdate { + /** + * Identifier of the option that changed (for example `model`). + */ + readonly optionId: string; + + /** + * The new value assigned to the option. When `undefined`, the option is cleared. + */ + readonly value: string | undefined; } export namespace chat { @@ -184,16 +235,20 @@ declare module 'vscode' { /** * Registers a new {@link ChatSessionContentProvider chat session content provider}. * - * @param chatSessionType A unique identifier for the chat session type. This is used to differentiate between different chat session providers. + * @param scheme The uri-scheme to register for. This must be unique. * @param provider The provider to register. * * @returns A disposable that unregisters the provider when disposed. */ - export function registerChatSessionContentProvider(chatSessionType: string, provider: ChatSessionContentProvider, chatParticipant: ChatParticipant, capabilities?: ChatSessionCapabilities): Disposable; + export function registerChatSessionContentProvider(scheme: string, provider: ChatSessionContentProvider, chatParticipant: ChatParticipant, capabilities?: ChatSessionCapabilities): Disposable; } export interface ChatContext { readonly chatSessionContext?: ChatSessionContext; + readonly chatSummary?: { + readonly prompt?: string; + readonly history?: string; + }; } export interface ChatSessionContext { @@ -208,19 +263,51 @@ declare module 'vscode' { supportsInterruptions?: boolean; } - export interface ChatSessionShowOptions { + /** + * Represents a single selectable item within a provider option group. + */ + export interface ChatSessionProviderOptionItem { /** - * The editor view column to show the chat session in. - * - * If not provided, the chat session will be shown in the chat panel instead. + * Unique identifier for the option item. + */ + readonly id: string; + + /** + * Human-readable name displayed in the UI. + */ + readonly name: string; + } + + /** + * Represents a group of related provider options (e.g., models, sub-agents). + */ + export interface ChatSessionProviderOptionGroup { + /** + * Unique identifier for the option group (e.g., "models", "subagents"). + */ + readonly id: string; + + /** + * Human-readable name for the option group. + */ + readonly name: string; + + /** + * Optional description providing context about this option group. + */ + readonly description?: string; + + /** + * The selectable items within this option group. */ - readonly viewColumn?: ViewColumn; + readonly items: ChatSessionProviderOptionItem[]; } - export namespace window { + export interface ChatSessionProviderOptions { /** - * Shows a chat session in the panel or editor. + * Provider-defined option groups (0-2 groups supported). + * Examples: models picker, sub-agents picker, etc. */ - export function showChatSession(chatSessionType: string, sessionId: string, options: ChatSessionShowOptions): Thenable; + optionGroups?: ChatSessionProviderOptionGroup[]; } } diff --git a/src/@types/vscode.proposed.markdownAlertSyntax.d.ts b/src/@types/vscode.proposed.markdownAlertSyntax.d.ts new file mode 100644 index 0000000000..bb02da446f --- /dev/null +++ b/src/@types/vscode.proposed.markdownAlertSyntax.d.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + // @kycutler https://github.com/microsoft/vscode/issues/209652 + + export interface MarkdownString { + + /** + * Indicates that this markdown string can contain alert syntax. Defaults to `false`. + * + * When `supportAlertSyntax` is true, the markdown renderer will parse GitHub-style alert syntax: + * + * ```markdown + * > [!NOTE] + * > This is a note alert + * + * > [!WARNING] + * > This is a warning alert + * ``` + * + * Supported alert types: `NOTE`, `TIP`, `IMPORTANT`, `WARNING`, `CAUTION`. + */ + supportAlertSyntax?: boolean; + } +} diff --git a/src/@types/vscode.proposed.treeItemMarkdownLabel.d.ts b/src/@types/vscode.proposed.treeItemMarkdownLabel.d.ts new file mode 100644 index 0000000000..6150fa0667 --- /dev/null +++ b/src/@types/vscode.proposed.treeItemMarkdownLabel.d.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + // @kycutler https://github.com/microsoft/vscode/issues/271523 + + export interface TreeItemLabel2 { + highlights?: [number, number][]; + + /** + * A human-readable string or MarkdownString describing the {@link TreeItem Tree item}. + * + * When using MarkdownString, only the following Markdown syntax is supported: + * - Icons (e.g., `$(icon-name)`, when the `supportIcons` flag is also set) + * - Bold, italics, and strikethrough formatting, but only when the syntax wraps the entire string + * (e.g., `**bold**`, `_italic_`, `~~strikethrough~~`) + */ + label: string | MarkdownString; + } +} diff --git a/src/api/api.d.ts b/src/api/api.d.ts index 67d5a856d4..011cbd9cfa 100644 --- a/src/api/api.d.ts +++ b/src/api/api.d.ts @@ -188,7 +188,7 @@ export interface Repository { setBranchUpstream(name: string, upstream: string): Promise; getRefs?(query: RefQuery, cancellationToken?: CancellationToken): Promise; // Optional, because Remote Hub doesn't support this - getMergeBase(ref1: string, ref2: string): Promise; + getMergeBase(ref1: string, ref2: string): Promise; status(): Promise; checkout(treeish: string): Promise; @@ -239,10 +239,12 @@ export interface IGit { readonly onDidPublish?: Event; registerPostCommitCommandsProvider?(provider: PostCommitCommandsProvider): Disposable; + getRepositoryWorkspace?(uri: Uri): Promise; + clone?(uri: Uri, options?: CloneOptions): Promise; } export interface TitleAndDescriptionProvider { - provideTitleAndDescription(context: { commitMessages: string[], patches: string[] | { patch: string, fileUri: string, previousFileUri?: string }[], issues?: { reference: string, content: string }[] }, token: CancellationToken): Promise<{ title: string, description?: string } | undefined>; + provideTitleAndDescription(context: { commitMessages: string[], patches: string[] | { patch: string, fileUri: string, previousFileUri?: string }[], issues?: { reference: string, content: string }[], template?: string }, token: CancellationToken): Promise<{ title: string, description?: string } | undefined>; } export interface ReviewerComments { diff --git a/src/api/api1.ts b/src/api/api1.ts index b239538016..4eabe95f06 100644 --- a/src/api/api1.ts +++ b/src/api/api1.ts @@ -4,12 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { APIState, PublishEvent } from '../@types/git'; +import { API, IGit, PostCommitCommandsProvider, Repository, ReviewerCommentsProvider, TitleAndDescriptionProvider } from './api'; +import { APIState, CloneOptions, PublishEvent } from '../@types/git'; import { Disposable } from '../common/lifecycle'; import Logger from '../common/logger'; import { TernarySearchTree } from '../common/utils'; import { RepositoriesManager } from '../github/repositoriesManager'; -import { API, IGit, PostCommitCommandsProvider, Repository, ReviewerCommentsProvider, TitleAndDescriptionProvider } from './api'; export const enum RefType { Head, @@ -84,6 +84,25 @@ export class GitApiImpl extends Disposable implements API, IGit { super(); } + async getRepositoryWorkspace(uri: vscode.Uri): Promise { + for (const [, provider] of this._providers) { + if (provider.getRepositoryWorkspace) { + return provider.getRepositoryWorkspace(uri); + } + } + return null; + } + + async clone(uri: vscode.Uri, options?: CloneOptions): Promise { + for (const [, provider] of this._providers) { + if (provider.clone) { + return provider.clone(uri, options); + } + } + return null; + } + + public get repositories(): Repository[] { const ret: Repository[] = []; diff --git a/src/authentication/githubServer.ts b/src/authentication/githubServer.ts index 5afaab432f..993fc9cda2 100644 --- a/src/authentication/githubServer.ts +++ b/src/authentication/githubServer.ts @@ -5,11 +5,11 @@ import fetch from 'cross-fetch'; import * as vscode from 'vscode'; +import { HostHelper } from './configuration'; import { GitHubServerType } from '../common/authentication'; import Logger from '../common/logger'; import { agent } from '../env/node/net'; import { getEnterpriseUri } from '../github/utils'; -import { HostHelper } from './configuration'; export class GitHubManager { private static readonly _githubDotComServers = new Set().add('github.com').add('ssh.github.com'); diff --git a/src/commands.ts b/src/commands.ts index 89c93fcc00..4844af4cfa 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -7,11 +7,10 @@ import * as pathLib from 'path'; import * as vscode from 'vscode'; import { Repository } from './api/api'; -import { GitErrorCodes, Status } from './api/api1'; +import { GitErrorCodes } from './api/api1'; import { CommentReply, findActiveHandler, resolveCommentHandler } from './commentHandlerResolver'; import { commands } from './common/executeCommands'; import Logger from './common/logger'; -import * as PersistentState from './common/persistentState'; import { FILE_LIST_LAYOUT, HIDE_VIEWED_FILES, PR_SETTINGS_NAMESPACE } from './common/settingKeys'; import { editQuery } from './common/settingsUtils'; import { ITelemetry } from './common/telemetry'; @@ -19,14 +18,13 @@ import { asTempStorageURI, fromPRUri, fromReviewUri, Schemes, toPRUri } from './ import { formatError } from './common/utils'; import { EXTENSION_ID } from './constants'; import { ICopilotRemoteAgentCommandArgs } from './github/common'; -import { ChatSessionWithPR } from './github/copilotApi'; +import { ChatSessionWithPR, CrossChatSessionWithPR } from './github/copilotApi'; import { CopilotRemoteAgentManager } from './github/copilotRemoteAgent'; import { FolderRepositoryManager } from './github/folderRepositoryManager'; import { GitHubRepository } from './github/githubRepository'; import { Issue } from './github/interface'; import { IssueModel } from './github/issueModel'; import { IssueOverviewPanel } from './github/issueOverview'; -import { NotificationProvider } from './github/notifications'; import { GHPRComment, GHPRCommentThread, TemporaryComment } from './github/prComment'; import { PullRequestModel } from './github/pullRequestModel'; import { PullRequestOverviewPanel } from './github/pullRequestOverview'; @@ -35,7 +33,8 @@ import { RepositoriesManager } from './github/repositoriesManager'; import { getIssuesUrl, getPullsUrl, isInCodespaces, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput, vscodeDevPrLink } from './github/utils'; import { OverviewContext } from './github/views'; import { isNotificationTreeItem, NotificationTreeItem } from './notifications/notificationItem'; -import { PullRequestsTreeDataProvider } from './view/prsTreeDataProvider'; +import { NotificationsManager } from './notifications/notificationsManager'; +import { PrsTreeModel } from './view/prsTreeModel'; import { ReviewCommentController } from './view/reviewCommentController'; import { ReviewManager } from './view/reviewManager'; import { ReviewsManager } from './view/reviewsManager'; @@ -51,81 +50,6 @@ import { import { PRNode } from './view/treeNodes/pullRequestNode'; import { RepositoryChangesNode } from './view/treeNodes/repositoryChangesNode'; -// Modal dialog options for handling uncommitted changes during PR checkout -const STASH_CHANGES = vscode.l10n.t('Stash changes'); -const DISCARD_CHANGES = vscode.l10n.t('Discard changes'); -const DONT_SHOW_AGAIN = vscode.l10n.t('Try to checkout anyway and don\'t show again'); - -// Constants for persistent state storage -const UNCOMMITTED_CHANGES_SCOPE = vscode.l10n.t('uncommitted changes warning'); -const UNCOMMITTED_CHANGES_STORAGE_KEY = 'showWarning'; - -/** - * Shows a modal dialog when there are uncommitted changes during PR checkout - * @param repository The git repository with uncommitted changes - * @returns Promise true if user chose to proceed (after staging/discarding), false if cancelled - */ -async function handleUncommittedChanges(repository: Repository): Promise { - // Check if user has disabled the warning using persistent state - if (PersistentState.fetch(UNCOMMITTED_CHANGES_SCOPE, UNCOMMITTED_CHANGES_STORAGE_KEY) === false) { - return true; // User has disabled warnings, proceed without showing dialog - } - - // Filter out untracked files as they typically don't conflict with PR checkout - const trackedWorkingTreeChanges = repository.state.workingTreeChanges.filter(change => change.status !== Status.UNTRACKED); - const hasTrackedWorkingTreeChanges = trackedWorkingTreeChanges.length > 0; - const hasIndexChanges = repository.state.indexChanges.length > 0; - - if (!hasTrackedWorkingTreeChanges && !hasIndexChanges) { - return true; // No tracked uncommitted changes, proceed - } - - const modalResult = await vscode.window.showInformationMessage( - vscode.l10n.t('You have uncommitted changes that might be overwritten by checking out this pull request.'), - { - modal: true, - detail: vscode.l10n.t('Choose how to handle your uncommitted changes before checking out the pull request.'), - }, - STASH_CHANGES, - DISCARD_CHANGES, - DONT_SHOW_AGAIN, - ); - - if (!modalResult) { - return false; // User cancelled - } - - if (modalResult === DONT_SHOW_AGAIN) { - // Store preference to never show this dialog again using persistent state - PersistentState.store(UNCOMMITTED_CHANGES_SCOPE, UNCOMMITTED_CHANGES_STORAGE_KEY, false); - return true; // Proceed with checkout - } - - try { - if (modalResult === STASH_CHANGES) { - // Stash all changes (working tree changes + any unstaged changes) - const allChangedFiles = [ - ...trackedWorkingTreeChanges.map(change => change.uri.fsPath), - ...repository.state.indexChanges.map(change => change.uri.fsPath), - ]; - if (allChangedFiles.length > 0) { - await repository.add(allChangedFiles); - await vscode.commands.executeCommand('git.stash', repository); - } - } else if (modalResult === DISCARD_CHANGES) { - // Discard all tracked working tree changes - const trackedWorkingTreeFiles = trackedWorkingTreeChanges.map(change => change.uri.fsPath); - if (trackedWorkingTreeFiles.length > 0) { - await repository.clean(trackedWorkingTreeFiles); - } - } - return true; // Successfully handled changes, proceed with checkout - } catch (error) { - vscode.window.showErrorMessage(vscode.l10n.t('Failed to handle uncommitted changes: {0}', formatError(error))); - return false; - } -} - function ensurePR(folderRepoManager: FolderRepositoryManager, pr?: PRNode): PullRequestModel; function ensurePR>(folderRepoManager: FolderRepositoryManager, pr?: TIssueModel): TIssueModel; function ensurePR>(folderRepoManager: FolderRepositoryManager, pr?: PRNode | TIssueModel): TIssueModel { @@ -149,7 +73,6 @@ export async function openDescription( folderManager: FolderRepositoryManager, revealNode: boolean, preserveFocus: boolean = true, - notificationProvider?: NotificationProvider ) { const issue = ensurePR(folderManager, issueModel); if (revealNode) { @@ -165,12 +88,6 @@ export async function openDescription( */ telemetry.sendTelemetryEvent('issue.openDescription'); } - - if (notificationProvider?.hasNotification(issue)) { - notificationProvider.markPrNotificationsAsRead(issue); - } - - } export async function openPullRequestOnGitHub(e: PRNode | RepositoryChangesNode | IssueModel | NotificationTreeItem, telemetry: ITelemetry) { @@ -205,13 +122,19 @@ function isChatSessionWithPR(value: any): value is ChatSessionWithPR { return !!asChatSessionWithPR.pullRequest; } +function isCrossChatSessionWithPR(value: any): value is CrossChatSessionWithPR { + const asCrossChatSessionWithPR = value as Partial; + return !!asCrossChatSessionWithPR.pullRequestDetails; +} + export function registerCommands( context: vscode.ExtensionContext, reposManager: RepositoriesManager, reviewsManager: ReviewsManager, telemetry: ITelemetry, - tree: PullRequestsTreeDataProvider, copilotRemoteAgentManager: CopilotRemoteAgentManager, + notificationManager: NotificationsManager, + prsTreeModel: PrsTreeModel ) { const logId = 'RegisterCommands'; context.subscriptions.push( @@ -226,7 +149,7 @@ export function registerCommands( if (activePullRequests.length >= 1) { const result = await chooseItem( activePullRequests, - itemValue => itemValue.html_url, + itemValue => ({ label: itemValue.html_url }), ); if (result) { openPullRequestOnGitHub(result, telemetry); @@ -263,7 +186,7 @@ export function registerCommands( ? ( await chooseItem( activePullRequestsWithFolderManager, - itemValue => itemValue.activePr.html_url, + itemValue => ({ label: itemValue.activePr.html_url }), ) ) : activePullRequestsWithFolderManager[0]; @@ -427,8 +350,6 @@ export function registerCommands( "pr.deleteLocalPullRequest.success" : {} */ telemetry.sendTelemetryEvent('pr.deleteLocalPullRequest.success'); - // fire and forget - vscode.commands.executeCommand('pr.refreshList'); } }), ); @@ -444,7 +365,7 @@ export function registerCommands( } return chooseItem( reviewsManager.reviewManagers, - itemValue => pathLib.basename(itemValue.repository.rootUri.fsPath), + itemValue => ({ label: pathLib.basename(itemValue.repository.rootUri.fsPath) }), { placeHolder: vscode.l10n.t('Choose a repository to create a pull request in'), ignoreFocusOut: true }, ); } @@ -496,37 +417,6 @@ export function registerCommands( ), ); - const switchToPr = async (folderManager: FolderRepositoryManager, pullRequestModel: PullRequestModel, repository: Repository | undefined, isFromDescription: boolean) => { - // If we don't have a repository from the node, use the one from the folder manager - const repositoryToCheck = repository || folderManager.repository; - - // Check for uncommitted changes before proceeding with checkout - const shouldProceed = await handleUncommittedChanges(repositoryToCheck); - if (!shouldProceed) { - return; // User cancelled or there was an error handling changes - } - - /* __GDPR__ - "pr.checkout" : { - "fromDescriptionPage" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - telemetry.sendTelemetryEvent('pr.checkout', { fromDescription: isFromDescription.toString() }); - - return vscode.window.withProgress( - { - location: vscode.ProgressLocation.SourceControl, - title: vscode.l10n.t('Switching to Pull Request #{0}', pullRequestModel.number), - }, - async () => { - await ReviewManager.getReviewManagerForRepository( - reviewsManager.reviewManagers, - pullRequestModel.githubRepository, - repository - )?.switch(pullRequestModel); - }); - }; - context.subscriptions.push( vscode.commands.registerCommand('pr.pick', async (pr: PRNode | RepositoryChangesNode | PullRequestModel) => { if (pr === undefined) { @@ -552,7 +442,7 @@ export function registerCommands( } const fromDescriptionPage = pr instanceof PullRequestModel; - return switchToPr(folderManager, pullRequestModel, repository, fromDescriptionPage); + return reviewsManager.switchToPr(folderManager, pullRequestModel, repository, fromDescriptionPage); })); @@ -574,7 +464,7 @@ export function registerCommands( return { folderManager, pr }; }; - const applyPullRequestChanges = async (folderManager: FolderRepositoryManager, pullRequest: PullRequestModel): Promise => { + const applyPullRequestChanges = async (task: vscode.Progress<{ message?: string; increment?: number; }>, folderManager: FolderRepositoryManager, pullRequest: PullRequestModel): Promise => { let patch: string | undefined; try { patch = await pullRequest.getPatch(); @@ -592,22 +482,13 @@ export function registerCommands( const encoder = new TextEncoder(); const tempUri = vscode.Uri.file(tempFilePath); - await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: vscode.l10n.t('Applying changes from pull request #{0}', pullRequest.number.toString()), - cancellable: false - }, - async (task) => { - await vscode.workspace.fs.writeFile(tempUri, encoder.encode(patch)); - try { - await folderManager.repository.apply(tempFilePath, false); - task.report({ message: vscode.l10n.t('Successfully applied changes from pull request #{0}', pullRequest.number.toString()), increment: 100 }); - } finally { - await vscode.workspace.fs.delete(tempUri); - } - } - ); + await vscode.workspace.fs.writeFile(tempUri, encoder.encode(patch)); + try { + await folderManager.repository.apply(tempFilePath, false); + task.report({ message: vscode.l10n.t('Successfully applied changes from pull request #{0}', pullRequest.number.toString()), increment: 100 }); + } finally { + await vscode.workspace.fs.delete(tempUri); + } } catch (error) { const errorMessage = formatError(error); @@ -636,6 +517,18 @@ export function registerCommands( return !!contextAsPath.path; } + function prNumberFromUriPath(path: string): number | undefined { + const trimPath = path.startsWith('/') ? path.substring(1) : path; + if (!Number.isNaN(Number(trimPath))) { + return Number(trimPath); + } + // This is a base64 encoded PR number like: /MTIz + const decoded = Number(Buffer.from(trimPath, 'base64').toString('utf8')); + if (!Number.isNaN(decoded)) { + return decoded; + } + } + context.subscriptions.push(vscode.commands.registerCommand('pr.checkoutFromDescription', async (ctx: OverviewContext | { path: string } | undefined) => { if (!ctx) { return vscode.window.showErrorMessage(vscode.l10n.t('No pull request context provided for checkout.')); @@ -643,9 +536,9 @@ export function registerCommands( if (contextHasPath(ctx)) { const { path } = ctx; - const prNumber = Number(Buffer.from(path.substring(1), 'base64').toString('utf8')); - if (Number.isNaN(prNumber)) { - return vscode.window.showErrorMessage(vscode.l10n.t('Unable to parse pull request number.')); + const prNumber = prNumberFromUriPath(path); + if (!prNumber) { + return vscode.window.showErrorMessage(vscode.l10n.t('No pull request number found in context path.')); } const folderManager = reposManager.folderManagers[0]; const pullRequest = await folderManager.fetchById(folderManager.gitHubRepositories[0], Number(prNumber)); @@ -653,14 +546,14 @@ export function registerCommands( return vscode.window.showErrorMessage(vscode.l10n.t('Unable to find pull request #{0}', prNumber.toString())); } - return switchToPr(folderManager, pullRequest, folderManager.repository, true); + return reviewsManager.switchToPr(folderManager, pullRequest, folderManager.repository, true); } const resolved = await resolvePr(ctx); if (!resolved) { return vscode.window.showErrorMessage(vscode.l10n.t('Unable to resolve pull request for checkout.')); } - return switchToPr(resolved.folderManager, resolved.pr, resolved.folderManager.repository, true); + return reviewsManager.switchToPr(resolved.folderManager, resolved.pr, resolved.folderManager.repository, true); })); @@ -671,29 +564,52 @@ export function registerCommands( if (contextHasPath(ctx)) { const { path } = ctx; - const prNumber = Number(Buffer.from(path.substring(1), 'base64').toString('utf8')); - if (Number.isNaN(prNumber)) { + const prNumber = prNumberFromUriPath(path); + if (!prNumber) { return vscode.window.showErrorMessage(vscode.l10n.t('Unable to parse pull request number.')); } - const folderManager = reposManager.folderManagers[0]; - const pullRequest = await folderManager.fetchById(folderManager.gitHubRepositories[0], Number(prNumber)); - if (!pullRequest) { - return vscode.window.showErrorMessage(vscode.l10n.t('Unable to find pull request #{0}', prNumber.toString())); - } - return applyPullRequestChanges(folderManager, pullRequest); - } + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: vscode.l10n.t('Applying changes from pull request #{0}', prNumber.toString()), + cancellable: false + }, + async (task) => { + task.report({ increment: 30 }); - const resolved = await resolvePr(ctx); - if (!resolved) { - return vscode.window.showErrorMessage(vscode.l10n.t('Unable to resolve pull request for applying changes.')); + const folderManager = reposManager.folderManagers[0]; + const pullRequest = await folderManager.fetchById(folderManager.gitHubRepositories[0], Number(prNumber)); + if (!pullRequest) { + return vscode.window.showErrorMessage(vscode.l10n.t('Unable to find pull request #{0}', prNumber.toString())); + } + + return applyPullRequestChanges(task, folderManager, pullRequest); + }); + + return; } - return applyPullRequestChanges(resolved.folderManager, resolved.pr); + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: vscode.l10n.t('Applying changes from pull request'), + cancellable: false + }, + async (task) => { + task.report({ increment: 30 }); + + const resolved = await resolvePr(ctx); + if (!resolved) { + return vscode.window.showErrorMessage(vscode.l10n.t('Unable to resolve pull request for applying changes.')); + } + return applyPullRequestChanges(task, resolved.folderManager, resolved.pr); + } + ); })); context.subscriptions.push( - vscode.commands.registerCommand('pr.openChanges', async (pr: PRNode | RepositoryChangesNode | PullRequestModel | OverviewContext | ChatSessionWithPR | undefined) => { + vscode.commands.registerCommand('pr.openChanges', async (pr: PRNode | RepositoryChangesNode | PullRequestModel | OverviewContext | ChatSessionWithPR | { path: string } | undefined) => { if (pr === undefined) { // This is unexpected, but has happened a few times. Logger.error('Unexpectedly received undefined when picking a PR.', logId); @@ -708,7 +624,29 @@ export function registerCommands( pullRequestModel = pr; } else if (isChatSessionWithPR(pr)) { pullRequestModel = pr.pullRequest; - } else { + } else if (isCrossChatSessionWithPR(pr)) { + const resolved = await resolvePr({ + owner: pr.pullRequestDetails.repository.owner.login, + repo: pr.pullRequestDetails.repository.name, + number: pr.pullRequestDetails.number, + preventDefaultContextMenuItems: true, + }); + pullRequestModel = resolved?.pr; + } + else if (contextHasPath(pr)) { + const { path } = pr; + const prNumber = prNumberFromUriPath(path); + if (!prNumber) { + return vscode.window.showErrorMessage(vscode.l10n.t('No pull request number found in context path.')); + } + const folderManager = reposManager.folderManagers[0]; + const pullRequest = await folderManager.fetchById(folderManager.gitHubRepositories[0], Number(prNumber)); + if (!pullRequest) { + return vscode.window.showErrorMessage(vscode.l10n.t('Unable to find pull request #{0}', prNumber.toString())); + } + pullRequestModel = pullRequest; + } + else { const resolved = await resolvePr(pr as OverviewContext); pullRequestModel = resolved?.pr; } @@ -796,7 +734,7 @@ export function registerCommands( pullRequestModel = await chooseItem(reposManager.folderManagers .map(folderManager => folderManager.activePullRequest!) .filter(activePR => !!activePR), - itemValue => `${itemValue.number}: ${itemValue.title}`, + itemValue => ({ label: `${itemValue.number}: ${itemValue.title}` }), { placeHolder: vscode.l10n.t('Choose the pull request to exit') }); } else { pullRequestModel = pr; @@ -864,7 +802,7 @@ export function registerCommands( let newPR; if (value === yes) { try { - newPR = await folderManager.mergePullRequest(pullRequest); + newPR = await pullRequest.merge(folderManager.repository); return newPR; } catch (e) { vscode.window.showErrorMessage(`Unable to merge pull request. ${formatError(e)}`); @@ -909,10 +847,8 @@ export function registerCommands( context.subscriptions.push( vscode.commands.registerCommand('pr.dismissNotification', node => { if (node instanceof PRNode) { - tree.notificationProvider.markPrNotificationsAsRead(node.pullRequestModel).then( - () => tree.refresh(node) - ); - + notificationManager.markPrNotificationsAsRead(node.pullRequestModel); + prsTreeModel.clearCopilotNotification(node.pullRequestModel.remote.owner, node.pullRequestModel.remote.repositoryName, node.pullRequestModel.number); } }), ); @@ -920,7 +856,7 @@ export function registerCommands( context.subscriptions.push( vscode.commands.registerCommand('pr.markAllCopilotNotificationsAsRead', node => { if (node instanceof CategoryTreeNode && node.isCopilot && node.repo) { - copilotRemoteAgentManager.clearAllNotifications(node.repo.owner, node.repo.repositoryName); + prsTreeModel.clearAllCopilotNotifications(node.repo.owner, node.repo.repositoryName); } }), ); @@ -934,7 +870,7 @@ export function registerCommands( if (activePullRequests.length >= 1) { issueModel = await chooseItem( activePullRequests, - itemValue => itemValue.title, + itemValue => ({ label: itemValue.title }), ); } } else { @@ -970,11 +906,17 @@ export function registerCommands( const revealDescription = !(argument instanceof PRNode); - await openDescription(telemetry, issueModel, descriptionNode, folderManager, revealDescription, !(argument instanceof RepositoryChangesNode), tree.notificationProvider); + await openDescription(telemetry, issueModel, descriptionNode, folderManager, revealDescription, !(argument instanceof RepositoryChangesNode)); } - async function checkoutChatSessionPullRequest(argument: ChatSessionWithPR) { - const pr = argument.pullRequest; + async function checkoutChatSessionPullRequest(argument: ChatSessionWithPR | CrossChatSessionWithPR) { + const pr = isChatSessionWithPR(argument) ? argument.pullRequest : await resolvePr({ + owner: argument.pullRequestDetails.repository.owner.login, + repo: argument.pullRequestDetails.repository.name, + number: argument.pullRequestDetails.number, + preventDefaultContextMenuItems: true, + }).then(resolved => resolved?.pr); + if (!pr) { Logger.warn(`No pull request found in chat session`, logId); return; @@ -986,21 +928,31 @@ export function registerCommands( return vscode.window.showErrorMessage(vscode.l10n.t('Unable to find repository for pull request #{0}', pr.number.toString())); } - return switchToPr(folderManager, pr, folderManager.repository, false); + return reviewsManager.switchToPr(folderManager, pr, folderManager.repository, false); } - async function closeChatSessionPullRequest(argument: ChatSessionWithPR) { - const pr = argument.pullRequest; + async function closeChatSessionPullRequest(argument: ChatSessionWithPR | CrossChatSessionWithPR) { + const pr = isChatSessionWithPR(argument) ? argument.pullRequest : await resolvePr({ + owner: argument.pullRequestDetails.repository.owner.login, + repo: argument.pullRequestDetails.repository.name, + number: argument.pullRequestDetails.number, + preventDefaultContextMenuItems: true, + }).then(resolved => resolved?.pr); if (!pr) { Logger.warn(`No pull request found in chat session`, logId); return; } - pr.close(); + await pr.close(); copilotRemoteAgentManager.refreshChatSessions(); } - async function cancelCodingAgent(argument: ChatSessionWithPR) { - const pr = argument.pullRequest; + async function cancelCodingAgent(argument: ChatSessionWithPR | CrossChatSessionWithPR) { + const pr = isChatSessionWithPR(argument) ? argument.pullRequest : await resolvePr({ + owner: argument.pullRequestDetails.repository.owner.login, + repo: argument.pullRequestDetails.repository.name, + number: argument.pullRequestDetails.number, + preventDefaultContextMenuItems: true, + }).then(resolved => resolved?.pr); if (!pr) { Logger.warn(`No pull request found in chat session`, logId); return; @@ -1612,7 +1564,7 @@ ${contents} .filter(activePR => !!activePR); pr = await chooseItem( activePullRequests, - itemValue => `${itemValue.number}: ${itemValue.title}`, + itemValue => ({ label: `${itemValue.number}: ${itemValue.title}` }), { placeHolder: vscode.l10n.t('Pull request to create a link for') }, ); } @@ -1668,7 +1620,7 @@ ${contents} } const githubRepo = await chooseItem<{ manager: FolderRepositoryManager, repo: GitHubRepository }>( githubRepositories, - itemValue => `${itemValue.repo.remote.owner}/${itemValue.repo.remote.repositoryName}`, + itemValue => ({ label: `${itemValue.repo.remote.owner}/${itemValue.repo.remote.repositoryName}` }), { placeHolder: vscode.l10n.t('Which GitHub repository do you want to checkout the pull request from?') } ); if (!githubRepo) { @@ -1704,7 +1656,7 @@ ${contents} }); return chooseItem( githubRepositories, - itemValue => `${itemValue.remote.owner}/${itemValue.remote.repositoryName}`, + itemValue => ({ label: `${itemValue.remote.owner}/${itemValue.remote.repositoryName}` }), { placeHolder: vscode.l10n.t('Which GitHub repository do you want to open?') } ); } @@ -1928,7 +1880,7 @@ ${contents} const pr = await chooseItem( activePullRequests, - itemValue => `${itemValue.number}: ${itemValue.title}`, + itemValue => ({ label: `${itemValue.number}: ${itemValue.title}` }), { placeHolder: vscode.l10n.t('Pull request to create a link for') }, ); if (pr) { diff --git a/src/common/comment.ts b/src/common/comment.ts index 5c3891b501..889549c996 100644 --- a/src/common/comment.ts +++ b/src/common/comment.ts @@ -4,9 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { IAccount, Reaction } from '../github/interface'; import { COPILOT_LOGINS } from './copilot'; import { DiffHunk } from './diffHunk'; +import { IAccount, Reaction } from '../github/interface'; export enum DiffSide { LEFT = 'LEFT', diff --git a/src/common/diffHunk.ts b/src/common/diffHunk.ts index e28fd0d1a4..d7b9429fa1 100644 --- a/src/common/diffHunk.ts +++ b/src/common/diffHunk.ts @@ -7,8 +7,8 @@ * Inspired by and includes code from GitHub/VisualStudio project, obtained from https://github.com/github/VisualStudio/blob/master/src/GitHub.Exports/Models/DiffLine.cs */ -import { IRawFileChange } from '../github/interface'; import { GitChangeType, InMemFileChange, SlimFileChange } from './file'; +import { IRawFileChange } from '../github/interface'; export enum DiffChangeType { Context, @@ -18,12 +18,8 @@ export enum DiffChangeType { } export class DiffLine { - public get raw(): string { - return this._raw; - } - public get text(): string { - return this._raw.substr(1); + return this.raw.substr(1); } constructor( @@ -31,7 +27,7 @@ export class DiffLine { public oldLineNumber: number /* 1 based */, public newLineNumber: number /* 1 based */, public positionInHunk: number, - private _raw: string, + public readonly raw: string, public endwithLineBreak: boolean = true, ) { } } diff --git a/src/common/emoji.ts b/src/common/emoji.ts index 1a0bea6853..f94b7a612f 100644 --- a/src/common/emoji.ts +++ b/src/common/emoji.ts @@ -13,17 +13,18 @@ const emojiRegex = /:([-+_a-z0-9]+):/g; let emojiMap: Record | undefined; let emojiMapPromise: Promise | undefined; -export async function ensureEmojis(context: ExtensionContext) { +export async function ensureEmojis(context: ExtensionContext): Promise> { if (emojiMap === undefined) { if (emojiMapPromise === undefined) { emojiMapPromise = loadEmojiMap(context); } await emojiMapPromise; } + return emojiMap!; } async function loadEmojiMap(context: ExtensionContext) { - const uri = (Uri as any).joinPath(context.extensionUri, 'resources', 'emojis.json'); + const uri = Uri.joinPath(context.extensionUri, 'resources', 'emojis.json'); emojiMap = JSON.parse(new TextDecoder('utf8').decode(await workspace.fs.readFile(uri))); } diff --git a/src/common/executeCommands.ts b/src/common/executeCommands.ts index bf550fd2ce..dee2f69f39 100644 --- a/src/common/executeCommands.ts +++ b/src/common/executeCommands.ts @@ -40,4 +40,8 @@ export namespace commands { export function setContext(context: string, value: any) { return executeCommand('setContext', context, value); } + + export function openFolder(ur: vscode.Uri, options: { forceNewWindow?: boolean, forceReuseWindow?: boolean }) { + return executeCommand('vscode.openFolder', ur, options); + } } \ No newline at end of file diff --git a/src/common/githubRef.ts b/src/common/githubRef.ts index 867a6f0838..a3803173ab 100644 --- a/src/common/githubRef.ts +++ b/src/common/githubRef.ts @@ -3,9 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Remote, Repository } from '../api/api'; import { Protocol } from './protocol'; import { parseRemote } from './remote'; +import { Remote, Repository } from '../api/api'; export class GitHubRef { public repositoryCloneUrl: Protocol; diff --git a/src/common/protocol.ts b/src/common/protocol.ts index 6672a7bd3e..cac2471f14 100644 --- a/src/common/protocol.ts +++ b/src/common/protocol.ts @@ -4,8 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { resolve } from '../env/node/ssh'; import Logger from './logger'; +import { resolve } from '../env/node/ssh'; export enum ProtocolType { diff --git a/src/common/remote.ts b/src/common/remote.ts index 96671830ab..ae3e34ae23 100644 --- a/src/common/remote.ts +++ b/src/common/remote.ts @@ -3,10 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Repository } from '../api/api'; -import { getEnterpriseUri, isEnterprise } from '../github/utils'; import { AuthProvider, GitHubServerType } from './authentication'; import { Protocol } from './protocol'; +import { Repository } from '../api/api'; +import { getEnterpriseUri, isEnterprise } from '../github/utils'; export class Remote { public get host(): string { diff --git a/src/common/resources.ts b/src/common/resources.ts deleted file mode 100644 index 915158ebaf..0000000000 --- a/src/common/resources.ts +++ /dev/null @@ -1,37 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as path from 'path'; -import * as vscode from 'vscode'; - -export class Resource { - static icons: { - reactions: { - THUMBS_UP: string; - THUMBS_DOWN: string; - CONFUSED: string; - EYES: string; - HEART: string; - HOORAY: string; - LAUGH: string; - ROCKET: string; - }; - }; - - static initialize(context: vscode.ExtensionContext) { - Resource.icons = { - reactions: { - THUMBS_UP: context.asAbsolutePath(path.join('resources', 'icons', 'reactions', 'thumbs_up.png')), - THUMBS_DOWN: context.asAbsolutePath(path.join('resources', 'icons', 'reactions', 'thumbs_down.png')), - CONFUSED: context.asAbsolutePath(path.join('resources', 'icons', 'reactions', 'confused.png')), - EYES: context.asAbsolutePath(path.join('resources', 'icons', 'reactions', 'eyes.png')), - HEART: context.asAbsolutePath(path.join('resources', 'icons', 'reactions', 'heart.png')), - HOORAY: context.asAbsolutePath(path.join('resources', 'icons', 'reactions', 'hooray.png')), - LAUGH: context.asAbsolutePath(path.join('resources', 'icons', 'reactions', 'laugh.png')), - ROCKET: context.asAbsolutePath(path.join('resources', 'icons', 'reactions', 'rocket.png')), - }, - }; - } -} diff --git a/src/common/settingKeys.ts b/src/common/settingKeys.ts index fa1fb63b60..cb696a3d8a 100644 --- a/src/common/settingKeys.ts +++ b/src/common/settingKeys.ts @@ -6,6 +6,7 @@ export const PR_SETTINGS_NAMESPACE = 'githubPullRequests'; export const TERMINAL_LINK_HANDLER = 'terminalLinksHandler'; export const BRANCH_PUBLISH = 'createOnPublishBranch'; +export const BRANCH_LIST_TIMEOUT = 'branchListTimeout'; export const USE_REVIEW_MODE = 'useReviewMode'; export const FILE_LIST_LAYOUT = 'fileListLayout'; export const HIDE_VIEWED_FILES = 'hideViewedFiles'; @@ -18,6 +19,7 @@ export const OVERRIDE_DEFAULT_BRANCH = 'overrideDefaultBranch'; export const PULL_BRANCH = 'pullBranch'; export const PULL_REQUEST_DESCRIPTION = 'pullRequestDescription'; export const NOTIFICATION_SETTING = 'notifications'; +export type NotificationVariants = 'off' | 'pullRequests'; export const POST_CREATE = 'postCreate'; export const POST_DONE = 'postDone'; export const QUERIES = 'queries'; @@ -37,6 +39,7 @@ export type PullPRBranchVariants = 'never' | 'pull' | 'pullAndMergeBase' | 'pull export const UPSTREAM_REMOTE = 'upstreamRemote'; export const DEFAULT_CREATE_OPTION = 'defaultCreateOption'; export const CREATE_BASE_BRANCH = 'createDefaultBaseBranch'; +export const AUTO_STASH_ON_CHECKOUT = 'autoStashOnCheckout'; export const ISSUES_SETTINGS_NAMESPACE = 'githubIssues'; export const ASSIGN_WHEN_WORKING = 'assignWhenWorking'; @@ -68,6 +71,8 @@ export const PULL_BEFORE_CHECKOUT = 'pullBeforeCheckout'; export const OPEN_DIFF_ON_CLICK = 'openDiffOnClick'; export const SHOW_INLINE_OPEN_FILE_ACTION = 'showInlineOpenFileAction'; export const AUTO_STASH = 'autoStash'; +export const BRANCH_WHITESPACE_CHAR = 'branchWhitespaceChar'; +export const BRANCH_RANDOM_NAME_DICTIONARY = 'branchRandomName.dictionary'; // GitHub Enterprise export const GITHUB_ENTERPRISE = 'github-enterprise'; @@ -94,4 +99,5 @@ export const COLOR_THEME = 'colorTheme'; export const CODING_AGENT = `${PR_SETTINGS_NAMESPACE}.codingAgent`; export const CODING_AGENT_ENABLED = 'enabled'; export const CODING_AGENT_AUTO_COMMIT_AND_PUSH = 'autoCommitAndPush'; -export const CODING_AGENT_PROMPT_FOR_CONFIRMATION = 'promptForConfirmation'; \ No newline at end of file +export const CODING_AGENT_PROMPT_FOR_CONFIRMATION = 'promptForConfirmation'; +export const SHOW_CODE_LENS = 'codeLens'; \ No newline at end of file diff --git a/src/common/timelineEvent.ts b/src/common/timelineEvent.ts index 6475251fb5..2f719c5f2e 100644 --- a/src/common/timelineEvent.ts +++ b/src/common/timelineEvent.ts @@ -3,8 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IAccount, IActor, Reaction } from '../github/interface'; import { IComment } from './comment'; +import { IAccount, IActor, Reaction } from '../github/interface'; export enum EventType { Committed, diff --git a/src/common/uri.ts b/src/common/uri.ts index b4432e021a..7258baa2c3 100644 --- a/src/common/uri.ts +++ b/src/common/uri.ts @@ -12,12 +12,12 @@ import * as vscode from 'vscode'; import { RemoteInfo } from '../../common/types'; import { Repository } from '../api/api'; import { EXTENSION_ID } from '../constants'; -import { IAccount, isITeam, ITeam, reviewerId } from '../github/interface'; -import { PullRequestModel } from '../github/pullRequestModel'; import { GitChangeType } from './file'; import Logger from './logger'; import { TemporaryState } from './temporaryState'; import { compareIgnoreCase } from './utils'; +import { IAccount, isITeam, ITeam, reviewerId } from '../github/interface'; +import { PullRequestModel } from '../github/pullRequestModel'; export interface ReviewUriParams { path: string; @@ -53,7 +53,8 @@ export function fromPRUri(uri: vscode.Uri): PRUriParams | undefined { } export interface PRNodeUriParams { - prIdentifier: string + prIdentifier: string; + showCopilot?: boolean; } export function fromPRNodeUri(uri: vscode.Uri): PRNodeUriParams | undefined { @@ -205,7 +206,17 @@ export namespace DataUri { const iconsFolder = 'userIcons'; function iconFilename(user: IAccount | ITeam): string { - return `${reviewerId(user)}.jpg`; + // Include avatarUrl hash to invalidate cache when URL changes + const baseId = reviewerId(user); + if (user.avatarUrl) { + // Create a simple hash of the URL to detect changes + const urlHash = user.avatarUrl.split('').reduce((a, b) => { + a = ((a << 5) - a) + b.charCodeAt(0); + return a & a; + }, 0); + return `${baseId}_${Math.abs(urlHash)}.jpg`; + } + return `${baseId}.jpg`; } function cacheLocation(context: vscode.ExtensionContext): vscode.Uri { @@ -291,7 +302,6 @@ export namespace DataUri { const startingCacheSize = cacheLogOrder.length; const results = await Promise.all(users.map(async (user) => { - const imageSourceUrl = user.avatarUrl; if (imageSourceUrl === undefined) { return undefined; @@ -311,6 +321,9 @@ export namespace DataUri { cacheMiss = true; const doFetch = async () => { const response = await fetch(imageSourceUrl.toString()); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } const buffer = await response.arrayBuffer(); await writeAvatarToCache(context, user, new Uint8Array(buffer)); innerImageContents = Buffer.from(buffer); @@ -489,12 +502,15 @@ export function parsePRNodeIdentifier(identifier: string): { remote: string, prN } export function createPRNodeUri( - pullRequest: PullRequestModel | { remote: string, prNumber: number } | string + pullRequest: PullRequestModel | { remote: string, prNumber: number } | string, showCopilot?: boolean ): vscode.Uri { const identifier = createPRNodeIdentifier(pullRequest); const params: PRNodeUriParams = { prIdentifier: identifier, }; + if (showCopilot !== undefined) { + params.showCopilot = showCopilot; + } const uri = vscode.Uri.parse(`PRNode:${identifier}`); @@ -620,6 +636,7 @@ function validateOpenWebviewParams(owner?: string, repo?: string, number?: strin export enum UriHandlerPaths { OpenIssueWebview = '/open-issue-webview', OpenPullRequestWebview = '/open-pull-request-webview', + CheckoutPullRequest = '/checkout-pull-request' } export interface OpenIssueWebviewUriParams { @@ -660,14 +677,37 @@ export async function toOpenPullRequestWebviewUri(params: OpenPullRequestWebview return vscode.env.asExternalUri(vscode.Uri.from({ scheme: vscode.env.uriScheme, authority: EXTENSION_ID, path: UriHandlerPaths.OpenPullRequestWebview, query })); } -export function fromOpenPullRequestWebviewUri(uri: vscode.Uri): OpenPullRequestWebviewUriParams | undefined { +export function fromOpenOrCheckoutPullRequestWebviewUri(uri: vscode.Uri): OpenPullRequestWebviewUriParams | undefined { if (compareIgnoreCase(uri.authority, EXTENSION_ID) !== 0) { return; } - if (uri.path !== UriHandlerPaths.OpenPullRequestWebview) { + if (uri.path !== UriHandlerPaths.OpenPullRequestWebview && uri.path !== UriHandlerPaths.CheckoutPullRequest) { return; } try { + // Check if the query uses the new simplified format: uri=https://github.com/owner/repo/pull/number + const queryParams = new URLSearchParams(uri.query); + const uriParam = queryParams.get('uri'); + if (uriParam) { + // Parse the GitHub PR URL - match only exact format ending with the PR number + // Use named regex groups for clarity + const prUrlRegex = /^https?:\/\/github\.com\/(?[^\/]+)\/(?[^\/]+)\/pull\/(?\d+)$/; + const match = prUrlRegex.exec(uriParam); + if (match && match.groups) { + const { owner, repo, pullRequestNumber } = match.groups; + const params = { + owner, + repo, + pullRequestNumber: parseInt(pullRequestNumber, 10) + }; + if (!validateOpenWebviewParams(params.owner, params.repo, params.pullRequestNumber.toString())) { + return; + } + return params; + } + } + + // Fall back to the old JSON format for backward compatibility const query = JSON.parse(uri.query.split('&')[0]); if (!validateOpenWebviewParams(query.owner, query.repo, query.pullRequestNumber)) { return; diff --git a/src/common/utils.ts b/src/common/utils.ts index a9832fdbf5..470e0a5379 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -366,6 +366,10 @@ export interface Predicate { (input: T): boolean; } +export interface AsyncPredicate { + (input: T): Promise; +} + export const enum CharCode { Period = 46, /** @@ -982,6 +986,16 @@ export async function stringReplaceAsync(str: string, regex: RegExp, asyncFn: (s return str.replace(regex, () => data[offset++]); } +export async function arrayFindIndexAsync(arr: T[], predicate: (value: T, index: number, array: T[]) => Promise): Promise { + for (let i = 0; i < arr.length; i++) { + // Evaluate predicate sequentially to allow early exit on first match + if (await predicate(arr[i], i, arr)) { + return i; + } + } + return -1; +} + export async function batchPromiseAll(items: readonly T[], batchSize: number, processFn: (item: T) => Promise): Promise { const batches = Math.ceil(items.length / batchSize); @@ -995,3 +1009,9 @@ export function escapeRegExp(string: string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } +export function truncate(value: string, maxLength: number, suffix = '...'): string { + if (value.length <= maxLength) { + return value; + } + return `${value.substr(0, maxLength)}${suffix}`; +} \ No newline at end of file diff --git a/src/common/uuid.ts b/src/common/uuid.ts new file mode 100644 index 0000000000..bdccc3f5db --- /dev/null +++ b/src/common/uuid.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Copied from vscode/src/vs/base/common/uuid.ts + */ +export function generateUuid(): string { + // use `randomUUID` if possible + if (typeof crypto.randomUUID === 'function') { + // see https://developer.mozilla.org/en-US/docs/Web/API/Window/crypto + // > Although crypto is available on all windows, the returned Crypto object only has one + // > usable feature in insecure contexts: the getRandomValues() method. + // > In general, you should use this API only in secure contexts. + + return crypto.randomUUID.bind(crypto)(); + } + + // prep-work + const _data = new Uint8Array(16); + const _hex: string[] = []; + for (let i = 0; i < 256; i++) { + _hex.push(i.toString(16).padStart(2, '0')); + } + + // get data + crypto.getRandomValues(_data); + + // set version bits + _data[6] = (_data[6] & 0x0f) | 0x40; + _data[8] = (_data[8] & 0x3f) | 0x80; + + // print as string + let i = 0; + let result = ''; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += '-'; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += '-'; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += '-'; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += '-'; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + return result; +} \ No newline at end of file diff --git a/src/common/webview.ts b/src/common/webview.ts index 02b59a029a..f887fd349b 100644 --- a/src/common/webview.ts +++ b/src/common/webview.ts @@ -22,15 +22,6 @@ export interface IReplyMessage { res?: any; } -export function getNonce() { - let text = ''; - const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - for (let i = 0; i < 32; i++) { - text += possible.charAt(Math.floor(Math.random() * possible.length)); - } - return text; -} - export class WebviewBase extends Disposable { protected _webview?: vscode.Webview; diff --git a/src/extension.ts b/src/extension.ts index b9acc595b5..78f8936cb0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,9 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -'use strict'; import TelemetryReporter from '@vscode/extension-telemetry'; import * as vscode from 'vscode'; + import { LiveShare } from 'vsls/vscode.js'; import { PostCommitCommandsProvider, Repository } from './api/api'; import { GitApiImpl } from './api/api1'; @@ -16,7 +16,6 @@ import { isSubmodule } from './common/gitUtils'; import Logger from './common/logger'; import * as PersistentState from './common/persistentState'; import { parseRepositoryRemotes } from './common/remote'; -import { Resource } from './common/resources'; import { BRANCH_PUBLISH, EXPERIMENTAL_CHAT, FILE_LIST_LAYOUT, GIT, IGNORE_SUBMODULES, OPEN_DIFF_ON_CLICK, PR_SETTINGS_NAMESPACE, SHOW_INLINE_OPEN_FILE_ACTION } from './common/settingKeys'; import { initBasedOnSettingChange } from './common/settingsUtils'; import { TemporaryState } from './common/temporaryState'; @@ -37,17 +36,20 @@ import { ChatParticipant, ChatParticipantState } from './lm/participants'; import { registerTools } from './lm/tools/tools'; import { migrate } from './migrations'; import { NotificationsFeatureRegister } from './notifications/notificationsFeatureRegistar'; +import { NotificationsManager } from './notifications/notificationsManager'; +import { NotificationsProvider } from './notifications/notificationsProvider'; import { ThemeWatcher } from './themeWatcher'; -import { UriHandler } from './uriHandler'; +import { resumePendingCheckout, UriHandler } from './uriHandler'; import { CommentDecorationProvider } from './view/commentDecorationProvider'; import { CompareChanges } from './view/compareChangesTreeDataProvider'; import { CreatePullRequestHelper } from './view/createPullRequestHelper'; +import { EmojiCompletionProvider } from './view/emojiCompletionProvider'; import { FileTypeDecorationProvider } from './view/fileTypeDecorationProvider'; import { GitHubCommitFileSystemProvider } from './view/githubFileContentProvider'; import { getInMemPRFileSystemProvider } from './view/inMemPRContentProvider'; import { PullRequestChangesTreeDataProvider } from './view/prChangesTreeDataProvider'; -import { PRNotificationDecorationProvider } from './view/prNotificationDecorationProvider'; import { PullRequestsTreeDataProvider } from './view/prsTreeDataProvider'; +import { PrsTreeModel } from './view/prsTreeModel'; import { ReviewManager, ShowPullRequest } from './view/reviewManager'; import { ReviewsManager } from './view/reviewsManager'; import { TreeDecorationProviders } from './view/treeDecorationProviders'; @@ -70,7 +72,8 @@ async function init( reposManager: RepositoriesManager, createPrHelper: CreatePullRequestHelper, copilotRemoteAgentManager: CopilotRemoteAgentManager, - themeWatcher: ThemeWatcher + themeWatcher: ThemeWatcher, + prsTreeModel: PrsTreeModel, ): Promise { context.subscriptions.push(Logger); Logger.appendLine('Git repository found, initializing review manager and pr tree view.', ACTIVATION); @@ -170,9 +173,21 @@ async function init( context.subscriptions.push(treeDecorationProviders); treeDecorationProviders.registerProviders([new FileTypeDecorationProvider(), new CommentDecorationProvider(reposManager)]); - const reviewsManager = new ReviewsManager(context, reposManager, reviewManagers, tree, changesTree, telemetry, credentialStore, git, copilotRemoteAgentManager); + const notificationsProvider = new NotificationsProvider(credentialStore, reposManager); + context.subscriptions.push(notificationsProvider); + + const notificationsManager = new NotificationsManager(notificationsProvider, credentialStore, reposManager, context); + context.subscriptions.push(notificationsManager); + + const reviewsManager = new ReviewsManager(context, reposManager, reviewManagers, prsTreeModel, tree, changesTree, telemetry, credentialStore, git, copilotRemoteAgentManager, notificationsManager); context.subscriptions.push(reviewsManager); + context.subscriptions.push(vscode.languages.registerCompletionItemProvider( + { scheme: Schemes.Comment }, + new EmojiCompletionProvider(context), + ':' + )); + git.onDidChangeState(() => { Logger.appendLine(`Git initialization state changed: state=${git.state}`, ACTIVATION); reviewsManager.reviewManagers.forEach(reviewManager => reviewManager.updateState(true)); @@ -212,13 +227,12 @@ async function init( reviewsManager.addReviewManager(newReviewManager); } - // Check if repo is in one of the workspace folders - if (workspaceFolders && !workspaceFolders.some(folder => isDescendant(folder.uri.fsPath, repo.rootUri.fsPath))) { + // Check if repo is in one of the workspace folders or vice versa + if (workspaceFolders && !workspaceFolders.some(folder => isDescendant(folder.uri.fsPath, repo.rootUri.fsPath) || isDescendant(repo.rootUri.fsPath, folder.uri.fsPath))) { Logger.appendLine(`Repo ${repo.rootUri} is not in a workspace folder, ignoring.`, ACTIVATION); return; } addRepo(); - tree.notificationProvider.refreshOrLaunchPolling(); const disposable = repo.state.onDidChange(() => { Logger.appendLine(`Repo state for ${repo.rootUri} changed.`, ACTIVATION); addRepo(); @@ -229,14 +243,11 @@ async function init( git.onDidCloseRepository(repo => { reposManager.removeRepo(repo); reviewsManager.removeReviewManager(repo); - tree.notificationProvider.refreshOrLaunchPolling(); }); - tree.initialize(reviewsManager.reviewManagers.map(manager => manager.reviewModel), credentialStore); - - context.subscriptions.push(new PRNotificationDecorationProvider(tree.notificationProvider)); + tree.initialize(reviewsManager.reviewManagers.map(manager => manager.reviewModel), notificationsManager); - registerCommands(context, reposManager, reviewsManager, telemetry, tree, copilotRemoteAgentManager); + registerCommands(context, reposManager, reviewsManager, telemetry, copilotRemoteAgentManager, notificationsManager, prsTreeModel); const layout = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(FILE_LIST_LAYOUT); await vscode.commands.executeCommand('setContext', 'fileListLayout:flat', layout === 'flat'); @@ -245,7 +256,7 @@ async function init( context.subscriptions.push(issuesFeatures); await issuesFeatures.initialize(); - const notificationsFeatures = new NotificationsFeatureRegister(credentialStore, reposManager, telemetry, context); + const notificationsFeatures = new NotificationsFeatureRegister(credentialStore, reposManager, telemetry, notificationsManager); context.subscriptions.push(notificationsFeatures); context.subscriptions.push(new GitLensIntegration()); @@ -254,10 +265,13 @@ async function init( await vscode.commands.executeCommand('setContext', 'github:initialized', true); - registerPostCommitCommandsProvider(reposManager, git); + registerPostCommitCommandsProvider(context, reposManager, git); + + // Resume any pending checkout request stored before workspace reopened. + await resumePendingCheckout(reviewsManager, context, reposManager); - initChat(context, credentialStore, reposManager, copilotRemoteAgentManager, telemetry); - context.subscriptions.push(vscode.window.registerUriHandler(new UriHandler(reposManager, telemetry, context))); + initChat(context, credentialStore, reposManager, copilotRemoteAgentManager, telemetry, prsTreeModel); + context.subscriptions.push(vscode.window.registerUriHandler(new UriHandler(reposManager, reviewsManager, telemetry, context, git))); // Make sure any compare changes tabs, which come from the create flow, are closed. CompareChanges.closeTabs(); @@ -267,11 +281,11 @@ async function init( telemetry.sendTelemetryEvent('startup'); } -function initChat(context: vscode.ExtensionContext, credentialStore: CredentialStore, reposManager: RepositoriesManager, copilotRemoteManager: CopilotRemoteAgentManager, telemetry: ExperimentationTelemetry) { +function initChat(context: vscode.ExtensionContext, credentialStore: CredentialStore, reposManager: RepositoriesManager, copilotRemoteManager: CopilotRemoteAgentManager, telemetry: ExperimentationTelemetry, prsTreeModel: PrsTreeModel) { const createParticipant = () => { const chatParticipantState = new ChatParticipantState(); context.subscriptions.push(new ChatParticipant(context, chatParticipantState)); - registerTools(context, credentialStore, reposManager, chatParticipantState, copilotRemoteManager, telemetry); + registerTools(context, credentialStore, reposManager, chatParticipantState, copilotRemoteManager, telemetry, prsTreeModel); }; const chatEnabled = () => vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(EXPERIMENTAL_CHAT, false); @@ -284,7 +298,7 @@ function initChat(context: vscode.ExtensionContext, credentialStore: CredentialS export async function activate(context: vscode.ExtensionContext): Promise { Logger.appendLine(`Extension version: ${vscode.extensions.getExtension(EXTENSION_ID)?.packageJSON.version}`, 'Activation'); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore if (EXTENSION_ID === 'GitHub.vscode-pull-request-github-insiders') { const stable = vscode.extensions.getExtension('github.vscode-pull-request-github'); @@ -303,8 +317,6 @@ export async function activate(context: vscode.ExtensionContext): Promise prev + curr.gitHubRepositories.length, 0)} GitHub repositories.`, componentId); + Logger.appendLine(`Looking for remote. Comparing ${repository.state.remotes.length} local repo remotes with ${reposManager.folderManagers.reduce((prev, curr) => prev + curr.gitHubRepositories.length, 0)} GitHub repositories.`, componentId); const repoRemotes = parseRepositoryRemotes(repository); const found = reposManager.folderManagers.find(folderManager => folderManager.findRepo(githubRepo => { @@ -351,7 +363,7 @@ function registerPostCommitCommandsProvider(reposManager: RepositoriesManager, g return remote.equals(githubRepo.remote); }); })); - Logger.debug(`Found ${found ? 'a repo' : 'no repos'} when getting post commit commands.`, componentId); + Logger.appendLine(`Found ${found ? 'a repo' : 'no repos'} when getting post commit commands.`, componentId); return found ? [{ command: 'pr.pushAndCreate', title: vscode.l10n.t('{0} Commit & Create Pull Request', '$(git-pull-request-create)'), @@ -364,10 +376,10 @@ function registerPostCommitCommandsProvider(reposManager: RepositoriesManager, g return reposManager.folderManagers.some(folderManager => folderManager.gitHubRepositories.length > 0); } function tryRegister(): boolean { - Logger.debug('Trying to register post commit commands.', 'GitPostCommitCommands'); + Logger.appendLine('Trying to register post commit commands.', 'GitPostCommitCommands'); if (hasGitHubRepos()) { - Logger.debug('GitHub remote(s) found, registering post commit commands.', componentId); - git.registerPostCommitCommandsProvider(new Provider()); + Logger.appendLine('GitHub remote(s) found, registering post commit commands.', componentId); + context.subscriptions.push(git.registerPostCommitCommandsProvider(new Provider())); return true; } return false; @@ -383,7 +395,7 @@ function registerPostCommitCommandsProvider(reposManager: RepositoriesManager, g } async function deferredActivateRegisterBuiltInGitProvider(context: vscode.ExtensionContext, apiImpl: GitApiImpl, credentialStore: CredentialStore) { - Logger.debug('Registering built in git provider.', 'Activation'); + Logger.appendLine('Registering built in git provider.', 'Activation'); if (!(await doRegisterBuiltinGitProvider(context, credentialStore, apiImpl))) { const extensionsChangedDisposable = vscode.extensions.onDidChange(async () => { if (await doRegisterBuiltinGitProvider(context, credentialStore, apiImpl)) { @@ -409,6 +421,10 @@ async function deferredActivate(context: vscode.ExtensionContext, showPRControll const reposManager = new RepositoriesManager(credentialStore, telemetry); context.subscriptions.push(reposManager); + + const prsTreeModel = new PrsTreeModel(telemetry, reposManager, context); + context.subscriptions.push(prsTreeModel); + // API const apiImpl = new GitApiImpl(reposManager); context.subscriptions.push(apiImpl); @@ -424,7 +440,7 @@ async function deferredActivate(context: vscode.ExtensionContext, showPRControll Logger.debug('Creating tree view.', 'Activation'); - const copilotRemoteAgentManager = new CopilotRemoteAgentManager(credentialStore, reposManager, telemetry, context, apiImpl); + const copilotRemoteAgentManager = new CopilotRemoteAgentManager(credentialStore, reposManager, telemetry, context, apiImpl, prsTreeModel); context.subscriptions.push(copilotRemoteAgentManager); if (vscode.chat?.registerChatSessionItemProvider) { const chatParticipant = vscode.chat.createChatParticipant(COPILOT_SWE_AGENT, async (request, context, stream, token) => @@ -434,12 +450,12 @@ async function deferredActivate(context: vscode.ExtensionContext, showPRControll const provider = new class implements vscode.ChatSessionContentProvider, vscode.ChatSessionItemProvider { label = vscode.l10n.t('GitHub Copilot Coding Agent'); - provideChatSessionItems = async (token) => { + async provideChatSessionItems(token: vscode.CancellationToken) { return await copilotRemoteAgentManager.provideChatSessions(token); - }; - provideChatSessionContent = async (id, token) => { - return await copilotRemoteAgentManager.provideChatSessionContent(id, token); - }; + } + async provideChatSessionContent(resource: vscode.Uri, token: vscode.CancellationToken) { + return await copilotRemoteAgentManager.provideChatSessionContent(resource, token); + } onDidChangeChatSessionItems = copilotRemoteAgentManager.onDidChangeChatSessions; onDidCommitChatSessionItem = copilotRemoteAgentManager.onDidCommitChatSession; }(); @@ -457,7 +473,7 @@ async function deferredActivate(context: vscode.ExtensionContext, showPRControll )); } - const prTree = new PullRequestsTreeDataProvider(telemetry, context, reposManager, copilotRemoteAgentManager); + const prTree = new PullRequestsTreeDataProvider(prsTreeModel, telemetry, context, reposManager, copilotRemoteAgentManager); context.subscriptions.push(prTree); context.subscriptions.push(credentialStore.onDidGetSession(() => prTree.refreshAll(true))); Logger.appendLine('Looking for git repository', ACTIVATION); @@ -485,7 +501,7 @@ async function deferredActivate(context: vscode.ExtensionContext, showPRControll const githubFilesystemProvider = new GitHubCommitFileSystemProvider(reposManager, apiImpl, credentialStore); context.subscriptions.push(vscode.workspace.registerFileSystemProvider(Schemes.GitHubCommit, githubFilesystemProvider, { isReadonly: new vscode.MarkdownString(vscode.l10n.t('GitHub commits cannot be edited')) })); - await init(context, apiImpl, credentialStore, repositories, prTree, liveshareApiPromise, showPRController, reposManager, createPrHelper, copilotRemoteAgentManager, themeWatcher); + await init(context, apiImpl, credentialStore, repositories, prTree, liveshareApiPromise, showPRController, reposManager, createPrHelper, copilotRemoteAgentManager, themeWatcher, prsTreeModel); return apiImpl; } diff --git a/src/gitProviders/api.ts b/src/gitProviders/api.ts index 353a957dff..cd275d7f93 100644 --- a/src/gitProviders/api.ts +++ b/src/gitProviders/api.ts @@ -4,10 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { API } from '../api/api'; -import { CredentialStore } from '../github/credentials'; import { BuiltinGitProvider } from './builtinGit'; import { LiveShareManager } from './vsls'; +import { API } from '../api/api'; +import { CredentialStore } from '../github/credentials'; export function registerLiveShareGitProvider(apiImpl: API): LiveShareManager { const liveShareManager = new LiveShareManager(apiImpl); diff --git a/src/gitProviders/builtinGit.ts b/src/gitProviders/builtinGit.ts index fc42a50cc5..bb6df80374 100644 --- a/src/gitProviders/builtinGit.ts +++ b/src/gitProviders/builtinGit.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { APIState, GitAPI, GitExtension, PublishEvent } from '../@types/git'; +import { APIState, CloneOptions, GitAPI, GitExtension, PublishEvent } from '../@types/git'; import { IGit, Repository } from '../api/api'; import { commands } from '../common/executeCommands'; import { Disposable } from '../common/lifecycle'; @@ -41,11 +41,18 @@ export class BuiltinGitProvider extends Disposable implements IGit { throw e; } - this._register(this._gitAPI.onDidCloseRepository(e => this._onDidCloseRepository.fire(e as any))); - this._register(this._gitAPI.onDidOpenRepository(e => this._onDidOpenRepository.fire(e as any))); + this._register(this._gitAPI.onDidCloseRepository(e => this._onDidCloseRepository.fire(e))); + this._register(this._gitAPI.onDidOpenRepository(e => this._onDidOpenRepository.fire(e))); this._register(this._gitAPI.onDidChangeState(e => this._onDidChangeState.fire(e))); this._register(this._gitAPI.onDidPublish(e => this._onDidPublish.fire(e))); } + getRepositoryWorkspace(uri: vscode.Uri): Promise { + return this._gitAPI.getRepositoryWorkspace(uri); + } + + clone(uri: vscode.Uri, options?: CloneOptions): Promise { + return this._gitAPI.clone(uri, options); + } static async createProvider(): Promise { const extension = vscode.extensions.getExtension('vscode.git'); diff --git a/src/gitProviders/vsls.ts b/src/gitProviders/vsls.ts index c58783ea8f..a5eeee9c9d 100644 --- a/src/gitProviders/vsls.ts +++ b/src/gitProviders/vsls.ts @@ -4,11 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; + import { LiveShare } from 'vsls/vscode.js'; -import { API } from '../api/api'; -import { Disposable, disposeAll } from '../common/lifecycle'; import { VSLSGuest } from './vslsguest'; import { VSLSHost } from './vslshost'; +import { API } from '../api/api'; +import { Disposable, disposeAll } from '../common/lifecycle'; /** * Should be removed once we fix the webpack bundling issue. diff --git a/src/gitProviders/vslsguest.ts b/src/gitProviders/vslsguest.ts index e98329d03f..fd823781af 100644 --- a/src/gitProviders/vslsguest.ts +++ b/src/gitProviders/vslsguest.ts @@ -4,8 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; + import { LiveShare, SharedServiceProxy } from 'vsls/vscode.js'; -import { Branch, Change, Commit, Remote, RepositoryState, Submodule } from '../@types/git'; +import { Branch, Change, Commit, Ref, Remote, RepositoryState, Submodule } from '../@types/git'; import { IGit, Repository } from '../api/api'; import { Disposable } from '../common/lifecycle'; import { @@ -114,7 +115,7 @@ export class VSLSGuest extends Disposable implements IGit { } public getRepository(folder: vscode.WorkspaceFolder): Repository { - return this._openRepositories.filter(repository => (repository as any).workspaceFolder === folder)[0]; + return this._openRepositories.filter(repository => (repository as (Repository & { workspaceFolder: vscode.WorkspaceFolder })).workspaceFolder === folder)[0]; } } @@ -148,6 +149,8 @@ class LiveShareRepositoryState implements RepositoryState { this.HEAD = state.HEAD; this.remotes = state.remotes; } + refs: Ref[] = []; + untrackedChanges: Change[] = []; public update(state: RepositoryState) { this.HEAD = state.HEAD; diff --git a/src/gitProviders/vslshost.ts b/src/gitProviders/vslshost.ts index 23b1d3706a..0cbb235761 100644 --- a/src/gitProviders/vslshost.ts +++ b/src/gitProviders/vslshost.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; + import { LiveShare, SharedService } from 'vsls/vscode.js'; import { API } from '../api/api'; import { Disposable } from '../common/lifecycle'; diff --git a/src/github/activityBarViewProvider.ts b/src/github/activityBarViewProvider.ts index 7125c35b38..4fb936d851 100644 --- a/src/github/activityBarViewProvider.ts +++ b/src/github/activityBarViewProvider.ts @@ -5,13 +5,6 @@ import * as vscode from 'vscode'; import { openPullRequestOnGitHub } from '../commands'; -import { IComment } from '../common/comment'; -import { emojify, ensureEmojis } from '../common/emoji'; -import { disposeAll } from '../common/lifecycle'; -import { ReviewEvent } from '../common/timelineEvent'; -import { formatError } from '../common/utils'; -import { getNonce, IRequestMessage, WebviewViewBase } from '../common/webview'; -import { ReviewManager } from '../view/reviewManager'; import { FolderRepositoryManager } from './folderRepositoryManager'; import { GithubItemStateEnum, IAccount, isITeam, ITeam, PullRequestMergeability, reviewerId, ReviewEventEnum, ReviewState } from './interface'; import { PullRequestModel } from './pullRequestModel'; @@ -19,10 +12,20 @@ import { getDefaultMergeMethod } from './pullRequestOverview'; import { PullRequestView } from './pullRequestOverviewCommon'; import { isInCodespaces, parseReviewers } from './utils'; import { MergeArguments, PullRequest, ReviewType, SubmitReviewReply } from './views'; +import { IComment } from '../common/comment'; +import { emojify, ensureEmojis } from '../common/emoji'; +import { disposeAll } from '../common/lifecycle'; +import Logger from '../common/logger'; +import { ReviewEvent } from '../common/timelineEvent'; +import { formatError } from '../common/utils'; +import { generateUuid } from '../common/uuid'; +import { IRequestMessage, WebviewViewBase } from '../common/webview'; +import { ReviewManager } from '../view/reviewManager'; export class PullRequestViewProvider extends WebviewViewBase implements vscode.WebviewViewProvider { public override readonly viewType = 'github:activePullRequest'; private _existingReviewers: ReviewState[] = []; + private _isUpdating: boolean = false; constructor( extensionUri: vscode.Uri, @@ -32,21 +35,14 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W ) { super(extensionUri); - this._register(this._folderRepositoryManager.onDidMergePullRequest(_ => { - this._postMessage({ - command: 'update-state', - state: GithubItemStateEnum.Merged, - }); - })); - this._register(vscode.commands.registerCommand('review.approve', (e: { body: string }) => this.approvePullRequestCommand(e))); this._register(vscode.commands.registerCommand('review.comment', (e: { body: string }) => this.submitReviewCommand(e))); this._register(vscode.commands.registerCommand('review.requestChanges', (e: { body: string }) => this.requestChangesCommand(e))); this._register(vscode.commands.registerCommand('review.approveOnDotCom', () => { - return openPullRequestOnGitHub(this._item, (this._item as any)._telemetry); + return openPullRequestOnGitHub(this._item, this._folderRepositoryManager.telemetry); })); this._register(vscode.commands.registerCommand('review.requestChangesOnDotCom', () => { - return openPullRequestOnGitHub(this._item, (this._item as any)._telemetry); + return openPullRequestOnGitHub(this._item, this._folderRepositoryManager.telemetry); })); } @@ -116,7 +112,7 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W case 'pr.submit': return this.submitReviewMessage(message); case 'pr.openOnGitHub': - return openPullRequestOnGitHub(this._item, (this._item as any)._telemetry); + return openPullRequestOnGitHub(this._item, this._folderRepositoryManager.telemetry); case 'pr.checkout-default-branch': return this.checkoutDefaultBranch(message); case 'pr.update-branch': @@ -131,6 +127,19 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W const defaultBranch = await this._folderRepositoryManager.getPullRequestRepositoryDefaultBranch(this._item); const prBranch = this._folderRepositoryManager.repository.state.HEAD?.name; await this._folderRepositoryManager.checkoutDefaultBranch(defaultBranch); + // Check if we should pop the stash after successful checkout + const shouldPopStash = this._folderRepositoryManager.stashedOnCheckout; + if (shouldPopStash) { + try { + Logger.appendLine('Popping stash after returning to default branch', 'ActivityBarViewProvider'); + await vscode.commands.executeCommand('git.stashPop', this._folderRepositoryManager.repository); + this._folderRepositoryManager.stashedOnCheckout = false; + Logger.appendLine('Stash popped successfully', 'ActivityBarViewProvider'); + } catch (popError) { + Logger.error(`Failed to pop stash: ${formatError(popError)}`, 'ActivityBarViewProvider'); + vscode.window.showWarningMessage(vscode.l10n.t('Failed to restore stashed changes: {0}', formatError(popError))); + } + } if (prBranch) { await this._folderRepositoryManager.cleanupAfterPullRequest(prBranch, this._item); } @@ -189,132 +198,143 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W disposeAll(this._prDisposables); } this._prDisposables = []; - this._prDisposables.push(pullRequestModel.onDidChange(() => this.updatePullRequest(pullRequestModel))); + this._prDisposables.push(pullRequestModel.onDidChange(e => { + if ((e.state || e.comments || e.reviewers) && !this._isUpdating) { + this.updatePullRequest(pullRequestModel); + } + })); this._prDisposables.push(pullRequestModel.onDidChangePendingReviewState(() => this.updatePullRequest(pullRequestModel))); } private _updatePendingVisibility: vscode.Disposable | undefined = undefined; public async updatePullRequest(pullRequestModel: PullRequestModel): Promise { - if (this._view && !this._view.visible) { - this._updatePendingVisibility?.dispose(); - this._updatePendingVisibility = this._view.onDidChangeVisibility(async () => { - this.updatePullRequest(pullRequestModel); - this._updatePendingVisibility?.dispose(); - }); + if (this._isUpdating) { + throw new Error('Already updating pull request view'); } + this._isUpdating = true; - if ((this._prDisposables === undefined) || (pullRequestModel.number !== this._item.number)) { - this.registerPrSpecificListeners(pullRequestModel); - } - this._item = pullRequestModel; - return Promise.all([ - this._folderRepositoryManager.resolvePullRequest( - pullRequestModel.remote.owner, - pullRequestModel.remote.repositoryName, - pullRequestModel.number, - ), - this._folderRepositoryManager.getPullRequestRepositoryAccessAndMergeMethods(pullRequestModel), - pullRequestModel.getTimelineEvents(), - pullRequestModel.getReviewRequests(), - this._folderRepositoryManager.getBranchNameForPullRequest(pullRequestModel), - this._folderRepositoryManager.getPullRequestRepositoryDefaultBranch(pullRequestModel), - this._folderRepositoryManager.getCurrentUser(pullRequestModel.githubRepository), - pullRequestModel.canEdit(), - pullRequestModel.validateDraftMode(), - ensureEmojis(this._folderRepositoryManager.context) - ]) - .then(result => { - const [pullRequest, repositoryAccess, timelineEvents, requestedReviewers, branchInfo, defaultBranch, currentUser, viewerCanEdit, hasReviewDraft] = result; - if (!pullRequest) { - throw new Error( - `Fail to resolve Pull Request #${pullRequestModel.number} in ${pullRequestModel.remote.owner}/${pullRequestModel.remote.repositoryName}`, - ); - } + try { + if (this._view && !this._view.visible) { + this._updatePendingVisibility?.dispose(); + this._updatePendingVisibility = this._view.onDidChangeVisibility(async () => { + this.updatePullRequest(pullRequestModel); + this._updatePendingVisibility?.dispose(); + }); + } - this._item = pullRequest; - if (!this._view) { - // If the there is no PR webview, then there is nothing else to update. - return; - } + if ((this._prDisposables === undefined) || (pullRequestModel.number !== this._item.number)) { + this.registerPrSpecificListeners(pullRequestModel); + } + this._item = pullRequestModel; + const [pullRequest, repositoryAccess, timelineEvents, requestedReviewers, branchInfo, defaultBranch, currentUser, viewerCanEdit, hasReviewDraft] = await Promise.all([ + this._folderRepositoryManager.resolvePullRequest( + pullRequestModel.remote.owner, + pullRequestModel.remote.repositoryName, + pullRequestModel.number, + ), + this._folderRepositoryManager.getPullRequestRepositoryAccessAndMergeMethods(pullRequestModel), + pullRequestModel.getTimelineEvents(), + pullRequestModel.getReviewRequests(), + this._folderRepositoryManager.getBranchNameForPullRequest(pullRequestModel), + this._folderRepositoryManager.getPullRequestRepositoryDefaultBranch(pullRequestModel), + this._folderRepositoryManager.getCurrentUser(pullRequestModel.githubRepository), + pullRequestModel.canEdit(), + pullRequestModel.validateDraftMode(), + ensureEmojis(this._folderRepositoryManager.context) + ]); + + if (!pullRequest) { + throw new Error( + `Fail to resolve Pull Request #${pullRequestModel.number} in ${pullRequestModel.remote.owner}/${pullRequestModel.remote.repositoryName}`, + ); + } - try { - this._view.title = `${vscode.l10n.t('Review Pull Request')} #${pullRequestModel.number.toString()}`; - } catch (e) { - // If we ry to set the title of the webview too early it will throw an error. - } + this._item = pullRequest; + if (!this._view) { + // If the there is no PR webview, then there is nothing else to update. + return; + } - const isCurrentlyCheckedOut = pullRequestModel.equals(this._folderRepositoryManager.activePullRequest); - const hasWritePermission = repositoryAccess!.hasWritePermission; - const mergeMethodsAvailability = repositoryAccess!.mergeMethodsAvailability; - const canEdit = hasWritePermission || viewerCanEdit; - const defaultMergeMethod = getDefaultMergeMethod(mergeMethodsAvailability); - this._existingReviewers = parseReviewers( - requestedReviewers ?? [], - timelineEvents ?? [], - pullRequest.author, - ); + try { + this._view.title = `${vscode.l10n.t('Review Pull Request')} #${pullRequestModel.number.toString()}`; + } catch (e) { + // If we ry to set the title of the webview too early it will throw an error. + } - const isCrossRepository = - pullRequest.base && - pullRequest.head && - !pullRequest.base.repositoryCloneUrl.equals(pullRequest.head.repositoryCloneUrl); - - const continueOnGitHub = !!(isCrossRepository && isInCodespaces()); - const reviewState = this.getCurrentUserReviewState(this._existingReviewers, currentUser); - - const context: Partial = { - number: pullRequest.number, - title: pullRequest.title, - url: pullRequest.html_url, - createdAt: pullRequest.createdAt, - body: pullRequest.body, - bodyHTML: pullRequest.bodyHTML, - labels: pullRequest.item.labels.map(label => ({ ...label, displayName: emojify(label.name) })), - author: { - login: pullRequest.author.login, - name: pullRequest.author.name, - avatarUrl: pullRequest.userAvatar, - url: pullRequest.author.url, - email: pullRequest.author.email, - id: pullRequest.author.id, - accountType: pullRequest.author.accountType, - }, - state: pullRequest.state, - isCurrentlyCheckedOut: isCurrentlyCheckedOut, - isRemoteBaseDeleted: pullRequest.isRemoteBaseDeleted, - base: pullRequest.base.label, - isRemoteHeadDeleted: pullRequest.isRemoteHeadDeleted, - isLocalHeadDeleted: !branchInfo, - head: pullRequest.head?.label ?? '', - canEdit: canEdit, - hasWritePermission, - mergeable: pullRequest.item.mergeable, - isDraft: pullRequest.isDraft, - status: null, - reviewRequirement: null, - canUpdateBranch: pullRequest.item.viewerCanUpdate, - events: timelineEvents, - mergeMethodsAvailability, - defaultMergeMethod, - repositoryDefaultBranch: defaultBranch, - isIssue: false, - isAuthor: currentUser.login === pullRequest.author.login, - reviewers: this._existingReviewers, - continueOnGitHub, - isDarkTheme: vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark, - isEnterprise: pullRequest.githubRepository.remote.isEnterprise, - hasReviewDraft, - currentUserReviewState: reviewState - }; - - this._postMessage({ - command: 'pr.initialize', - pullrequest: context, - }); - }) - .catch(e => { - vscode.window.showErrorMessage(`Error updating active pull request view: ${formatError(e)}`); + const isCurrentlyCheckedOut = pullRequestModel.equals(this._folderRepositoryManager.activePullRequest); + const hasWritePermission = repositoryAccess!.hasWritePermission; + const mergeMethodsAvailability = repositoryAccess!.mergeMethodsAvailability; + const canEdit = hasWritePermission || viewerCanEdit; + const defaultMergeMethod = getDefaultMergeMethod(mergeMethodsAvailability); + this._existingReviewers = parseReviewers( + requestedReviewers ?? [], + timelineEvents ?? [], + pullRequest.author, + ); + + const isCrossRepository = + pullRequest.base && + pullRequest.head && + !pullRequest.base.repositoryCloneUrl.equals(pullRequest.head.repositoryCloneUrl); + + const continueOnGitHub = !!(isCrossRepository && isInCodespaces()); + const reviewState = this.getCurrentUserReviewState(this._existingReviewers, currentUser); + + const context: Partial = { + number: pullRequest.number, + title: pullRequest.title, + url: pullRequest.html_url, + createdAt: pullRequest.createdAt, + body: pullRequest.body, + bodyHTML: pullRequest.bodyHTML, + labels: pullRequest.item.labels.map(label => ({ ...label, displayName: emojify(label.name) })), + author: { + login: pullRequest.author.login, + name: pullRequest.author.name, + avatarUrl: pullRequest.userAvatar, + url: pullRequest.author.url, + email: pullRequest.author.email, + id: pullRequest.author.id, + accountType: pullRequest.author.accountType, + }, + state: pullRequest.state, + isCurrentlyCheckedOut: isCurrentlyCheckedOut, + isRemoteBaseDeleted: pullRequest.isRemoteBaseDeleted, + base: pullRequest.base.label, + isRemoteHeadDeleted: pullRequest.isRemoteHeadDeleted, + isLocalHeadDeleted: !branchInfo, + head: pullRequest.head?.label ?? '', + canEdit: canEdit, + hasWritePermission, + mergeable: pullRequest.item.mergeable, + isDraft: pullRequest.isDraft, + status: null, + reviewRequirement: null, + canUpdateBranch: pullRequest.item.viewerCanUpdate, + events: timelineEvents, + mergeMethodsAvailability, + defaultMergeMethod, + repositoryDefaultBranch: defaultBranch, + isIssue: false, + isAuthor: currentUser.login === pullRequest.author.login, + reviewers: this._existingReviewers, + continueOnGitHub, + isDarkTheme: vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark, + isEnterprise: pullRequest.githubRepository.remote.isEnterprise, + hasReviewDraft, + currentUserReviewState: reviewState + }; + + this._postMessage({ + command: 'pr.initialize', + pullrequest: context, }); + + } catch (e) { + vscode.window.showErrorMessage(`Error updating active pull request view: ${formatError(e)}`); + } finally { + this._isUpdating = false; + } } private close(message: IRequestMessage): void { @@ -466,28 +486,25 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W this._replyMessage(message, { state: GithubItemStateEnum.Open }); return; } + try { + const result = await this._item.merge(this._folderRepositoryManager.repository, title, description, method, email); - this._folderRepositoryManager - .mergePullRequest(this._item, title, description, method, email) - .then(result => { - vscode.commands.executeCommand('pr.refreshList'); - - if (!result.merged) { - vscode.window.showErrorMessage(vscode.l10n.t('Merging pull request failed: {0}', result?.message ?? '')); - } + if (!result.merged) { + vscode.window.showErrorMessage(vscode.l10n.t('Merging pull request failed: {0}', result?.message ?? '')); + } - this._replyMessage(message, { - state: result.merged ? GithubItemStateEnum.Merged : GithubItemStateEnum.Open, - }); - }) - .catch(e => { - vscode.window.showErrorMessage(vscode.l10n.t('Unable to merge pull request. {0}', formatError(e))); - this._throwError(message, ''); + this._replyMessage(message, { + state: result.merged ? GithubItemStateEnum.Merged : GithubItemStateEnum.Open, }); + + } catch (e) { + vscode.window.showErrorMessage(vscode.l10n.t('Unable to merge pull request. {0}', formatError(e))); + this._throwError(message, ''); + } } private _getHtmlForWebview() { - const nonce = getNonce(); + const nonce = generateUuid(); const uri = vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview-open-pr-view.js'); diff --git a/src/github/common.ts b/src/github/common.ts index e33fe8c0f9..6d9b424d56 100644 --- a/src/github/common.ts +++ b/src/github/common.ts @@ -4,14 +4,15 @@ *--------------------------------------------------------------------------------------------*/ import * as OctokitRest from '@octokit/rest'; import { Endpoints } from '@octokit/types'; +import { DocumentNode } from 'graphql'; import { ChatSessionStatus, Uri } from 'vscode'; +import { SessionInfo, SessionSetupStep } from './copilotApi'; +import { FolderRepositoryManager } from './folderRepositoryManager'; +import { GitHubRepository } from './githubRepository'; import { Repository } from '../api/api'; import { CopilotPRStatus } from '../common/copilot'; import { GitHubRemote } from '../common/remote'; import { EventType, TimelineEvent } from '../common/timelineEvent'; -import { SessionInfo, SessionSetupStep } from './copilotApi'; -import { FolderRepositoryManager } from './folderRepositoryManager'; -import { GitHubRepository } from './githubRepository'; export namespace OctokitCommon { export type IssuesAssignParams = OctokitRest.RestEndpointMethodTypes['issues']['addAssignees']['parameters']; @@ -44,7 +45,9 @@ export namespace OctokitCommon { user_view_type: string; } export type PullsCreateParams = OctokitRest.RestEndpointMethodTypes['pulls']['create']['parameters']; - export type PullsCreateReviewResponseData = Endpoints['POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews']['response']['data']; + export type PullsCreateReviewResponseData = Endpoints['POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews']['response']['data'] & { + submitted_at: string; + }; export type PullsCreateReviewCommentResponseData = Endpoints['POST /repos/{owner}/{repo}/pulls/{pull_number}/comments']['response']['data']; export type PullsGetResponseData = OctokitRest.RestEndpointMethodTypes['pulls']['get']['response']['data']; export type IssuesGetResponseData = OctokitRest.RestEndpointMethodTypes['issues']['get']['response']['data']; @@ -86,9 +89,7 @@ export namespace OctokitCommon { export type WorkflowJobs = Endpoints['GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs']['response']['data']; } -// eslint-disable-next-line rulesdir/no-any-except-union-method-signature -export type Schema = { [key: string]: any, definitions: any[]; }; -export function mergeQuerySchemaWithShared(sharedSchema: Schema, schema: Schema) { +export function mergeQuerySchemaWithShared(sharedSchema: DocumentNode, schema: DocumentNode) { const sharedSchemaDefinitions = sharedSchema.definitions; const schemaDefinitions = schema.definitions; const mergedDefinitions = schemaDefinitions.concat(sharedSchemaDefinitions); @@ -100,7 +101,7 @@ export function mergeQuerySchemaWithShared(sharedSchema: Schema, schema: Schema) } type RemoteAgentSuccessResult = { link: string; state: 'success'; number: number; webviewUri: Uri; llmDetails: string; sessionId: string }; -type RemoteAgentErrorResult = { error: string; state: 'error' }; +type RemoteAgentErrorResult = { error: string; innerError?: string; state: 'error' }; export type RemoteAgentResult = RemoteAgentSuccessResult | RemoteAgentErrorResult; export interface IAPISessionLogs { diff --git a/src/github/conflictResolutionCoordinator.ts b/src/github/conflictResolutionCoordinator.ts index 8878ae52d9..a0bb0e8bbc 100644 --- a/src/github/conflictResolutionCoordinator.ts +++ b/src/github/conflictResolutionCoordinator.ts @@ -5,6 +5,8 @@ import * as buffer from 'buffer'; import * as vscode from 'vscode'; +import { Conflict, ConflictResolutionModel } from './conflictResolutionModel'; +import { GitHubRepository } from './githubRepository'; import { commands, contexts } from '../common/executeCommands'; import { Disposable } from '../common/lifecycle'; import { ITelemetry } from '../common/telemetry'; @@ -12,8 +14,6 @@ import { Schemes } from '../common/uri'; import { asPromise } from '../common/utils'; import { ConflictResolutionTreeView } from '../view/conflictResolution/conflictResolutionTreeView'; import { GitHubContentProvider } from '../view/gitHubContentProvider'; -import { Conflict, ConflictResolutionModel } from './conflictResolutionModel'; -import { GitHubRepository } from './githubRepository'; interface MergeEditorInputData { uri: vscode.Uri; title?: string; detail?: string; description?: string } const ORIGINAL_FILE = diff --git a/src/github/copilotApi.ts b/src/github/copilotApi.ts index 152b0a01aa..499ba2a253 100644 --- a/src/github/copilotApi.ts +++ b/src/github/copilotApi.ts @@ -6,21 +6,24 @@ import fetch from 'cross-fetch'; import JSZip from 'jszip'; import * as vscode from 'vscode'; -import { AuthProvider } from '../common/authentication'; -import { COPILOT_SWE_AGENT } from '../common/copilot'; -import Logger from '../common/logger'; -import { ITelemetry } from '../common/telemetry'; import { CredentialStore, GitHub } from './credentials'; import { PRType } from './interface'; import { LoggingOctokit } from './loggingOctokit'; import { PullRequestModel } from './pullRequestModel'; import { RepositoriesManager } from './repositoriesManager'; import { hasEnterpriseUri } from './utils'; +import { AuthProvider } from '../common/authentication'; +import { COPILOT_SWE_AGENT } from '../common/copilot'; +import Logger from '../common/logger'; +import { ITelemetry } from '../common/telemetry'; const LEARN_MORE_URL = 'https://aka.ms/coding-agent-docs'; const PREMIUM_REQUESTS_URL = 'https://docs.github.com/en/copilot/concepts/copilot-billing/understanding-and-managing-requests-in-copilot#what-are-premium-requests'; // https://github.com/github/sweagentd/blob/59e7d9210ca3ebba029918387e525eea73cb1f4a/internal/problemstatement/problemstatement.go#L36-L53 export const MAX_PROBLEM_STATEMENT_LENGTH = 30_000 - 50; // 50 character buffer +// https://github.com/github/sweagentd/blob/0ad8f81a9c64754cb8a83d10777de4638bba1a6e/docs/adr/0001-create-job-api.md#post-jobsownerrepo---create-job-task +const JOBS_API_VERSION = 'v1'; + export interface RemoteAgentJobPayload { problem_statement: string; event_type: string; @@ -35,17 +38,38 @@ export interface RemoteAgentJobPayload { } export interface RemoteAgentJobResponse { - pull_request: { - html_url: string; - number: number; - } + job_id: string; session_id: string; + actor: { + id: number; + login: string; + }; + created_at: string; + updated_at: string; } export interface ChatSessionWithPR extends vscode.ChatSessionItem { pullRequest: PullRequestModel; } + +/** + * This is temporary for the migration of CCA only. + * Once fully migrated we can rename to ChatSessionWithPR and remove the old one. + **/ +export interface CrossChatSessionWithPR extends vscode.ChatSessionItem { + pullRequestDetails: { + id: string; + number: number; + repository: { + owner: { + login: string; + }; + name: string; + }; + }; +} + export class CopilotApi { protected static readonly ID = 'copilotApi'; @@ -73,7 +97,6 @@ export class CopilotApi { return `vscode-pull-request-github/${extensionVersion}`; } - async postRemoteAgentJob( owner: string, name: string, @@ -81,7 +104,7 @@ export class CopilotApi { isTruncated: boolean, ): Promise { const repoSlug = `${owner}/${name}`; - const apiUrl = `/agents/swe/v0/jobs/${repoSlug}`; + const apiUrl = `/agents/swe/${JOBS_API_VERSION}/jobs/${repoSlug}`; let status: number | undefined; const problemStatementLength = payload.problem_statement.length.toString(); @@ -169,18 +192,27 @@ export class CopilotApi { if (!data || typeof data !== 'object') { throw new Error('Invalid response from coding agent'); } - if (!data.pull_request || typeof data.pull_request !== 'object') { - throw new Error('Invalid pull_request in response'); - } - if (typeof data.pull_request.html_url !== 'string') { - throw new Error('Invalid pull_request.html_url in response'); - } - if (typeof data.pull_request.number !== 'number') { - throw new Error('Invalid pull_request.number in response'); + if (typeof data.job_id !== 'string') { + throw new Error('Invalid job_id in response'); } if (typeof data.session_id !== 'string') { throw new Error('Invalid session_id in response'); } + if (!data.actor || typeof data.actor !== 'object') { + throw new Error('Invalid actor in response'); + } + if (typeof data.actor.id !== 'number') { + throw new Error('Invalid actor.id in response'); + } + if (typeof data.actor.login !== 'string') { + throw new Error('Invalid actor.login in response'); + } + if (typeof data.created_at !== 'string') { + throw new Error('Invalid created_at in response'); + } + if (typeof data.updated_at !== 'string') { + throw new Error('Invalid updated_at in response'); + } } public async getLogsFromZipUrl(logsUrl: string): Promise { @@ -270,9 +302,32 @@ export class CopilotApi { return await logsResponse.text(); } + public async getJobByJobId(owner: string, repo: string, jobId: string): Promise { + try { + const response = await this.makeApiCall(`/agents/swe/v1/jobs/${owner}/${repo}/${jobId}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${this.token}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': this.userAgent, + } + }); + if (!response.ok) { + Logger.warn(`Failed to fetch job info for job ${jobId}: ${response.statusText}`, CopilotApi.ID); + return; + } + const data = await response.json() as JobInfo; + return data; + } catch (error) { + Logger.warn(`Error fetching job info for job ${jobId}: ${error}`, CopilotApi.ID); + return; + } + } + public async getJobBySessionId(owner: string, repo: string, sessionId: string): Promise { try { - const response = await this.makeApiCall(`/agents/swe/v0/jobs/${owner}/${repo}/session/${sessionId}`, { + const response = await this.makeApiCall(`/agents/swe/${JOBS_API_VERSION}/jobs/${owner}/${repo}/session/${sessionId}`, { method: 'GET', headers: { 'Authorization': `Bearer ${this.token}`, diff --git a/src/github/copilotPrWatcher.ts b/src/github/copilotPrWatcher.ts index 1ab4b6739e..5a81e9ad06 100644 --- a/src/github/copilotPrWatcher.ts +++ b/src/github/copilotPrWatcher.ts @@ -4,22 +4,27 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { GithubItemStateEnum } from './interface'; +import { PullRequestModel } from './pullRequestModel'; +import { PullRequestOverviewPanel } from './pullRequestOverview'; +import { RepositoriesManager } from './repositoriesManager'; import { debounce } from '../common/async'; import { COPILOT_ACCOUNTS } from '../common/comment'; import { COPILOT_LOGINS, copilotEventToStatus, CopilotPRStatus } from '../common/copilot'; import { Disposable } from '../common/lifecycle'; -import Logger from '../common/logger'; import { PR_SETTINGS_NAMESPACE, QUERIES } from '../common/settingKeys'; -import { PRType } from './interface'; -import { PullRequestModel } from './pullRequestModel'; -import { PullRequestOverviewPanel } from './pullRequestOverview'; -import { RepositoriesManager } from './repositoriesManager'; +import { PrsTreeModel } from '../view/prsTreeModel'; export function isCopilotQuery(query: string): boolean { const lowerQuery = query.toLowerCase(); return COPILOT_LOGINS.some(login => lowerQuery.includes(`author:${login.toLowerCase()}`)); } +export function getCopilotQuery(): string | undefined { + const queries = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<{ label: string; query: string }[]>(QUERIES, []); + return queries.find(query => isCopilotQuery(query.query))?.query; +} + export interface CodingAgentPRAndStatus { item: PullRequestModel; status: CopilotPRStatus; @@ -31,15 +36,9 @@ export class CopilotStateModel extends Disposable { private readonly _states: Map = new Map(); private readonly _showNotification: Set = new Set(); private readonly _onDidChangeStates = this._register(new vscode.EventEmitter()); - readonly onDidChangeStates = this._onDidChangeStates.event; + readonly onDidChangeCopilotStates = this._onDidChangeStates.event; private readonly _onDidChangeNotifications = this._register(new vscode.EventEmitter()); - readonly onDidChangeNotifications = this._onDidChangeNotifications.event; - private readonly _onRefresh = this._register(new vscode.EventEmitter()); - readonly onRefresh = this._onRefresh.event; - - clear(): void { - this._onRefresh.fire(); - } + readonly onDidChangeCopilotNotifications = this._onDidChangeNotifications.event; makeKey(owner: string, repo: string, prNumber?: number): string { if (prNumber === undefined) { @@ -48,16 +47,11 @@ export class CopilotStateModel extends Disposable { return `${owner}/${repo}#${prNumber}`; } - delete(owner: string, repo: string, prNumber: number): void { - const key = this.makeKey(owner, repo, prNumber); - this.deleteKey(key); - } - deleteKey(key: string): void { if (this._states.has(key)) { + const item = this._states.get(key)!; this._states.delete(key); if (this._showNotification.has(key)) { - const item = this._states.get(key)!; this._showNotification.delete(key); this._onDidChangeNotifications.fire([item.item]); } @@ -65,17 +59,17 @@ export class CopilotStateModel extends Disposable { } } - set(statuses: { pullRequestModel: PullRequestModel, status: CopilotPRStatus }[]): void { + set(statuses: CodingAgentPRAndStatus[]): void { const changedModels: PullRequestModel[] = []; const changedKeys: string[] = []; - for (const { pullRequestModel, status } of statuses) { - const key = this.makeKey(pullRequestModel.remote.owner, pullRequestModel.remote.repositoryName, pullRequestModel.number); + for (const { item, status } of statuses) { + const key = this.makeKey(item.remote.owner, item.remote.repositoryName, item.number); const currentStatus = this._states.get(key); if (currentStatus?.status === status) { continue; } - this._states.set(key, { item: pullRequestModel, status }); - changedModels.push(pullRequestModel); + this._states.set(key, { item, status }); + changedModels.push(item); changedKeys.push(key); } if (changedModels.length > 0) { @@ -193,9 +187,11 @@ export class CopilotStateModel extends Disposable { } export class CopilotPRWatcher extends Disposable { + private readonly _model: CopilotStateModel; - constructor(private readonly _reposManager: RepositoriesManager, private readonly _model: CopilotStateModel) { + constructor(private readonly _reposManager: RepositoriesManager, private readonly _prsTreeModel: PrsTreeModel) { super(); + this._model = _prsTreeModel.copilotStateModel; if (this._reposManager.folderManagers.length === 0) { const initDisposable = this._reposManager.onDidChangeAnyGitHubRepository(() => { initDisposable.dispose(); @@ -204,16 +200,18 @@ export class CopilotPRWatcher extends Disposable { } else { this._initialize(); } - this._register(this._model.onRefresh(() => this._getStateChanges())); } private _initialize() { - this._getStateChanges(); + this._prsTreeModel.refreshCopilotStateChanges(true); this._pollForChanges(); - const updateFullState = debounce(() => this._getStateChanges(), 50); + const updateFullState = debounce(() => this._prsTreeModel.refreshCopilotStateChanges(true), 50); this._register(this._reposManager.onDidChangeAnyPullRequests(e => { if (e.some(pr => COPILOT_ACCOUNTS[pr.model.author.login])) { - if (this._model.isInitialized && e.some(pr => this._model.get(pr.model.remote.owner, pr.model.remote.repositoryName, pr.model.number) === CopilotPRStatus.None)) { + if (!this._model.isInitialized) { + return; + } + if (e.some(pr => this._model.get(pr.model.remote.owner, pr.model.remote.repositoryName, pr.model.number) === CopilotPRStatus.None)) { // A PR we don't know about was updated updateFullState(); } else { @@ -243,11 +241,6 @@ export class CopilotPRWatcher extends Disposable { this._register({ dispose: () => this._pollTimeout && clearTimeout(this._pollTimeout) }); } - private _queriesIncludeCopilot(): string | undefined { - const queries = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<{ label: string; query: string }[]>(QUERIES, []); - return queries.find(query => isCopilotQuery(query.query))?.query; - } - private get _pollInterval(): number { if (vscode.window.state.active || vscode.window.state.focused) { return 60 * 1000 * 2; // Poll every 2 minutes @@ -263,7 +256,7 @@ export class CopilotPRWatcher extends Disposable { this._pollTimeout = undefined; } this._lastPollTime = Date.now(); - const shouldContinue = await this._getStateChanges(); + const shouldContinue = await this._prsTreeModel.refreshCopilotStateChanges(true); if (shouldContinue) { this._pollTimeout = setTimeout(() => { @@ -273,9 +266,9 @@ export class CopilotPRWatcher extends Disposable { } private async _updateSingleState(pr: PullRequestModel): Promise { - const changes: { pullRequestModel: PullRequestModel, status: CopilotPRStatus }[] = []; + const changes: CodingAgentPRAndStatus[] = []; - const copilotEvents = await pr.getCopilotTimelineEvents(pr); + const copilotEvents = await pr.getCopilotTimelineEvents(pr, false, !this._model.isInitialized); let latestEvent = copilotEventToStatus(copilotEvents[copilotEvents.length - 1]); if (latestEvent === CopilotPRStatus.None) { if (!COPILOT_ACCOUNTS[pr.author.login]) { @@ -283,76 +276,19 @@ export class CopilotPRWatcher extends Disposable { } latestEvent = CopilotPRStatus.Started; } + + if (pr.state !== GithubItemStateEnum.Open) { + // PR has been closed or merged, time to remove it. + const key = this._model.makeKey(pr.remote.owner, pr.remote.repositoryName, pr.number); + this._model.deleteKey(key); + return; + } + const lastStatus = this._model.get(pr.remote.owner, pr.remote.repositoryName, pr.number) ?? CopilotPRStatus.None; if (latestEvent !== lastStatus) { - changes.push({ pullRequestModel: pr, status: latestEvent }); + changes.push({ item: pr, status: latestEvent }); } this._model.set(changes); } - private _getStateChangesPromise: Promise | undefined; - private async _getStateChanges(): Promise { - // Return the existing in-flight promise if one exists - if (this._getStateChangesPromise) { - return this._getStateChangesPromise; - } - - // Create and store the in-flight promise, and ensure it's cleared when done - this._getStateChangesPromise = (async () => { - try { - const query = this._queriesIncludeCopilot(); - if (!query) { - return false; - } - const unseenKeys: Set = new Set(this._model.keys()); - let initialized = 0; - - const changes: { pullRequestModel: PullRequestModel, status: CopilotPRStatus }[] = []; - for (const folderManager of this._reposManager.folderManagers) { - initialized++; - const items: PullRequestModel[] = []; - let hasMore = true; - do { - const prs = await folderManager.getPullRequests(PRType.Query, { fetchOnePagePerRepo: true, fetchNextPage: !this._model.isInitialized }, query); - items.push(...prs.items); - hasMore = prs.hasMorePages; - } while (hasMore); - - for (const pr of items) { - unseenKeys.delete(this._model.makeKey(pr.remote.owner, pr.remote.repositoryName, pr.number)); - const copilotEvents = await pr.getCopilotTimelineEvents(pr); - let latestEvent = copilotEventToStatus(copilotEvents[copilotEvents.length - 1]); - if (latestEvent === CopilotPRStatus.None) { - if (!COPILOT_ACCOUNTS[pr.author.login]) { - continue; - } - latestEvent = CopilotPRStatus.Started; - } - const lastStatus = this._model.get(pr.remote.owner, pr.remote.repositoryName, pr.number) ?? CopilotPRStatus.None; - if (latestEvent !== lastStatus) { - changes.push({ pullRequestModel: pr, status: latestEvent }); - } - } - } - for (const key of unseenKeys) { - this._model.deleteKey(key); - } - this._model.set(changes); - if (!this._model.isInitialized) { - if ((initialized === this._reposManager.folderManagers.length) && (this._reposManager.folderManagers.length > 0)) { - Logger.debug(`Copilot PR state initialized with ${this._model.keys().length} PRs`, CopilotStateModel.ID); - this._model.setInitialized(); - } - return true; - } else { - return true; - } - } finally { - // Ensure the stored promise is cleared so subsequent calls start a new run - this._getStateChangesPromise = undefined; - } - })(); - - return this._getStateChangesPromise; - } } \ No newline at end of file diff --git a/src/github/copilotRemoteAgent.ts b/src/github/copilotRemoteAgent.ts index e58a8cd0b5..e31b3b0c6b 100644 --- a/src/github/copilotRemoteAgent.ts +++ b/src/github/copilotRemoteAgent.ts @@ -4,13 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import * as pathLib from 'path'; +import { URI } from '@vscode/prompt-tsx/dist/base/util/vs/common/uri'; import * as marked from 'marked'; import vscode, { ChatPromptReference, ChatSessionItem } from 'vscode'; +import { copilotPRStatusToSessionStatus, IAPISessionLogs, ICopilotRemoteAgentCommandArgs, ICopilotRemoteAgentCommandResponse, OctokitCommon, RemoteAgentResult, RepoInfo } from './common'; +import { ChatSessionWithPR, CopilotApi, getCopilotApi, JobInfo, RemoteAgentJobPayload, SessionInfo, SessionSetupStep } from './copilotApi'; +import { CodingAgentPRAndStatus, CopilotPRWatcher } from './copilotPrWatcher'; import { parseSessionLogs, parseToolCallDetails, StrReplaceEditorToolData } from '../../common/sessionParsing'; import { GitApiImpl } from '../api/api1'; import { COPILOT_ACCOUNTS } from '../common/comment'; import { CopilotRemoteAgentConfig } from '../common/config'; -import { COPILOT_LOGINS, COPILOT_SWE_AGENT, copilotEventToStatus, CopilotPRStatus, mostRecentCopilotEvent } from '../common/copilot'; +import { COPILOT_LOGINS, COPILOT_SWE_AGENT } from '../common/copilot'; import { commands } from '../common/executeCommands'; import { Disposable } from '../common/lifecycle'; import Logger from '../common/logger'; @@ -18,9 +22,6 @@ import { GitHubRemote } from '../common/remote'; import { CODING_AGENT, CODING_AGENT_AUTO_COMMIT_AND_PUSH } from '../common/settingKeys'; import { ITelemetry } from '../common/telemetry'; import { toOpenPullRequestWebviewUri } from '../common/uri'; -import { copilotPRStatusToSessionStatus, IAPISessionLogs, ICopilotRemoteAgentCommandArgs, ICopilotRemoteAgentCommandResponse, OctokitCommon, RemoteAgentResult, RepoInfo } from './common'; -import { ChatSessionWithPR, CopilotApi, getCopilotApi, RemoteAgentJobPayload, SessionInfo, SessionSetupStep } from './copilotApi'; -import { CodingAgentPRAndStatus, CopilotPRWatcher, CopilotStateModel } from './copilotPrWatcher'; import { ChatSessionContentBuilder } from './copilotRemoteAgent/chatSessionContentBuilder'; import { GitOperationsManager } from './copilotRemoteAgent/gitOperationsManager'; import { extractTitle, formatBodyPlaceholder, truncatePrompt } from './copilotRemoteAgentUtils'; @@ -32,6 +33,7 @@ import { PullRequestModel } from './pullRequestModel'; import { chooseItem } from './quickPicks'; import { RepositoriesManager } from './repositoriesManager'; import { getRepositoryForFile } from './utils'; +import { PrsTreeModel } from '../view/prsTreeModel'; const LEARN_MORE = vscode.l10n.t('Learn about coding agent'); // Without Pending Changes @@ -42,6 +44,7 @@ const CONTINUE_WITHOUT_PUSHING = vscode.l10n.t('Ignore changes'); const CONTINUE_AND_DO_NOT_ASK_AGAIN = vscode.l10n.t('Continue and don\'t ask again'); const CONTINUE_TRUNCATION = vscode.l10n.t('Continue with truncation'); +const DELEGATE_MODAL_DETAILS = vscode.l10n.t('The agent will work asynchronously to create a pull request with your requested changes.'); const COPILOT = '@copilot'; @@ -54,12 +57,14 @@ export namespace SessionIdForPr { const prefix = 'pull-session-by-index'; - export function getId(prNumber: number, sessionIndex: number): string { - return `${prefix}-${prNumber}-${sessionIndex}`; + export function getResource(prNumber: number, sessionIndex: number): vscode.Uri { + return vscode.Uri.from({ + scheme: COPILOT_SWE_AGENT, path: `/${prefix}-${prNumber}-${sessionIndex}`, + }); } - export function parse(id: string): { prNumber: number; sessionIndex: number } | undefined { - const match = id.match(new RegExp(`^${prefix}-(\\d+)-(\\d+)$`)); + export function parse(resource: vscode.Uri): { prNumber: number; sessionIndex: number } | undefined { + const match = resource.path.match(new RegExp(`^/${prefix}-(\\d+)-(\\d+)$`)); if (match) { return { prNumber: parseInt(match[1], 10), @@ -70,47 +75,106 @@ export namespace SessionIdForPr { } } +type ConfirmationResult = { step: string; accepted: boolean; metadata?: CreatePromptMetadata /* | SomeOtherMetadata */ }; + +interface CreatePromptMetadata { + prompt: string; + history?: string; + references?: ChatPromptReference[]; +} + export class CopilotRemoteAgentManager extends Disposable { async chatParticipantImpl(request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) { - const startSession = async (prompt: string, history: ReadonlyArray, source: string) => { + const startSession = async (source: string, prompt: string, history?: string, references?: readonly vscode.ChatPromptReference[]) => { /* __GDPR__ "copilot.remoteagent.editor.invoke" : { "promptLength" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, "historyLength" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "referencesCount" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, "source" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } } */ this.telemetry.sendTelemetryEvent('copilot.remoteagent.editor.invoke', { - promptLength: prompt.length.toString(), - historyLength: history?.length.toString(), + promptLength: prompt.length.toString() ?? '0', + historyLength: history?.length.toString() ?? '0', + referencesCount: references?.length.toString() ?? '0', source, }); - stream.progress(vscode.l10n.t('Delegating to coding agent')); const result = await this.invokeRemoteAgent( prompt, [ - this.extractFileReferences(request.references), - await this.extractHistory(history) + this.extractFileReferences(references), + history ].join('\n\n').trim(), token, false, + stream, ); if (result.state !== 'success') { - Logger.error(`Failed to provide new chat session item: ${result.error}`, CopilotRemoteAgentManager.ID); - stream.warning('Failed delegating to coding agent. Please try again later.'); + Logger.error(`Failed to provide new chat session item: ${result.error}${result.innerError ? `\nInner Error: ${result.innerError}` : ''}`, CopilotRemoteAgentManager.ID); + stream.warning(result.error); return; } return result.number; }; + const handleConfirmationData = async () => { + const results: ConfirmationResult[] = []; + results.push(...(request.acceptedConfirmationData?.map(data => ({ step: data.step, accepted: true, metadata: data?.metadata })) ?? [])); + results.push(...((request.rejectedConfirmationData ?? []).filter(data => !results.some(r => r.step === data.step)).map(data => ({ step: data.step, accepted: false, metadata: data?.metadata })))); + for (const data of results) { + switch (data.step) { + case 'create': + if (!data.accepted) { + stream.markdown(vscode.l10n.t('Coding agent request cancelled.')); + return {}; + } + const { prompt, history, references } = data.metadata as CreatePromptMetadata; + const number = await startSession('chat', prompt, history, references); + if (!number) { + return {}; + } + const pullRequest = await this.findPullRequestById(number, true); + if (!pullRequest) { + stream.warning(vscode.l10n.t('Could not find the associated pull request {0} for this chat session.', number)); + return {}; + } + const uri = await toOpenPullRequestWebviewUri({ owner: pullRequest.remote.owner, repo: pullRequest.remote.repositoryName, pullRequestNumber: pullRequest.number }); + const plaintextBody = marked.parse(pullRequest.body, { renderer: new PlainTextRenderer(true), smartypants: true }).trim(); + const card = new vscode.ChatResponsePullRequestPart(uri, pullRequest.title, plaintextBody, pullRequest.author.specialDisplayName ?? pullRequest.author.login, `#${pullRequest.number}`); + stream.push(card); + stream.markdown(vscode.l10n.t('GitHub Copilot coding agent has begun working on your request. Follow its progress in the associated chat and pull request.')); + vscode.commands.executeCommand('vscode.open', vscode.Uri.from({ scheme: COPILOT_SWE_AGENT, path: '/' + number }), { viewColumn: vscode.ViewColumn.Active }); + break; + default: + stream.warning(`Unknown confirmation step: ${data.step}\n\n`); + break; + } + } + return {}; + }; + + if (request.acceptedConfirmationData || request.rejectedConfirmationData) { + return await handleConfirmationData(); + } + if (context.chatSessionContext?.isUntitled) { /* Generate new coding agent session from an 'untitled' session */ - const number = await startSession(request.prompt, context.history, 'untitledChatSession'); + const number = await startSession( + 'untitledChatSession', + context.chatSummary?.prompt ?? request.prompt, + context.chatSummary?.history, + request.references + ); if (!number) { return {}; } // Tell UI to the new chat session - this._onDidCommitChatSession.fire({ original: context.chatSessionContext.chatSessionItem, modified: { id: String(number), label: `Pull Request ${number}` } }); + const modified: vscode.ChatSessionItem = { + resource: vscode.Uri.from({ scheme: COPILOT_SWE_AGENT, path: '/' + number }), + label: `Pull Request ${number}`, + }; + this._onDidCommitChatSession.fire({ original: context.chatSessionContext.chatSessionItem, modified }); } else if (context.chatSessionContext) { /* Follow up to an existing coding agent session */ try { @@ -127,9 +191,9 @@ export class CopilotRemoteAgentManager extends Disposable { stream.progress(vscode.l10n.t('Preparing')); - const pullRequest = await this.findPullRequestById(parseInt(context.chatSessionContext.chatSessionItem.id, 10), true); + const pullRequest = await this.findPullRequestById(parseInt(context.chatSessionContext.chatSessionItem.resource.path.slice(1), 10), true); if (!pullRequest) { - stream.warning(vscode.l10n.t('Could not find the associated pull request {0} for this chat session.', context.chatSessionContext.chatSessionItem.id)); + stream.warning(vscode.l10n.t('Could not find the associated pull request {0} for this chat session.', context.chatSessionContext.chatSessionItem.resource.toString)); return {}; } @@ -146,7 +210,7 @@ export class CopilotRemoteAgentManager extends Disposable { stream.markdown(result); stream.markdown('\n\n'); - stream.progress(vscode.l10n.t('Waiting for coding agent to respond')); + stream.progress(vscode.l10n.t('Attaching to session')); // Wait for new session and stream its progress const newSession = await this.waitForNewSession(pullRequest, stream, token, true); @@ -167,33 +231,25 @@ export class CopilotRemoteAgentManager extends Disposable { return { errorDetails: { message: error.message } }; } } else { - /* @copilot invoked from a 'normal' chat */ - - // TODO(jospicer): Use confirmations to guide users - - const number = await startSession(request.prompt, context.history, 'chat'); // TODO(jospicer): 'All of the chat messages so far in the current chat session. Currently, only chat messages for the current participant are included' - if (!number) { - return {}; - } - const pullRequest = await this.findPullRequestById(number, true); - if (!pullRequest) { - stream.warning(vscode.l10n.t('Could not find the associated pull request {0} for this chat session.', number)); - return {}; - } - - const uri = await toOpenPullRequestWebviewUri({ owner: pullRequest.remote.owner, repo: pullRequest.remote.repositoryName, pullRequestNumber: pullRequest.number }); - const plaintextBody = marked.parse(pullRequest.body, { renderer: new PlainTextRenderer(true), smartypants: true }).trim(); - - const card = new vscode.ChatResponsePullRequestPart(uri, pullRequest.title, plaintextBody, pullRequest.author.specialDisplayName ?? pullRequest.author.login, `#${pullRequest.number}`); - stream.push(card); - stream.markdown(vscode.l10n.t('GitHub Copilot coding agent has begun working on your request. Follow its progress in the associated chat and pull request.')); - vscode.window.showChatSession(COPILOT_SWE_AGENT, String(number), { viewColumn: vscode.ViewColumn.Active }); + /* @copilot invoked from a 'normal' chat or 'cloud button' */ + stream.confirmation( + vscode.l10n.t('Delegate to agent'), + DELEGATE_MODAL_DETAILS, + { + step: 'create', + metadata: { + prompt: context.chatSummary?.prompt ?? request.prompt, + history: context.chatSummary?.history, + references: request.references, + } + }, + ['Delegate', 'Cancel'] + ); } } public static ID = 'CopilotRemoteAgentManager'; - private readonly _stateModel: CopilotStateModel; private readonly _onDidChangeStates = this._register(new vscode.EventEmitter()); readonly onDidChangeStates = this._onDidChangeStates.event; private readonly _onDidChangeNotifications = this._register(new vscode.EventEmitter()); @@ -206,11 +262,7 @@ export class CopilotRemoteAgentManager extends Disposable { readonly onDidCommitChatSession = this._onDidCommitChatSession.event; private readonly gitOperationsManager: GitOperationsManager; - - private codingAgentPRsPromise: Promise<{ - item: PullRequestModel; - status: CopilotPRStatus; - }[]> | undefined; + private _isAssignable: boolean | undefined; constructor( private credentialStore: CredentialStore, @@ -218,6 +270,7 @@ export class CopilotRemoteAgentManager extends Disposable { private telemetry: ITelemetry, private context: vscode.ExtensionContext, private gitAPI: GitApiImpl, + private readonly prsTreeModel: PrsTreeModel, ) { super(); this.gitOperationsManager = new GitOperationsManager(CopilotRemoteAgentManager.ID); @@ -227,13 +280,12 @@ export class CopilotRemoteAgentManager extends Disposable { } })); - this._stateModel = new CopilotStateModel(); - this._register(new CopilotPRWatcher(this.repositoriesManager, this._stateModel)); - this._register(this._stateModel.onDidChangeStates(() => { + this._register(new CopilotPRWatcher(this.repositoriesManager, this.prsTreeModel)); + this._register(this.prsTreeModel.onDidChangeCopilotStates(() => { this._onDidChangeStates.fire(); this._onDidChangeChatSessions.fire(); })); - this._register(this._stateModel.onDidChangeNotifications(items => this._onDidChangeNotifications.fire(items))); + this._register(this.prsTreeModel.onDidChangeCopilotNotifications(items => this._onDidChangeNotifications.fire(items))); this._register(this.repositoriesManager.onDidChangeFolderRepositories((event) => { if (event.added) { @@ -299,9 +351,18 @@ export class CopilotRemoteAgentManager extends Disposable { } async isAssignable(): Promise { + const setCachedResult = (b: boolean) => { + this._isAssignable = b; + return b; + }; + + if (this._isAssignable !== undefined) { + return this._isAssignable; + } + const repoInfo = await this.repoInfo(); if (!repoInfo) { - return false; + return setCachedResult(false); } const { fm } = repoInfo; @@ -312,14 +373,12 @@ export class CopilotRemoteAgentManager extends Disposable { const allAssignableUsers = fm.getAllAssignableUsers(); if (!allAssignableUsers) { - return false; + return setCachedResult(false); } - - // Check if any of the copilot logins are in the assignable users - return allAssignableUsers.some(user => COPILOT_LOGINS.includes(user.login)); + return setCachedResult(allAssignableUsers.some(user => COPILOT_LOGINS.includes(user.login))); } catch (error) { // If there's an error fetching assignable users, assume not assignable - return false; + return setCachedResult(false); } } @@ -349,6 +408,7 @@ export class CopilotRemoteAgentManager extends Disposable { private async updateAssignabilityContext(): Promise { try { + this._isAssignable = undefined; // Invalidate cache const available = await this.isAvailable(); commands.setContext('copilotCodingAgentAssignable', available); } catch (error) { @@ -367,7 +427,7 @@ export class CopilotRemoteAgentManager extends Disposable { private chooseFolderManager(): Promise { return chooseItem( this.repositoriesManager.folderManagers, - itemValue => pathLib.basename(itemValue.repository.rootUri.fsPath), + itemValue => ({ label: pathLib.basename(itemValue.repository.rootUri.fsPath) }), ); } @@ -402,7 +462,7 @@ export class CopilotRemoteAgentManager extends Disposable { const result = await chooseItem( ghRemotes, - itemValue => `${itemValue.remoteName} (${itemValue.owner}/${itemValue.repositoryName})`, + itemValue => ({ label: itemValue.remoteName, description: `(${itemValue.owner}/${itemValue.repositoryName})` }), { title: vscode.l10n.t('Coding agent will create pull requests against the selected remote.'), } @@ -543,7 +603,7 @@ export class CopilotRemoteAgentManager extends Disposable { let autoPushAndCommit = false; const message = vscode.l10n.t('Copilot coding agent will continue your work in \'{0}\'.', repoName); - const detail = vscode.l10n.t('Your chat context will be used to continue work in a new pull request.'); + const detail = DELEGATE_MODAL_DETAILS; if (source !== 'prompt' && hasChanges && CopilotRemoteAgentConfig.getAutoCommitAndPushEnabled()) { // Pending changes modal const modalResult = await vscode.window.showInformationMessage( @@ -606,6 +666,8 @@ export class CopilotRemoteAgentManager extends Disposable { summary, undefined, autoPushAndCommit, + undefined, + fm ); if (result.state !== 'success') { @@ -649,7 +711,7 @@ export class CopilotRemoteAgentManager extends Disposable { } else { await this.provideChatSessions(new vscode.CancellationTokenSource().token); if (pr) { - vscode.window.showChatSession(COPILOT_SWE_AGENT, `${pr.number}`, {}); + vscode.commands.executeCommand('vscode.open', vscode.Uri.from({ scheme: COPILOT_SWE_AGENT, path: '/' + pr.number })); } } @@ -669,20 +731,51 @@ export class CopilotRemoteAgentManager extends Disposable { return vscode.l10n.t('🚀 Coding agent will continue work in [#{0}]({1}). Track progress [here]({2}).', number, link, webviewUri.toString()); } - async invokeRemoteAgent(prompt: string, problemContext?: string, token?: vscode.CancellationToken, autoPushAndCommit = true): Promise { + async invokeRemoteAgent(prompt: string, problemContext?: string, token?: vscode.CancellationToken, autoPushAndCommit = true, chatStream?: vscode.ChatResponseStream, fm?: FolderRepositoryManager): Promise { const capiClient = await this.copilotApi; if (!capiClient) { - return { error: vscode.l10n.t('Failed to initialize Copilot API'), state: 'error' }; + return { error: vscode.l10n.t('Failed to initialize Copilot API. Please try again later.'), state: 'error' }; } await this.promptAndUpdatePreferredGitHubRemote(true); - const repoInfo = await this.repoInfo(); + const repoInfo = await this.repoInfo(fm); if (!repoInfo) { return { error: vscode.l10n.t('No repository information found. Please open a workspace with a GitHub repository.'), state: 'error' }; } const { owner, repo, remote, repository, ghRepository, baseRef } = repoInfo; + // Check if user has permission to access the repository + try { + await ghRepository.octokit.api.repos.get({ owner, repo }); + } catch (error) { + if (error.status === 404 || error.status === 403) { + const currentUser = await this.credentialStore.getCurrentUser(remote.authProviderId); + return { + error: vscode.l10n.t( + 'Unable to access {0} as user {1}. Please check your permissions and try again.', + `\`${owner}/${repo}\``, + `\`${currentUser.login}\``, + ), + state: 'error', + }; + } + + // Re-throw other errors to be handled by the outer catch block + throw error; + } + + // Check if user has permission to assign Copilot in repository + if (!(await this.isAssignable())) { + return { + error: vscode.l10n.t( + 'Unable to assign GitHub Copilot coding agent in {0}. Please check your permissions and try again.', + `\`${owner}/${repo}\`` + ), + state: 'error', + }; + } + // NOTE: This is as unobtrusive as possible with the current high-level APIs. // We only create a new branch and commit if there are staged or working changes. // This could be improved if we add lower-level APIs to our git extension (e.g. in-memory temp git index). @@ -695,9 +788,10 @@ export class CopilotRemoteAgentManager extends Disposable { return { error: vscode.l10n.t('Uncommitted changes detected. Please commit or stash your changes before starting the remote agent. Enable \'{0}\' to push your changes automatically.', CODING_AGENT_AUTO_COMMIT_AND_PUSH), state: 'error' }; } try { + chatStream?.progress(vscode.l10n.t('Waiting for local changes')); head_ref = await this.gitOperationsManager.commitAndPushChanges(repoInfo); } catch (error) { - return { error: error.message, state: 'error' }; + return { error: vscode.l10n.t('Failed to commit and push changes. Please try again later.'), innerError: error.message, state: 'error' }; } } @@ -719,6 +813,7 @@ export class CopilotRemoteAgentManager extends Disposable { const { problemStatement, isTruncated } = truncatePrompt(prompt, problemContext); if (isTruncated) { + chatStream?.progress(vscode.l10n.t('Truncating context')); const truncationResult = await vscode.window.showWarningMessage( vscode.l10n.t('Prompt size exceeded'), { modal: true, detail: vscode.l10n.t('Your prompt will be truncated to fit within coding agent\'s context window. This may affect the quality of the response.') }, CONTINUE_TRUNCATION); const userCancelled = token?.isCancellationRequested || !truncationResult || truncationResult !== CONTINUE_TRUNCATION; @@ -731,7 +826,7 @@ export class CopilotRemoteAgentManager extends Disposable { isCancelled: String(userCancelled), }); if (userCancelled) { - return { error: vscode.l10n.t('User cancelled due to truncation'), state: 'error' }; + return { error: vscode.l10n.t('User cancelled due to truncation.'), state: 'error' }; } } @@ -748,22 +843,36 @@ export class CopilotRemoteAgentManager extends Disposable { }; try { - const { pull_request, session_id } = await capiClient.postRemoteAgentJob(owner, repo, payload, isTruncated); - this._onDidCreatePullRequest.fire(pull_request.number); - const webviewUri = await toOpenPullRequestWebviewUri({ owner, repo, pullRequestNumber: pull_request.number }); + chatStream?.progress(vscode.l10n.t('Delegating to coding agent')); + const response = await capiClient.postRemoteAgentJob(owner, repo, payload, isTruncated); + + // For v1 API, we need to fetch the job details to get the PR info + // Since the PR might not be created immediately, we need to poll for it + chatStream?.progress(vscode.l10n.t('Creating pull request')); + const jobInfo = await this.waitForJobWithPullRequest(capiClient, owner, repo, response.job_id, token); + if (!jobInfo || !jobInfo.pull_request) { + return { error: vscode.l10n.t('Failed to retrieve pull request information from job'), state: 'error' }; + } + + const { number } = jobInfo.pull_request; + + // Find the actual PR to get the HTML URL + const pullRequest = await this.findPullRequestById(number, true); + const htmlUrl = pullRequest?.html_url || `https://github.com/${owner}/${repo}/pull/${number}`; + + const webviewUri = await toOpenPullRequestWebviewUri({ owner, repo, pullRequestNumber: number }); const prLlmString = `The remote agent has begun work and has created a pull request. Details about the pull request are being shown to the user. If the user wants to track progress or iterate on the agent's work, they should use the pull request.`; - await this.waitForQueuedToInProgress(session_id, token); return { state: 'success', - number: pull_request.number, - link: pull_request.html_url, + number, + link: htmlUrl, webviewUri, llmDetails: head_ref ? `Local pending changes have been pushed to branch '${head_ref}'. ${prLlmString}` : prLlmString, - sessionId: session_id + sessionId: response.session_id }; } catch (error) { - return { error: error.message, state: 'error' }; + return { error: vscode.l10n.t('Failed delegating to coding agent. Please try again later.'), innerError: error.message, state: 'error' }; } } @@ -876,39 +985,6 @@ export class CopilotRemoteAgentManager extends Disposable { })[0]; } - getNotificationsCount(owner: string, repo: string): number { - return this._stateModel.getNotificationsCount(owner, repo); - } - - get notificationsCount(): number { - return this._stateModel.notifications.size; - } - - - public clearAllNotifications(owner?: string, repo?: string): void { - this._stateModel.clearAllNotifications(owner, repo); - } - - hasNotification(owner: string, repo: string, pullRequestNumber?: number): boolean { - if (pullRequestNumber !== undefined) { - const key = this._stateModel.makeKey(owner, repo, pullRequestNumber); - return this._stateModel.notifications.has(key); - } else { - const partialKey = this._stateModel.makeKey(owner, repo); - return Array.from(this._stateModel.notifications.keys()).some(key => { - return key.startsWith(partialKey); - }); - } - } - - getStateForPR(owner: string, repo: string, prNumber: number): CopilotPRStatus { - return this._stateModel.get(owner, repo, prNumber); - } - - getCounts(owner: string, repo: string): { total: number; inProgress: number; error: number } { - return this._stateModel.getCounts(owner, repo); - } - async extractHistory(history: ReadonlyArray): Promise { if (!history) { return; @@ -968,29 +1044,9 @@ export class CopilotRemoteAgentManager extends Disposable { await this.waitRepoManagerInitialization(); - let codingAgentPRs: CodingAgentPRAndStatus[] = []; - if (this._stateModel.isInitialized) { - codingAgentPRs = this._stateModel.all; - Logger.debug(`Fetched PRs from state model: ${codingAgentPRs.length}`, CopilotRemoteAgentManager.ID); - } else { - this.codingAgentPRsPromise = this.codingAgentPRsPromise ?? new Promise(async (resolve) => { - try { - const sessions = await capi.getAllCodingAgentPRs(this.repositoriesManager); - const prAndStatus = await Promise.all(sessions.map(async pr => { - const timeline = await pr.getCopilotTimelineEvents(pr); - const status = copilotEventToStatus(mostRecentCopilotEvent(timeline)); - return { item: pr, status }; - })); - - resolve(prAndStatus); - } catch (error) { - Logger.error(`Failed to fetch coding agent PRs: ${error}`, CopilotRemoteAgentManager.ID); - resolve([]); - } - }); - codingAgentPRs = await this.codingAgentPRsPromise; - Logger.debug(`Fetched PRs from API: ${codingAgentPRs.length}`, CopilotRemoteAgentManager.ID); - } + let codingAgentPRs: CodingAgentPRAndStatus[] = await this.prsTreeModel.getCopilotPullRequests(); + Logger.debug(`Fetched PRs from API: ${codingAgentPRs.length}`, CopilotRemoteAgentManager.ID); + return await Promise.all(codingAgentPRs.map(async prAndStatus => { const timestampNumber = new Date(prAndStatus.item.createdAt).getTime(); const status = copilotPRStatusToSessionStatus(prAndStatus.status); @@ -999,9 +1055,19 @@ export class CopilotRemoteAgentManager extends Disposable { const uri = await toOpenPullRequestWebviewUri({ owner: pullRequest.remote.owner, repo: pullRequest.remote.repositoryName, pullRequestNumber: pullRequest.number }); const prLinkTitle = vscode.l10n.t('Open pull request in VS Code'); - const description = new vscode.MarkdownString(`[#${pullRequest.number}](${uri.toString()} "${prLinkTitle}")`); // pullRequest.base.ref === defaultBranch ? `PR #${pullRequest.number}`: `PR #${pullRequest.number} → ${pullRequest.base.ref}`; - return { - id: `${pullRequest.number}`, + + // If we have multiple repositories, include the repo name in the link text + // e.g., 'owner/repo #123' instead of just '#123' + let repoInfo = ''; + if (this.repositoriesManager.folderManagers.length > 1) { + const owner = pullRequest.remote.owner; + const repo = pullRequest.remote.repositoryName; + repoInfo = `${owner}/${repo} `; + } + const fileCount = pullRequest.fileChanges.size === 0 ? (await pullRequest.getFileChangesInfo()).length : pullRequest.fileChanges.size; + const description = new vscode.MarkdownString(`[${repoInfo}#${pullRequest.number}](${uri.toString()} "${prLinkTitle}")`); // pullRequest.base.ref === defaultBranch ? `PR #${pullRequest.number}`: `PR #${pullRequest.number} → ${pullRequest.base.ref}`; + const chatSession: ChatSessionWithPR = { + resource: vscode.Uri.from({ scheme: COPILOT_SWE_AGENT, path: '/' + pullRequest.number }), label: pullRequest.title || `Session ${pullRequest.number}`, iconPath: this.getIconForSession(status), pullRequest: pullRequest, @@ -1013,19 +1079,19 @@ export class CopilotRemoteAgentManager extends Disposable { }, statistics: pullRequest.item.additions !== undefined && pullRequest.item.deletions !== undefined && (pullRequest.item.additions > 0 || pullRequest.item.deletions > 0) ? { insertions: pullRequest.item.additions, - deletions: pullRequest.item.deletions + deletions: pullRequest.item.deletions, + files: fileCount } : undefined }; + return chatSession; })); } catch (error) { Logger.error(`Failed to provide coding agents information: ${error}`, CopilotRemoteAgentManager.ID); - } finally { - this.codingAgentPRsPromise = undefined; } return []; } - public async provideChatSessionContent(id: string, token: vscode.CancellationToken): Promise { + public async provideChatSessionContent(resource: URI, token: vscode.CancellationToken): Promise { try { const capi = await this.copilotApi; if (!capi || token.isCancellationRequested) { @@ -1037,16 +1103,16 @@ export class CopilotRemoteAgentManager extends Disposable { let pullRequestNumber: number | undefined; let sessionIndex: number | undefined; - const indexedSessionId = SessionIdForPr.parse(id); + const indexedSessionId = SessionIdForPr.parse(resource); if (indexedSessionId) { pullRequestNumber = indexedSessionId.prNumber; sessionIndex = indexedSessionId.sessionIndex; } if (typeof pullRequestNumber === 'undefined') { - pullRequestNumber = parseInt(id); + pullRequestNumber = parseInt(resource.path.slice(1)); if (isNaN(pullRequestNumber)) { - Logger.error(`Invalid pull request number: ${id}`, CopilotRemoteAgentManager.ID); + Logger.error(`Invalid pull request number: ${resource}`, CopilotRemoteAgentManager.ID); return this.createEmptySession(); } } @@ -1102,14 +1168,14 @@ export class CopilotRemoteAgentManager extends Disposable { sessions: SessionInfo[], pullRequest: PullRequestModel ): ((stream: vscode.ChatResponseStream, token: vscode.CancellationToken) => Thenable) | undefined { - // Only the latest in-progress session gets activeResponseCallback - const inProgressSession = sessions + // Only the latest in-progress or queued session gets activeResponseCallback + const pendingSession = sessions .slice() .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) - .find(session => session.state === 'in_progress'); + .find(session => session.state === 'in_progress' || session.state === 'queued'); - if (inProgressSession) { - return this.createActiveResponseCallback(pullRequest, inProgressSession.id); + if (pendingSession) { + return this.createActiveResponseCallback(pullRequest, pendingSession.id); } return undefined; } @@ -1124,6 +1190,8 @@ export class CopilotRemoteAgentManager extends Disposable { private createActiveResponseCallback(pullRequest: PullRequestModel, sessionId: string): (stream: vscode.ChatResponseStream, token: vscode.CancellationToken) => Thenable { return async (stream: vscode.ChatResponseStream, token: vscode.CancellationToken) => { // Use the shared streaming logic + await this.waitForQueuedToInProgress(sessionId, stream, token); + this._onDidCreatePullRequest.fire(pullRequest.number); return this.streamSessionLogs(stream, pullRequest, sessionId, token); }; } @@ -1439,6 +1507,7 @@ export class CopilotRemoteAgentManager extends Disposable { private async waitForQueuedToInProgress( sessionId: string, + stream?: vscode.ChatResponseStream, token?: vscode.CancellationToken ): Promise { const capi = await this.copilotApi; @@ -1456,6 +1525,7 @@ export class CopilotRemoteAgentManager extends Disposable { do { sessionInfo = await capi.getSessionInfo(sessionId); if (sessionInfo && sessionInfo.state === 'queued') { + stream?.progress(vscode.l10n.t('Attaching to session')); Logger.trace('Queued session found', CopilotRemoteAgentManager.ID); break; } @@ -1467,6 +1537,10 @@ export class CopilotRemoteAgentManager extends Disposable { } while (waitForQueuedCount <= waitForQueuedMaxRetries && (!token || !token.isCancellationRequested)); if (!sessionInfo || sessionInfo.state !== 'queued') { + if (sessionInfo?.state === 'in_progress') { + Logger.trace('Session already in progress', CopilotRemoteAgentManager.ID); + return sessionInfo; + } // Failure Logger.trace('Failed to find queued session', CopilotRemoteAgentManager.ID); return; @@ -1485,6 +1559,7 @@ export class CopilotRemoteAgentManager extends Disposable { } await new Promise(resolve => setTimeout(resolve, pollInterval)); } + Logger.warn(`Timed out waiting for session ${sessionId} to transition from queued to in_progress`, CopilotRemoteAgentManager.ID); } private async waitForNewSession( @@ -1518,7 +1593,7 @@ export class CopilotRemoteAgentManager extends Disposable { if (!waitForTransitionToInProgress) { return newSession; } - const inProgressSession = await this.waitForQueuedToInProgress(newSession.id, token); + const inProgressSession = await this.waitForQueuedToInProgress(newSession.id, stream, token); if (!inProgressSession) { stream.markdown(vscode.l10n.t('Timed out waiting for coding agent to begin work. Please try again shortly.')); return; @@ -1546,7 +1621,34 @@ export class CopilotRemoteAgentManager extends Disposable { } public refreshChatSessions(): void { - this._stateModel.clear(); + this.prsTreeModel.clearCopilotCaches(); + this._onDidChangeChatSessions.fire(); + } + + private async waitForJobWithPullRequest( + capiClient: CopilotApi, + owner: string, + repo: string, + jobId: string, + token?: vscode.CancellationToken + ): Promise { + const maxWaitTime = 30 * 1000; // 30 seconds + const pollInterval = 2000; // 2 seconds + const startTime = Date.now(); + + Logger.appendLine(`Waiting for job ${jobId} to have pull request information...`, CopilotRemoteAgentManager.ID); + + while (Date.now() - startTime < maxWaitTime && (!token || !token.isCancellationRequested)) { + const jobInfo = await capiClient.getJobByJobId(owner, repo, jobId); + if (jobInfo && jobInfo.pull_request && jobInfo.pull_request.number) { + Logger.appendLine(`Job ${jobId} now has pull request #${jobInfo.pull_request.number}`, CopilotRemoteAgentManager.ID); + return jobInfo; + } + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + + Logger.warn(`Timed out waiting for job ${jobId} to have pull request information`, CopilotRemoteAgentManager.ID); + return undefined; } public async cancelMostRecentChatSession(pullRequest: PullRequestModel): Promise { diff --git a/src/github/copilotRemoteAgent/chatSessionContentBuilder.ts b/src/github/copilotRemoteAgent/chatSessionContentBuilder.ts index 756509396b..626c3fccc1 100644 --- a/src/github/copilotRemoteAgent/chatSessionContentBuilder.ts +++ b/src/github/copilotRemoteAgent/chatSessionContentBuilder.ts @@ -95,9 +95,9 @@ export class ChatSessionContentBuilder { private async createResponseTurn(pullRequest: PullRequestModel, logs: string, session: SessionInfo): Promise { if (logs.trim().length > 0) { return await this.parseSessionLogsIntoResponseTurn(pullRequest, logs, session); - } else if (session.state === 'in_progress') { + } else if (session.state === 'in_progress' || session.state === 'queued') { // For in-progress sessions without logs, create a placeholder response - const placeholderParts = [new vscode.ChatResponseProgressPart('Session is initializing...')]; + const placeholderParts = [new vscode.ChatResponseProgressPart('Initializing session')]; const responseResult: vscode.ChatResult = {}; return new vscode.ChatResponseTurn2(placeholderParts, responseResult, COPILOT_SWE_AGENT); } else { diff --git a/src/github/copilotRemoteAgent/gitOperationsManager.ts b/src/github/copilotRemoteAgent/gitOperationsManager.ts index ae7df244f4..dd9d49640d 100644 --- a/src/github/copilotRemoteAgent/gitOperationsManager.ts +++ b/src/github/copilotRemoteAgent/gitOperationsManager.ts @@ -3,9 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { adjectives, animals, colors, NumberDictionary, uniqueNamesGenerator } from '@joaomoreno/unique-names-generator'; import vscode from 'vscode'; import { Repository } from '../../api/api'; import Logger from '../../common/logger'; +import { BRANCH_RANDOM_NAME_DICTIONARY, BRANCH_WHITESPACE_CHAR, GIT } from '../../common/settingKeys'; import { RepoInfo } from '../common'; export class GitOperationsManager { @@ -13,7 +15,7 @@ export class GitOperationsManager { async commitAndPushChanges(repoInfo: RepoInfo) { const { repository, remote, baseRef } = repoInfo; - const asyncBranch = `copilot/vscode${Date.now()}`; + const asyncBranch = await this.generateRandomBranchName(repository, 'copilot'); try { await repository.createBranch(asyncBranch, true); @@ -138,4 +140,54 @@ export class GitOperationsManager { } } } + + // Adapted from https://github.com/microsoft/vscode/blob/e35e3b4e057450ea3d90c724fae5e3e9619b96fe/extensions/git/src/commands.ts#L3007 + private async generateRandomBranchName(repository: Repository, prefix: string): Promise { + const config = vscode.workspace.getConfiguration(GIT); + const branchWhitespaceChar = config.get(BRANCH_WHITESPACE_CHAR); + const branchRandomNameDictionary = config.get(BRANCH_RANDOM_NAME_DICTIONARY); + + // Default to legacy behaviour if config mismatches core + if (branchWhitespaceChar === undefined || branchRandomNameDictionary === undefined) { + return `copilot/vscode${Date.now()}`; + } + + const separator = branchWhitespaceChar; + const dictionaries: string[][] = []; + for (const dictionary of branchRandomNameDictionary) { + if (dictionary.toLowerCase() === 'adjectives') { + dictionaries.push(adjectives); + } + if (dictionary.toLowerCase() === 'animals') { + dictionaries.push(animals); + } + if (dictionary.toLowerCase() === 'colors') { + dictionaries.push(colors); + } + if (dictionary.toLowerCase() === 'numbers') { + dictionaries.push(NumberDictionary.generate({ length: 3 })); + } + } + + if (dictionaries.length === 0) { + return ''; + } + + // 5 attempts to generate a random branch name + for (let index = 0; index < 5; index++) { + const randomName = `${prefix}/${uniqueNamesGenerator({ + dictionaries, + length: dictionaries.length, + separator + })}`; + + // Check for local ref conflict + const refs = await repository.getRefs?.({ pattern: `refs/heads/${randomName}` }); + if (!refs || refs.length === 0) { + return randomName; + } + } + + return ''; + } } diff --git a/src/github/copilotRemoteAgentUtils.ts b/src/github/copilotRemoteAgentUtils.ts index 4eae9d2d04..306efb2449 100644 --- a/src/github/copilotRemoteAgentUtils.ts +++ b/src/github/copilotRemoteAgentUtils.ts @@ -4,8 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import Logger from '../common/logger'; import { MAX_PROBLEM_STATEMENT_LENGTH } from './copilotApi'; +import Logger from '../common/logger'; /** * Truncation utility to ensure the problem statement sent to Copilot API is under the maximum length. diff --git a/src/github/createPRLinkProvider.ts b/src/github/createPRLinkProvider.ts index b2b6d7b1db..543de0559c 100644 --- a/src/github/createPRLinkProvider.ts +++ b/src/github/createPRLinkProvider.ts @@ -4,9 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { FolderRepositoryManager } from './folderRepositoryManager'; import { PR_SETTINGS_NAMESPACE, TERMINAL_LINK_HANDLER } from '../common/settingKeys'; import { ReviewManager } from '../view/reviewManager'; -import { FolderRepositoryManager } from './folderRepositoryManager'; interface GitHubCreateTerminalLink extends vscode.TerminalLink { url: string; diff --git a/src/github/createPRViewProvider.ts b/src/github/createPRViewProvider.ts index d2c28e7097..40763443bb 100644 --- a/src/github/createPRViewProvider.ts +++ b/src/github/createPRViewProvider.ts @@ -4,6 +4,20 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { + byRemoteName, + FolderRepositoryManager, + PullRequestDefaults, + titleAndBodyFrom, +} from './folderRepositoryManager'; +import { GitHubRepository } from './githubRepository'; +import { IAccount, ILabel, IMilestone, IProject, isITeam, ITeam, MergeMethod, RepoAccessAndMergeMethods } from './interface'; +import { BaseBranchMetadata, PullRequestGitHelper } from './pullRequestGitHelper'; +import { PullRequestModel } from './pullRequestModel'; +import { getDefaultMergeMethod } from './pullRequestOverview'; +import { getAssigneesQuickPickItems, getLabelOptions, getMilestoneFromQuickPick, getProjectFromQuickPick, reviewersQuickPick } from './quickPicks'; +import { getIssueNumberLabelFromParsed, ISSUE_EXPRESSION, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput, variableSubstitution } from './utils'; +import { DisplayLabel, PreReviewState } from './views'; import { RemoteInfo } from '../../common/types'; import { ChooseBaseRemoteAndBranchResult, ChooseCompareRemoteAndBranchResult, ChooseRemoteAndBranchArgs, CreateParamsNew, CreatePullRequestNew, TitleAndDescriptionArgs } from '../../common/views'; import type { Branch, Ref } from '../api/api'; @@ -24,23 +38,10 @@ import { } from '../common/settingKeys'; import { ITelemetry } from '../common/telemetry'; import { asPromise, compareIgnoreCase, formatError, promiseWithTimeout } from '../common/utils'; -import { getNonce, IRequestMessage, WebviewViewBase } from '../common/webview'; +import { generateUuid } from '../common/uuid'; +import { IRequestMessage, WebviewViewBase } from '../common/webview'; import { PREVIOUS_CREATE_METHOD } from '../extensionState'; import { CreatePullRequestDataModel } from '../view/createPullRequestDataModel'; -import { - byRemoteName, - FolderRepositoryManager, - PullRequestDefaults, - titleAndBodyFrom, -} from './folderRepositoryManager'; -import { GitHubRepository } from './githubRepository'; -import { IAccount, ILabel, IMilestone, IProject, isITeam, ITeam, MergeMethod, RepoAccessAndMergeMethods } from './interface'; -import { BaseBranchMetadata, PullRequestGitHelper } from './pullRequestGitHelper'; -import { PullRequestModel } from './pullRequestModel'; -import { getDefaultMergeMethod } from './pullRequestOverview'; -import { getAssigneesQuickPickItems, getLabelOptions, getMilestoneFromQuickPick, getProjectFromQuickPick, reviewersQuickPick } from './quickPicks'; -import { getIssueNumberLabelFromParsed, ISSUE_EXPRESSION, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput, variableSubstitution } from './utils'; -import { DisplayLabel, PreReviewState } from './views'; const ISSUE_CLOSING_KEYWORDS = new RegExp('closes|closed|close|fixes|fixed|fix|resolves|resolved|resolve\s$', 'i'); // https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword @@ -549,7 +550,7 @@ export abstract class BaseCreatePullRequestViewProvider repo.remote.owner === this.model.compareOwner)?.remote.remoteName; - const branchRemoteChanged = compareBranch && (compareBranch.upstream?.remote !== currentCompareRemote); - if (branchChanged || branchRemoteChanged) { - this._defaultCompareBranch = compareBranch!.name!; - this.model.setCompareBranch(compareBranch!.name); - this.changeBranch(compareBranch!.name!, false).then(async titleAndDescription => { - const params: Partial = { - defaultTitle: titleAndDescription.title, - defaultDescription: titleAndDescription.description, - compareBranch: compareBranch?.name, - defaultCompareBranch: compareBranch?.name, - warning: await this.existingPRMessage(), - }; - if (!branchRemoteChanged) { - return this._postMessage({ - command: 'pr.initialize', - params, - }); - } + this._defaultCompareBranch = compareBranch!.name!; + this.model.setCompareBranch(compareBranch!.name); + this.changeBranch(compareBranch!.name!, false).then(async titleAndDescription => { + const params: Partial = { + defaultTitle: titleAndDescription.title, + defaultDescription: titleAndDescription.description, + compareBranch: compareBranch?.name, + defaultCompareBranch: compareBranch?.name, + warning: await this.existingPRMessage(), + }; + return this._postMessage({ + command: 'pr.initialize', + params, }); - } + }); + } public override show(compareBranch?: Branch): void { @@ -704,7 +699,7 @@ export class CreatePullRequestViewProvider extends BaseCreatePullRequestViewProv const [totalCommits, lastCommit, pullRequestTemplate] = await Promise.all([ this.getTotalGitHubCommits(compareBranch, baseBranch), name ? titleAndBodyFrom(promiseWithTimeout(this._folderRepositoryManager.getTipCommitMessage(name), 5000)) : undefined, - descrptionSource === 'template' ? await this.getPullRequestTemplate() : undefined + descrptionSource === 'template' ? this.getPullRequestTemplate() : undefined ]); const totalNonMergeCommits = totalCommits?.filter(commit => commit.parents.length < 2); @@ -1028,11 +1023,13 @@ export class CreatePullRequestViewProvider extends BaseCreatePullRequestViewProv private async getTitleAndDescriptionFromProvider(token: vscode.CancellationToken, searchTerm?: string) { return CreatePullRequestViewProvider.withProgress(async () => { try { + const templatePromise = this.getPullRequestTemplate(); // Fetch in parallel const { commitMessages, patches } = await this.getCommitsAndPatches(); const issues = await this.findIssueContext(commitMessages); + const template = await templatePromise; const provider = this._folderRepositoryManager.getTitleAndDescriptionProvider(searchTerm); - const result = await provider?.provider.provideTitleAndDescription({ commitMessages, patches, issues }, token); + const result = await provider?.provider.provideTitleAndDescription({ commitMessages, patches, issues, template }, token); if (provider) { this.lastGeneratedTitleAndDescription = { ...result, providerTitle: provider.title }; diff --git a/src/github/credentials.ts b/src/github/credentials.ts index c5f88acf0e..ce2f62b102 100644 --- a/src/github/credentials.ts +++ b/src/github/credentials.ts @@ -9,6 +9,9 @@ import { setContext } from 'apollo-link-context'; import { createHttpLink } from 'apollo-link-http'; import fetch from 'cross-fetch'; import * as vscode from 'vscode'; +import { IAccount } from './interface'; +import { LoggingApolloClient, LoggingOctokit, RateLogger } from './loggingOctokit'; +import { convertRESTUserToAccount, getEnterpriseUri, hasEnterpriseUri, isEnterprise } from './utils'; import { AuthProvider } from '../common/authentication'; import { commands } from '../common/executeCommands'; import { Disposable } from '../common/lifecycle'; @@ -18,9 +21,6 @@ import { GITHUB_ENTERPRISE, URI } from '../common/settingKeys'; import { initBasedOnSettingChange } from '../common/settingsUtils'; import { ITelemetry } from '../common/telemetry'; import { agent } from '../env/node/net'; -import { IAccount } from './interface'; -import { LoggingApolloClient, LoggingOctokit, RateLogger } from './loggingOctokit'; -import { convertRESTUserToAccount, getEnterpriseUri, hasEnterpriseUri, isEnterprise } from './utils'; const TRY_AGAIN = vscode.l10n.t('Try again?'); const CANCEL = vscode.l10n.t('Cancel'); @@ -551,7 +551,7 @@ const link = (url: string, token: string) => createHttpLink({ uri: `${url}/graphql`, // https://github.com/apollographql/apollo-link/issues/513 - fetch: fetch as any, + fetch: fetch as (((input: URL | string, init?: RequestInit) => Promise) | undefined), }), ); diff --git a/src/github/emptyCommitWebview.ts b/src/github/emptyCommitWebview.ts new file mode 100644 index 0000000000..29a5f8127e --- /dev/null +++ b/src/github/emptyCommitWebview.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +/** + * Opens a webview panel to display a message for an empty commit. + * The message is centered and styled similar to GitHub.com. + */ +export function showEmptyCommitWebview(extensionUri: vscode.Uri, commitSha: string): void { + const panel = vscode.window.createWebviewPanel( + 'emptyCommit', + vscode.l10n.t('Commit {0}', commitSha.substring(0, 7)), + vscode.ViewColumn.Active, + { + enableScripts: false, + localResourceRoots: [] + } + ); + + panel.iconPath = { + light: vscode.Uri.joinPath(extensionUri, 'resources', 'icons', 'codicons', 'git-commit.svg'), + dark: vscode.Uri.joinPath(extensionUri, 'resources', 'icons', 'codicons', 'git-commit.svg') + }; + + panel.webview.html = getEmptyCommitHtml(); +} + +function getEmptyCommitHtml(): string { + return ` + + + + + Empty Commit + + + +
+
+ +
+
No changes to show.
+
This commit has no content.
+
+ +`; +} diff --git a/src/github/folderRepositoryManager.ts b/src/github/folderRepositoryManager.ts index 41e4585be3..70490a368b 100644 --- a/src/github/folderRepositoryManager.ts +++ b/src/github/folderRepositoryManager.ts @@ -6,6 +6,27 @@ import * as nodePath from 'path'; import { bulkhead } from 'cockatiel'; import * as vscode from 'vscode'; +import { OctokitCommon } from './common'; +import { ConflictModel } from './conflictGuide'; +import { ConflictResolutionCoordinator } from './conflictResolutionCoordinator'; +import { Conflict, ConflictResolutionModel } from './conflictResolutionModel'; +import { CredentialStore } from './credentials'; +import { CopilotWorkingStatus, GitHubRepository, ItemsData, PULL_REQUEST_PAGE_SIZE, PullRequestChangeEvent, PullRequestData, TeamReviewerRefreshKind, ViewerPermission } from './githubRepository'; +import { PullRequestResponse, PullRequestState } from './graphql'; +import { IAccount, ILabel, IMilestone, IProject, IPullRequestsPagingOptions, Issue, ITeam, MergeMethod, PRType, PullRequestMergeability, RepoAccessAndMergeMethods, User } from './interface'; +import { IssueModel } from './issueModel'; +import { PullRequestGitHelper, PullRequestMetadata } from './pullRequestGitHelper'; +import { IResolvedPullRequestModel, PullRequestModel } from './pullRequestModel'; +import { + convertRESTIssueToRawPullRequest, + convertRESTPullRequestToRawPullRequest, + getOverrideBranch, + getPRFetchQuery, + loginComparator, + parseGraphQLPullRequest, + teamComparator, + variableSubstitution, +} from './utils'; import type { Branch, Commit, Repository, UpstreamRef } from '../api/api'; import { GitApiImpl, GitErrorCodes } from '../api/api1'; import { GitHubManager } from '../authentication/githubServer'; @@ -20,7 +41,6 @@ import { GitHubRemote, parseRemote, parseRepositoryRemotes, Remote } from '../co import { ALLOW_FETCH, AUTO_STASH, - DEFAULT_MERGE_METHOD, GIT, POST_DONE, PR_SETTINGS_NAMESPACE, @@ -30,36 +50,14 @@ import { UPSTREAM_REMOTE, } from '../common/settingKeys'; import { ITelemetry } from '../common/telemetry'; -import { EventType, TimelineEvent } from '../common/timelineEvent'; +import { EventType } from '../common/timelineEvent'; import { Schemes } from '../common/uri'; -import { batchPromiseAll, compareIgnoreCase, formatError, Predicate } from '../common/utils'; +import { AsyncPredicate, batchPromiseAll, compareIgnoreCase, formatError, Predicate } from '../common/utils'; import { PULL_REQUEST_OVERVIEW_VIEW_TYPE } from '../common/webview'; import { LAST_USED_EMAIL, NEVER_SHOW_PULL_NOTIFICATION, REPO_KEYS, ReposState } from '../extensionState'; import { git } from '../gitProviders/gitCommands'; import { IThemeWatcher } from '../themeWatcher'; import { CreatePullRequestHelper } from '../view/createPullRequestHelper'; -import { OctokitCommon } from './common'; -import { ConflictModel } from './conflictGuide'; -import { ConflictResolutionCoordinator } from './conflictResolutionCoordinator'; -import { Conflict, ConflictResolutionModel } from './conflictResolutionModel'; -import { CredentialStore } from './credentials'; -import { CopilotWorkingStatus, GitHubRepository, GraphQLError, GraphQLErrorType, ItemsData, PULL_REQUEST_PAGE_SIZE, PullRequestChangeEvent, PullRequestData, TeamReviewerRefreshKind, ViewerPermission } from './githubRepository'; -import { MergeMethod as GraphQLMergeMethod, MergePullRequestInput, MergePullRequestResponse, PullRequestResponse, PullRequestState } from './graphql'; -import { IAccount, ILabel, IMilestone, IProject, IPullRequestsPagingOptions, Issue, ITeam, MergeMethod, PRType, PullRequestMergeability, RepoAccessAndMergeMethods, User } from './interface'; -import { IssueModel } from './issueModel'; -import { PullRequestGitHelper, PullRequestMetadata } from './pullRequestGitHelper'; -import { IResolvedPullRequestModel, PullRequestModel } from './pullRequestModel'; -import { - convertRESTIssueToRawPullRequest, - convertRESTPullRequestToRawPullRequest, - getOverrideBranch, - getPRFetchQuery, - loginComparator, - parseCombinedTimelineEvents, - parseGraphQLPullRequest, - teamComparator, - variableSubstitution, -} from './utils'; async function createConflictResolutionModel(pullRequest: PullRequestModel): Promise { const head = pullRequest.head; @@ -195,9 +193,6 @@ export class FolderRepositoryManager extends Disposable { private _repositoryPageInformation: Map = new Map(); private _addedUpstreamCount: number = 0; - private _onDidMergePullRequest = this._register(new vscode.EventEmitter()); - readonly onDidMergePullRequest = this._onDidMergePullRequest.event; - private _onDidChangeActivePullRequest = this._register(new vscode.EventEmitter<{ new: PullRequestModel | undefined, old: PullRequestModel | undefined }>()); readonly onDidChangeActivePullRequest: vscode.Event<{ new: PullRequestModel | undefined, old: PullRequestModel | undefined }> = this._onDidChangeActivePullRequest.event; private _onDidChangeActiveIssue = this._register(new vscode.EventEmitter()); @@ -209,6 +204,12 @@ export class FolderRepositoryManager extends Disposable { private _onDidChangeRepositories = this._register(new vscode.EventEmitter<{ added: boolean }>()); readonly onDidChangeRepositories: vscode.Event<{ added: boolean }> = this._onDidChangeRepositories.event; + /** + * Tracks whether changes were stashed when checking out the current PR. + * Used to determine if we should pop the stash when returning to the default branch. + */ + private _stashedOnCheckout: boolean = false; + private _onDidChangeAssignableUsers = this._register(new vscode.EventEmitter()); readonly onDidChangeAssignableUsers: vscode.Event = this._onDidChangeAssignableUsers.event; @@ -378,6 +379,14 @@ export class FolderRepositoryManager extends Disposable { this._onDidChangeActivePullRequest.fire({ old: oldPR, new: pullRequest }); } + get stashedOnCheckout(): boolean { + return this._stashedOnCheckout; + } + + set stashedOnCheckout(value: boolean) { + this._stashedOnCheckout = value; + } + get repository(): Repository { return this._repository; } @@ -494,8 +503,10 @@ export class FolderRepositoryManager extends Disposable { const activeRemotes = await this.getActiveRemotes(); const isAuthenticated = this.checkForAuthMatch(activeRemotes); if (this.credentialStore.isAnyAuthenticated() && (activeRemotes.length === 0)) { - const areAllNeverGitHub = (await this.computeAllUnknownRemotes()).every(remote => GitHubManager.isNeverGitHub(vscode.Uri.parse(remote.normalizedHost).authority)); - if (areAllNeverGitHub) { + const allUnknownRemotes = await this.computeAllUnknownRemotes(); + const areAllNeverGitHub = allUnknownRemotes.every(remote => GitHubManager.isNeverGitHub(vscode.Uri.parse(remote.normalizedHost).authority)); + if ((allUnknownRemotes.length > 0) && areAllNeverGitHub) { + Logger.appendLine('No GitHub remotes found and all remotes are marked as never GitHub.', this.id); this.state = ReposManagerState.RepositoriesLoaded; return true; } @@ -1087,7 +1098,13 @@ export class FolderRepositoryManager extends Disposable { } }; + const activeGitHubRemotes = await this.getActiveGitHubRemotes(this._allGitHubRemotes); + const githubRepositories = this._githubRepositories.filter(repo => { + if (!activeGitHubRemotes.find(r => r.equals(repo.remote))) { + return false; + } + const info = this._repositoryPageInformation.get(repo.remote.url.toString() + queryId); // If we are in case 1 or 3, don't filter out repos that are out of pages, as we will be querying from the start. return info && (options.fetchNextPage === false || info.hasMorePages !== false); @@ -1303,13 +1320,13 @@ export class FolderRepositoryManager extends Disposable { * Pull request defaults in the query, like owner and repository variables, will be resolved. */ async getIssues( - query?: string, + query?: string, options: IPullRequestsPagingOptions = { fetchNextPage: false, fetchOnePagePerRepo: false } ): Promise | undefined> { if (this.gitHubRepositories.length === 0) { return undefined; } try { - const data = await this.fetchPagedData({ fetchNextPage: false, fetchOnePagePerRepo: false }, `issuesKey${query}`, PagedDataType.IssueSearch, PRType.All, query); + const data = await this.fetchPagedData(options, `issuesKey${query}`, PagedDataType.IssueSearch, PRType.All, query); const mappedData: ItemsResponseResult = { items: [], hasMorePages: data.hasMorePages, @@ -1507,7 +1524,7 @@ export class FolderRepositoryManager extends Disposable { ? first // I GUESS THAT'S WHAT WE'RE GOING WITH, THEN. : // Otherwise, let's try... this.findRepo(byRemoteName('origin')) || // by convention - this.findRepo(ownedByMe) || // bc maybe we can push there + await this.findRepoAsync(ownedByMe) || // bc maybe we can push there first; // out of raw desperation } @@ -1515,6 +1532,17 @@ export class FolderRepositoryManager extends Disposable { return this._githubRepositories.filter(where)[0]; } + findRepoAsync(where: AsyncPredicate): Promise { + return (async () => { + for (const repo of this._githubRepositories) { + if (await where(repo)) { + return repo; + } + } + return undefined; + })(); + } + get upstreamRef(): UpstreamRef | undefined { const { HEAD } = this.repository.state; return HEAD && HEAD.upstream; @@ -1681,101 +1709,6 @@ export class FolderRepositoryManager extends Disposable { return this._credentialStore.getCurrentUser(githubRepository.remote.authProviderId); } - async mergePullRequest( - pullRequest: PullRequestModel, - title?: string, - description?: string, - method?: 'merge' | 'squash' | 'rebase', - email?: string, - ): Promise<{ merged: boolean, message: string, timeline?: TimelineEvent[] }> { - Logger.debug(`Merging PR: ${pullRequest.number} method: ${method} for user: "${email}" - enter`, this.id); - const { mutate, schema } = await pullRequest.githubRepository.ensure(); - - const activePRSHA = this.activePullRequest && this.activePullRequest.head && this.activePullRequest.head.sha; - const workingDirectorySHA = this.repository.state.HEAD && this.repository.state.HEAD.commit; - const mergingPRSHA = pullRequest.head && pullRequest.head.sha; - const workingDirectoryIsDirty = this.repository.state.workingTreeChanges.length > 0; - let expectedHeadOid: string | undefined = pullRequest.head?.sha; - - if (activePRSHA === mergingPRSHA) { - // We're on the branch of the pr being merged. - expectedHeadOid = workingDirectorySHA; - if (workingDirectorySHA !== mergingPRSHA) { - // We are looking at different commit than what will be merged - const { ahead } = this.repository.state.HEAD!; - const pluralMessage = vscode.l10n.t('You have {0} unpushed commits on this pull request branch.\n\nWould you like to proceed anyway?', ahead ?? 'unknown'); - const singularMessage = vscode.l10n.t('You have 1 unpushed commit on this pull request branch.\n\nWould you like to proceed anyway?'); - if (ahead && - (await vscode.window.showWarningMessage( - ahead > 1 ? pluralMessage : singularMessage, - { modal: true }, - vscode.l10n.t('Yes'), - )) === undefined) { - - return { - merged: false, - message: vscode.l10n.t('unpushed changes'), - }; - } - } - - if (workingDirectoryIsDirty) { - // We have made changes to the PR that are not committed - if ( - (await vscode.window.showWarningMessage( - vscode.l10n.t('You have uncommitted changes on this pull request branch.\n\n Would you like to proceed anyway?'), - { modal: true }, - vscode.l10n.t('Yes'), - )) === undefined - ) { - return { - merged: false, - message: vscode.l10n.t('uncommitted changes'), - }; - } - } - } - const input: MergePullRequestInput = { - pullRequestId: pullRequest.graphNodeId, - commitHeadline: title, - commitBody: description, - expectedHeadOid, - authorEmail: email, - mergeMethod: - (method?.toUpperCase() ?? - vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'merge' | 'squash' | 'rebase'>(DEFAULT_MERGE_METHOD, 'merge')?.toUpperCase()) as GraphQLMergeMethod, - }; - - return mutate({ - mutation: schema.MergePullRequest, - variables: { - input - } - }) - .then(async (result) => { - Logger.debug(`Merging PR: ${pullRequest.number}} - done`, this.id); - - /* __GDPR__ - "pr.merge.success" : {} - */ - this.telemetry.sendTelemetryEvent('pr.merge.success'); - this._onDidMergePullRequest.fire(); - return { merged: true, message: '', timeline: await parseCombinedTimelineEvents(result.data?.mergePullRequest.pullRequest.timelineItems.nodes ?? [], await pullRequest.getCopilotTimelineEvents(pullRequest), pullRequest.githubRepository) }; - }) - .catch(e => { - /* __GDPR__ - "pr.merge.failure" : {} - */ - this.telemetry.sendTelemetryErrorEvent('pr.merge.failure'); - const graphQLErrors = e.graphQLErrors as GraphQLError[] | undefined; - if (graphQLErrors?.length && graphQLErrors.find(error => error.type === GraphQLErrorType.Unprocessable && error.message?.includes('Head branch was modified'))) { - return { merged: false, message: vscode.l10n.t('Head branch was modified. Pull, review, then try again.') }; - } else { - throw e; - } - }); - } - async deleteBranch(pullRequest: PullRequestModel) { await pullRequest.githubRepository.deleteBranch(pullRequest); } @@ -2219,10 +2152,10 @@ export class FolderRepositoryManager extends Disposable { useCache: boolean = false, ): Promise { const githubRepo = await this.resolveItem(owner, repositoryName); - Logger.appendLine(`Found GitHub repo for pr #${pullRequestNumber}: ${githubRepo ? 'yes' : 'no'}`, this.id); + Logger.trace(`Found GitHub repo for pr #${pullRequestNumber}: ${githubRepo ? 'yes' : 'no'}`, this.id); if (githubRepo) { const pr = await githubRepo.getPullRequest(pullRequestNumber, useCache); - Logger.appendLine(`Found GitHub pr repo for pr #${pullRequestNumber}: ${pr ? 'yes' : 'no'}`, this.id); + Logger.trace(`Found GitHub pr repo for pr #${pullRequestNumber}: ${pr ? 'yes' : 'no'}`, this.id); return pr; } return undefined; @@ -2233,10 +2166,14 @@ export class FolderRepositoryManager extends Disposable { repositoryName: string, pullRequestNumber: number, withComments: boolean = false, + useCache: boolean = false ): Promise { const githubRepo = await this.resolveItem(owner, repositoryName); + Logger.trace(`Found GitHub repo for issue #${pullRequestNumber}: ${githubRepo ? 'yes' : 'no'}`, this.id); if (githubRepo) { - return githubRepo.getIssue(pullRequestNumber, withComments); + const issue = await githubRepo.getIssue(pullRequestNumber, withComments, useCache); + Logger.trace(`Found GitHub issue repo for issue #${pullRequestNumber}: ${issue ? 'yes' : 'no'}`, this.id); + return issue; } return undefined; } @@ -2947,9 +2884,8 @@ export function getEventType(text: string) { } } -const ownedByMe: Predicate = repo => { - const { currentUser = null } = repo.octokit as any; - return currentUser && repo.remote.owner === currentUser.login; +const ownedByMe: AsyncPredicate = async repo => { + return repo.isCurrentUser(repo.remote.authProviderId, repo.remote.owner); }; export const byRemoteName = (name: string): Predicate => ({ remote: { remoteName } }) => diff --git a/src/github/githubRepository.ts b/src/github/githubRepository.ts index 7e05ff9a8d..a08bd61e93 100644 --- a/src/github/githubRepository.ts +++ b/src/github/githubRepository.ts @@ -4,17 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import * as buffer from 'buffer'; -import { ApolloQueryResult, DocumentNode, FetchResult, MutationOptions, NetworkStatus, QueryOptions } from 'apollo-boost'; +import { ApolloQueryResult, DocumentNode, FetchResult, MutationOptions, NetworkStatus, OperationVariables, QueryOptions } from 'apollo-boost'; import LRUCache from 'lru-cache'; import * as vscode from 'vscode'; -import { AuthenticationError, AuthProvider, GitHubServerType, isSamlError } from '../common/authentication'; -import { Disposable, disposeAll } from '../common/lifecycle'; -import Logger from '../common/logger'; -import { GitHubRemote, parseRemote } from '../common/remote'; -import { ITelemetry } from '../common/telemetry'; -import { PullRequestCommentController } from '../view/pullRequestCommentController'; -import { PRCommentControllerRegistry } from '../view/pullRequestCommentControllerRegistry'; -import { mergeQuerySchemaWithShared, OctokitCommon, Schema } from './common'; +import { mergeQuerySchemaWithShared, OctokitCommon } from './common'; import { CredentialStore, GitHub } from './credentials'; import { AssignableUsersResponse, @@ -79,6 +72,21 @@ import { parseMilestone, restPaginate, } from './utils'; +import { AuthenticationError, AuthProvider, GitHubServerType, isSamlError } from '../common/authentication'; + +import { Disposable, disposeAll } from '../common/lifecycle'; + +import Logger from '../common/logger'; +import { GitHubRemote, parseRemote } from '../common/remote'; + + +import { BRANCH_LIST_TIMEOUT, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; +import { ITelemetry } from '../common/telemetry'; + +import { PullRequestCommentController } from '../view/pullRequestCommentController'; + +import { PRCommentControllerRegistry } from '../view/pullRequestCommentControllerRegistry'; + export const PULL_REQUEST_PAGE_SIZE = 20; @@ -169,9 +177,17 @@ export class GitHubRepository extends Disposable { value.model.dispose(); } }); + private _issueModelsByNumber: LRUCache = new LRUCache({ + maxAge: 1000 * 60 * 60 * 4 /* 4 hours */, stale: true, updateAgeOnGet: true, + dispose: (_key, value) => { + disposeAll(value.disposables); + value.model.dispose(); + } + }); // eslint-disable-next-line rulesdir/no-any-except-union-method-signature private _queriesSchema: any; private _areQueriesLimited: boolean = false; + get areQueriesLimited(): boolean { return this._areQueriesLimited; } private _onDidAddPullRequest: vscode.EventEmitter = this._register(new vscode.EventEmitter()); public readonly onDidAddPullRequest: vscode.Event = this._onDidAddPullRequest.event; @@ -197,19 +213,26 @@ export class GitHubRepository extends Disposable { return this._pullRequestModelsByNumber.get(prNumber)?.model; } + getExistingIssueModel(issueNumber: number): IssueModel | undefined { + return this._issueModelsByNumber.get(issueNumber)?.model; + } + get pullRequestModels(): PullRequestModel[] { return Array.from(this._pullRequestModelsByNumber.values().map(value => value.model)); } + get issueModels(): IssueModel[] { + return Array.from(this._issueModelsByNumber.values().map(value => value.model)); + } + public async ensureCommentsController(): Promise { try { + await this.ensure(); if (this.commentsController) { return; } - - await this.ensure(); this.commentsController = vscode.comments.createCommentController( - `${PullRequestCommentController.PREFIX}-${this.remote.gitProtocol.normalizeUri()?.authority}-${this.remote.owner}-${this.remote.repositoryName}`, + `${PullRequestCommentController.PREFIX}-${this.remote.gitProtocol.normalizeUri()?.authority}-${this.remote.remoteName}-${this.remote.owner}-${this.remote.repositoryName}`, `Pull Request (${this.remote.owner}/${this.remote.repositoryName})`, ); this.commentsHandler = new PRCommentControllerRegistry(this.commentsController, this.telemetry); @@ -243,7 +266,7 @@ export class GitHubRepository extends Disposable { silent: boolean = false ) { super(); - this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default as unknown as Schema, defaultSchema as unknown as Schema); + this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default, defaultSchema); // kick off the comments controller early so that the Comments view is visible and doesn't pop up later in an way that's jarring if (!silent) { this.ensureCommentsController(); @@ -277,17 +300,18 @@ export class GitHubRepository extends Disposable { } } - query = async (query: QueryOptions, ignoreSamlErrors: boolean = false, legacyFallback?: { query: DocumentNode }): Promise> => { + query = async (query: QueryOptions, ignoreSamlErrors: boolean = false, legacyFallback?: { query: DocumentNode, variables?: OperationVariables }): Promise> => { const gql = this.authMatchesServer && this.hub && this.hub.graphql; if (!gql) { const logValue = (query.query.definitions[0] as { name: { value: string } | undefined }).name?.value; Logger.debug(`Not available for query: ${logValue ?? 'unknown'}`, GRAPHQL_COMPONENT_ID); - return { - data: null, + const empty: ApolloQueryResult = { + data: null as T, loading: false, networkStatus: NetworkStatus.error, stale: false, - } as any; + } satisfies ApolloQueryResult; + return empty; } let rsp; @@ -299,6 +323,7 @@ export class GitHubRepository extends Disposable { Logger.error(`Error querying GraphQL API (${logInfo}): ${e.message}${gqlErrors ? `. ${gqlErrors.map(error => error.extensions?.code).join(',')}` : ''}`, this.id); if (legacyFallback) { query.query = legacyFallback.query; + query.variables = legacyFallback.variables; return this.query(query, ignoreSamlErrors); } @@ -306,7 +331,7 @@ export class GitHubRepository extends Disposable { // We're running against a GitHub server that doesn't support the query we're trying to run. // Switch to the limited schema and try again. this._areQueriesLimited = true; - this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default as any, limitedSchema.default as any); + this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default, limitedSchema.default); query.query = this.schema[(query.query.definitions[0] as { name: { value: string } }).name.value]; rsp = await gql.query(query); } else if (ignoreSamlErrors && isSamlError(e)) { @@ -328,15 +353,13 @@ export class GitHubRepository extends Disposable { const gql = this.authMatchesServer && this.hub && this.hub.graphql; if (!gql) { Logger.debug(`Not available for query: ${mutation.context as string}`, GRAPHQL_COMPONENT_ID); - return { - data: null, - loading: false, - networkStatus: NetworkStatus.error, - stale: false, - } as any; + const empty: FetchResult = { + data: null + }; + return empty; } - let rsp; + let rsp: FetchResult; try { rsp = await gql.mutate(mutation); } catch (e) { @@ -373,7 +396,8 @@ export class GitHubRepository extends Disposable { repo }); Logger.debug(`Fetch metadata for repo ${owner}/${repo} - done`, this.id); - return ({ ...result.data, currentUser: (octokit as any).currentUser } as unknown) as IMetadata; + const metadata = { ...result.data, currentUser: await this._hub?.currentUser }; + return metadata; } async getMetadata(): Promise { @@ -426,14 +450,14 @@ export class GitHubRepository extends Disposable { } if (oldHub !== this._hub) { - if (this._areQueriesLimited || this._credentialStore.areScopesOld(this.remote.authProviderId)) { + if (this._areQueriesLimited || this._credentialStore.areScopesOld(this.remote.authProviderId) || (this.remote.authProviderId === AuthProvider.githubEnterprise)) { this._areQueriesLimited = true; - this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default as any, limitedSchema.default as any); + this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default, limitedSchema.default); } else { if (this._credentialStore.areScopesExtra(this.remote.authProviderId)) { - this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default as any, extraSchema.default as any); + this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default, extraSchema.default); } else { - this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default as any, defaultSchema as any); + this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default, defaultSchema); } } } @@ -497,7 +521,7 @@ export class GitHubRepository extends Disposable { squash: data.allow_squash_merge ?? false, rebase: data.allow_rebase_merge ?? false, }, - viewerCanAutoMerge: ((data as any).allow_auto_merge && hasWritePermission) ?? false + viewerCanAutoMerge: (data.allow_auto_merge && hasWritePermission) ?? false }; } return this._repoAccessAndMergeMethods; @@ -999,6 +1023,19 @@ export class GitHubRepository extends Disposable { return model; } + private createOrUpdateIssueModel(issue: Issue): IssueModel { + let model = this._issueModelsByNumber.get(issue.number)?.model; + if (model) { + model.update(issue); + } else { + model = new IssueModel(this.telemetry, this, this.remote, issue); + // No issue-specific event emitters yet; store empty disposables list for symmetry/cleanup + const disposables: vscode.Disposable[] = []; + this._issueModelsByNumber.set(issue.number, { model, disposables }); + } + return model; + } + private _onPullRequestModelChanged(model: PullRequestModel, change: IssueChangeEvent): void { this._onDidChangePullRequests.fire([{ model, event: change }]); } @@ -1084,14 +1121,23 @@ export class GitHubRepository extends Disposable { } Logger.debug(`Fetch pull request ${id} - done`, this.id); - return this.createOrUpdatePullRequestModel(await parseGraphQLPullRequest(data.repository.pullRequest, this)); + const pr = this.createOrUpdatePullRequestModel(await parseGraphQLPullRequest(data.repository.pullRequest, this)); + await pr.getLastUpdateTime(new Date(pr.item.updatedAt)); + return pr; } catch (e) { Logger.error(`Unable to fetch PR: ${e}`, this.id); return; } } - async getIssue(id: number, withComments: boolean = false): Promise { + async getIssue(id: number, withComments: boolean = false, useCache: boolean = false): Promise { + if (useCache) { + const cached = this._issueModelsByNumber.get(id)?.model; + if (cached) { + Logger.debug(`Using cached issue model for ${id}`, this.id); + return cached; + } + } try { Logger.debug(`Fetch issue ${id} - enter`, this.id); const { query, remote, schema } = await this.ensure(); @@ -1111,7 +1157,9 @@ export class GitHubRepository extends Disposable { } Logger.debug(`Fetch issue ${id} - done`, this.id); - return new IssueModel(this.telemetry, this, remote, await parseGraphQLIssue(data.repository.issue, this)); + const issue = this.createOrUpdateIssueModel(await parseGraphQLIssue(data.repository.issue, this)); + await issue.getLastUpdateTime(new Date(issue.item.updatedAt)); + return issue; } catch (e) { Logger.error(`Unable to fetch issue: ${e}`, this.id); return; @@ -1136,7 +1184,7 @@ export class GitHubRepository extends Disposable { path: filePath, ref, }, - )) as any; + )) as { data: { content: string; encoding: string; sha: string } }; if (Array.isArray(fileContent.data)) { throw new Error(`Unexpected array response when getting file ${filePath}`); @@ -1164,7 +1212,7 @@ export class GitHubRepository extends Disposable { Logger.debug(`Fetch blob file ${filePath} - done`, this.id); } - const buff = buffer.Buffer.from(contents, (fileContent.data as any).encoding); + const buff = buffer.Buffer.from(contents, fileContent.data.encoding as BufferEncoding); Logger.debug(`Fetch file ${filePath}, file length ${contents.length} - done`, this.id); return buff; } @@ -1194,6 +1242,7 @@ export class GitHubRepository extends Disposable { const branches: string[] = []; const defaultBranch = (await this.getMetadataForRepo(owner, repositoryName)).default_branch; const startingTime = new Date().getTime(); + const timeout = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(BRANCH_LIST_TIMEOUT, 5000); do { try { @@ -1208,8 +1257,8 @@ export class GitHubRepository extends Disposable { }); branches.push(...data.repository.refs.nodes.map(node => node.name)); - if (new Date().getTime() - startingTime > 5000) { - Logger.warn('List branches timeout hit.', this.id); + if (new Date().getTime() - startingTime > timeout) { + Logger.warn(`List branches timeout hit after ${timeout}ms.`, this.id); break; } hasNextPage = data.repository.refs.pageInfo.hasNextPage; @@ -1330,6 +1379,14 @@ export class GitHubRepository extends Disposable { first: 100, after: after, }, + }, false, { + query: schema.GetAssignableUsers, + variables: { + owner: remote.owner, + name: remote.repositoryName, + first: 100, + after: after, + } }); } else { @@ -1352,9 +1409,9 @@ export class GitHubRepository extends Disposable { const users = (result.data as AssignableUsersResponse).repository?.assignableUsers ?? (result.data as SuggestedActorsResponse).repository?.suggestedActors; ret.push( - ...users?.nodes.map(node => { + ...(users?.nodes.map(node => { return parseAccount(node, this); - }), + }) || []), ); hasNextPage = users?.pageInfo.hasNextPage; diff --git a/src/github/graphql.ts b/src/github/graphql.ts index 3e6824f985..ad48671e85 100644 --- a/src/github/graphql.ts +++ b/src/github/graphql.ts @@ -3,8 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { DiffSide, SubjectType, ViewedState } from '../common/comment'; import { ForkDetails } from './githubRepository'; +import { DiffSide, SubjectType, ViewedState } from '../common/comment'; interface PageInfo { hasNextPage: boolean; @@ -262,7 +262,7 @@ export interface TimelineEventsResponse { repository: { pullRequest: { timelineItems: { - nodes: (MergedEvent | Review | IssueComment | Commit | AssignedEvent | HeadRefDeletedEvent)[]; + nodes: (MergedEvent | Review | IssueComment | Commit | AssignedEvent | HeadRefDeletedEvent | null)[]; }; }; } | null; diff --git a/src/github/interface.ts b/src/github/interface.ts index f616e039bd..39a69fa8bc 100644 --- a/src/github/interface.ts +++ b/src/github/interface.ts @@ -260,7 +260,7 @@ export interface Notification { }; reason: string; unread: boolean; - updatedAd: Date; + updatedAt: Date; lastReadAt: Date | undefined; } diff --git a/src/github/issueModel.ts b/src/github/issueModel.ts index 691f96adc1..2793661eee 100644 --- a/src/github/issueModel.ts +++ b/src/github/issueModel.ts @@ -4,13 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { COPILOT_ACCOUNTS, IComment } from '../common/comment'; -import { Disposable } from '../common/lifecycle'; -import Logger from '../common/logger'; -import { Remote } from '../common/remote'; -import { ITelemetry } from '../common/telemetry'; -import { ClosedEvent, CrossReferencedEvent, EventType, TimelineEvent } from '../common/timelineEvent'; -import { compareIgnoreCase, formatError } from '../common/utils'; import { OctokitCommon } from './common'; import { CopilotWorkingStatus, GitHubRepository } from './githubRepository'; import { @@ -25,6 +18,13 @@ import { } from './graphql'; import { GithubItemStateEnum, IAccount, IIssueEditData, IMilestone, IProject, IProjectItem, Issue, StateReason } from './interface'; import { convertRESTIssueToRawPullRequest, eventTime, parseCombinedTimelineEvents, parseGraphQlIssueComment, parseMilestone, parseSelectRestTimelineEvents, restPaginate } from './utils'; +import { COPILOT_ACCOUNTS, IComment } from '../common/comment'; +import { Disposable } from '../common/lifecycle'; +import Logger from '../common/logger'; +import { Remote } from '../common/remote'; +import { ITelemetry } from '../common/telemetry'; +import { ClosedEvent, CrossReferencedEvent, EventType, TimelineEvent } from '../common/timelineEvent'; +import { compareIgnoreCase, formatError } from '../common/utils'; export interface IssueChangeEvent { title?: true; @@ -65,7 +65,10 @@ export class IssueModel extends Disposable { public body: string; public bodyHTML?: string; + private _lastCheckedForUpdatesAt?: Date; + private _timelineEvents: readonly TimelineEvent[] | undefined; + private _copilotTimelineEvents: TimelineEvent[] | undefined; protected _onDidChange = this._register(new vscode.EventEmitter()); public onDidChange = this._onDidChange.event; @@ -93,6 +96,10 @@ export class IssueModel extends Disposable { } } + public get lastCheckedForUpdatesAt(): Date | undefined { + return this._lastCheckedForUpdatesAt; + } + public get isOpen(): boolean { return this.state === GithubItemStateEnum.Open; } @@ -420,6 +427,8 @@ export class IssueModel extends Disposable { async getLastUpdateTime(time: Date): Promise { Logger.debug(`Fetch timeline events of issue #${this.number} - enter`, IssueModel.ID); + // Record when we initiated this check regardless of outcome so callers can know staleness. + this._lastCheckedForUpdatesAt = new Date(); const githubRepository = this.githubRepository; const { query, remote, schema } = await githubRepository.ensure(); try { @@ -506,13 +515,19 @@ export class IssueModel extends Disposable { /** * TODO: @alexr00 we should delete this https://github.com/microsoft/vscode-pull-request-github/issues/6965 */ - async getCopilotTimelineEvents(issueModel: IssueModel, skipMerge: boolean = false): Promise { + async getCopilotTimelineEvents(issueModel: IssueModel, skipMerge: boolean = false, useCache: boolean = false): Promise { if (!COPILOT_ACCOUNTS[issueModel.author.login]) { return []; } Logger.debug(`Fetch Copilot timeline events of issue #${issueModel.number} - enter`, GitHubRepository.ID); + if (useCache && this._copilotTimelineEvents) { + Logger.debug(`Fetch Copilot timeline events of issue #${issueModel.number} (used cache) - exit`, GitHubRepository.ID); + + return this._copilotTimelineEvents; + } + const { octokit, remote } = await this.githubRepository.ensure(); try { const timeline = await restPaginate(octokit.api.issues.listEventsForTimeline, { @@ -523,6 +538,7 @@ export class IssueModel extends Disposable { }); const timelineEvents = parseSelectRestTimelineEvents(issueModel, timeline); + this._copilotTimelineEvents = timelineEvents; if (timelineEvents.length === 0) { return []; } diff --git a/src/github/issueOverview.ts b/src/github/issueOverview.ts index 09b849df83..41f0e08629 100644 --- a/src/github/issueOverview.ts +++ b/src/github/issueOverview.ts @@ -7,6 +7,12 @@ import * as vscode from 'vscode'; import { CloseResult } from '../../common/views'; import { openPullRequestOnGitHub } from '../commands'; +import { FolderRepositoryManager } from './folderRepositoryManager'; +import { GithubItemStateEnum, IAccount, IMilestone, IProject, IProjectItem, RepoAccessAndMergeMethods } from './interface'; +import { IssueModel } from './issueModel'; +import { getAssigneesQuickPickItems, getLabelOptions, getMilestoneFromQuickPick, getProjectFromQuickPick } from './quickPicks'; +import { isInCodespaces, vscodeDevPrLink } from './utils'; +import { ChangeAssigneesReply, DisplayLabel, Issue, ProjectItemsReply, SubmitReviewReply } from './views'; import { COPILOT_ACCOUNTS, IComment } from '../common/comment'; import { emojify, ensureEmojis } from '../common/emoji'; import Logger from '../common/logger'; @@ -14,13 +20,8 @@ import { PR_SETTINGS_NAMESPACE, WEBVIEW_REFRESH_INTERVAL } from '../common/setti import { ITelemetry } from '../common/telemetry'; import { CommentEvent, EventType, ReviewStateValue, TimelineEvent } from '../common/timelineEvent'; import { asPromise, formatError } from '../common/utils'; -import { getNonce, IRequestMessage, WebviewBase } from '../common/webview'; -import { FolderRepositoryManager } from './folderRepositoryManager'; -import { GithubItemStateEnum, IAccount, IMilestone, IProject, IProjectItem, RepoAccessAndMergeMethods } from './interface'; -import { IssueModel } from './issueModel'; -import { getAssigneesQuickPickItems, getLabelOptions, getMilestoneFromQuickPick, getProjectFromQuickPick } from './quickPicks'; -import { isInCodespaces, vscodeDevPrLink } from './utils'; -import { ChangeAssigneesReply, DisplayLabel, Issue, ProjectItemsReply, SubmitReviewReply } from './views'; +import { generateUuid } from '../common/uuid'; +import { IRequestMessage, WebviewBase } from '../common/webview'; export class IssueOverviewPanel extends WebviewBase { public static ID: string = 'IssueOverviewPanel'; @@ -360,7 +361,7 @@ export class IssueOverviewPanel extends W case 'pr.copy-vscodedevlink': return this.copyVscodeDevLink(); case 'pr.openOnGitHub': - return openPullRequestOnGitHub(this._item, (this._item as any)._telemetry); + return openPullRequestOnGitHub(this._item, this._telemetry); case 'pr.debug': return this.webviewDebug(message); default: @@ -705,7 +706,7 @@ export class IssueOverviewPanel extends W } protected getHtmlForWebview() { - const nonce = getNonce(); + const nonce = generateUuid(); const uri = vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview-pr-description.js'); diff --git a/src/github/loggingOctokit.ts b/src/github/loggingOctokit.ts index 075b556a35..09813b75ae 100644 --- a/src/github/loggingOctokit.ts +++ b/src/github/loggingOctokit.ts @@ -7,13 +7,13 @@ import { Octokit } from '@octokit/rest'; import { ApolloClient, ApolloQueryResult, FetchResult, MutationOptions, NormalizedCacheObject, OperationVariables, QueryOptions } from 'apollo-boost'; import { bulkhead, BulkheadPolicy } from 'cockatiel'; import * as vscode from 'vscode'; +import { RateLimit } from './graphql'; +import { IRawFileChange } from './interface'; +import { restPaginate } from './utils'; import { GitHubRef } from '../common/githubRef'; import Logger from '../common/logger'; import { GitHubRemote } from '../common/remote'; import { ITelemetry } from '../common/telemetry'; -import { RateLimit } from './graphql'; -import { IRawFileChange } from './interface'; -import { restPaginate } from './utils'; interface RestResponse { headers: { @@ -22,6 +22,12 @@ interface RestResponse { } } +interface RateLimitResult { + data: { + rateLimit: RateLimit | undefined + } | undefined; +} + export class RateLogger { private bulkhead: BulkheadPolicy = bulkhead(140); private static ID = 'RateLimit'; @@ -56,7 +62,7 @@ export class RateLogger { return this.bulkhead.execute(() => apiRequest()) as T; } - public async logRateLimit(info: string | undefined, result: Promise<{ data: { rateLimit: RateLimit | undefined } | undefined } | undefined>, isRest: boolean = false) { + public async logRateLimit(info: string | undefined, result: Promise, isRest: boolean = false) { let rateLimitInfo: { limit: number, remaining: number, cost: number } | undefined; try { const resolvedResult = await result; @@ -115,7 +121,7 @@ export class LoggingApolloClient { if (result === undefined) { throw new Error('API call count has exceeded a rate limit.'); } - this._rateLogger.logRateLimit(logInfo, result as any); + this._rateLogger.logRateLimit(logInfo, result as Promise); return result; } @@ -125,7 +131,7 @@ export class LoggingApolloClient { if (result === undefined) { throw new Error('API call count has exceeded a rate limit.'); } - this._rateLogger.logRateLimit(logInfo, result as any); + this._rateLogger.logRateLimit(logInfo, result as Promise); return result; } } diff --git a/src/github/markdownUtils.ts b/src/github/markdownUtils.ts index 8e4b95226f..cf4c766af7 100644 --- a/src/github/markdownUtils.ts +++ b/src/github/markdownUtils.ts @@ -6,15 +6,15 @@ import * as marked from 'marked'; import 'url-search-params-polyfill'; import * as vscode from 'vscode'; -import { ensureEmojis } from '../common/emoji'; -import Logger from '../common/logger'; -import { CODE_PERMALINK, findCodeLinkLocally } from '../issues/issueLinkLookup'; import { PullRequestDefaults } from './folderRepositoryManager'; import { GithubItemStateEnum, User } from './interface'; import { IssueModel } from './issueModel'; import { PullRequestModel } from './pullRequestModel'; import { RepositoriesManager } from './repositoriesManager'; import { getIssueNumberLabelFromParsed, ISSUE_OR_URL_EXPRESSION, makeLabel, parseIssueExpressionOutput, UnsatisfiedChecks } from './utils'; +import { ensureEmojis } from '../common/emoji'; +import Logger from '../common/logger'; +import { CODE_PERMALINK, findCodeLinkLocally } from '../issues/issueLinkLookup'; function getIconString(issue: IssueModel) { switch (issue.state) { diff --git a/src/github/notifications.ts b/src/github/notifications.ts deleted file mode 100644 index aa1e701f4a..0000000000 --- a/src/github/notifications.ts +++ /dev/null @@ -1,394 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { OctokitResponse } from '@octokit/types'; -import * as vscode from 'vscode'; -import { AuthProvider } from '../common/authentication'; -import { Disposable } from '../common/lifecycle'; -import Logger from '../common/logger'; -import { NOTIFICATION_SETTING, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; -import { createPRNodeUri } from '../common/uri'; -import { PullRequestsTreeDataProvider } from '../view/prsTreeDataProvider'; -import { CategoryTreeNode } from '../view/treeNodes/categoryNode'; -import { PRNode } from '../view/treeNodes/pullRequestNode'; -import { TreeNode } from '../view/treeNodes/treeNode'; -import { CredentialStore, GitHub } from './credentials'; -import { GitHubRepository } from './githubRepository'; -import { PullRequestState } from './graphql'; -import { IssueModel } from './issueModel'; -import { PullRequestModel } from './pullRequestModel'; -import { RepositoriesManager } from './repositoriesManager'; -import { hasEnterpriseUri } from './utils'; - -const DEFAULT_POLLING_DURATION = 60; - -export class Notification { - public readonly identifier; - public readonly threadId: number; - public readonly repositoryName: string; - public readonly pullRequestNumber: number; - public pullRequestModel?: PullRequestModel; - - constructor(identifier: string, threadId: number, repositoryName: string, - pullRequestNumber: number, pullRequestModel?: PullRequestModel) { - - this.identifier = identifier; - this.threadId = threadId; - this.repositoryName = repositoryName; - this.pullRequestNumber = pullRequestNumber; - this.pullRequestModel = pullRequestModel; - } -} - -export class NotificationProvider extends Disposable { - private static ID = 'NotificationProvider'; - private readonly _gitHubPrsTree: PullRequestsTreeDataProvider; - private readonly _credentialStore: CredentialStore; - private _authProvider: AuthProvider | undefined; - // The key uniquely identifies a PR from a Repository. The key is created with `getPrIdentifier` - private _notifications: Map; - private readonly _reposManager: RepositoriesManager; - - private _pollingDuration: number; - private _lastModified: string; - private _pollingHandler: NodeJS.Timeout | null; - - private _onDidChangeNotifications: vscode.EventEmitter = this._register(new vscode.EventEmitter()); - public readonly onDidChangeNotifications = this._onDidChangeNotifications.event; - - constructor( - gitHubPrsTree: PullRequestsTreeDataProvider, - credentialStore: CredentialStore, - reposManager: RepositoriesManager - ) { - super(); - this._gitHubPrsTree = gitHubPrsTree; - this._credentialStore = credentialStore; - this._reposManager = reposManager; - this._notifications = new Map(); - - this._lastModified = ''; - this._pollingDuration = DEFAULT_POLLING_DURATION; - this._pollingHandler = null; - - this.registerAuthProvider(credentialStore); - - for (const manager of this._reposManager.folderManagers) { - this._register(manager.onDidChangeGithubRepositories(() => { - this.refreshOrLaunchPolling(); - })); - } - - this._register(gitHubPrsTree.onDidChangeTreeData((node) => { - if (NotificationProvider.isPRNotificationsOn()) { - this.adaptPRNotifications(node); - } - })); - this._register(gitHubPrsTree.onDidChange(() => { - if (NotificationProvider.isPRNotificationsOn()) { - this.adaptPRNotifications(); - } - })); - - this._register(vscode.workspace.onDidChangeConfiguration((e) => { - if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${NOTIFICATION_SETTING}`)) { - this.checkNotificationSetting(); - } - })); - } - - private static isPRNotificationsOn() { - return ( - vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(NOTIFICATION_SETTING) === - 'pullRequests' - ); - } - - private registerAuthProvider(credentialStore: CredentialStore) { - if (credentialStore.isAuthenticated(AuthProvider.githubEnterprise) && hasEnterpriseUri()) { - this._authProvider = AuthProvider.githubEnterprise; - } else if (credentialStore.isAuthenticated(AuthProvider.github)) { - this._authProvider = AuthProvider.github; - } - - this._register(vscode.authentication.onDidChangeSessions(_ => { - if (credentialStore.isAuthenticated(AuthProvider.githubEnterprise) && hasEnterpriseUri()) { - this._authProvider = AuthProvider.githubEnterprise; - } - - if (credentialStore.isAuthenticated(AuthProvider.github)) { - this._authProvider = AuthProvider.github; - } - })); - } - - private getPrIdentifier(pullRequest: IssueModel | OctokitResponse['data']): string { - if (pullRequest instanceof IssueModel) { - return `${pullRequest.remote.url}:${pullRequest.number}`; - } - const splitPrUrl = pullRequest.subject.url.split('/'); - const prNumber = splitPrUrl[splitPrUrl.length - 1]; - return `${pullRequest.repository.html_url}.git:${prNumber}`; - } - - /* Takes a PullRequestModel or a PRIdentifier and - returns true if there is a Notification for the corresponding PR */ - public hasNotification(pullRequest: IssueModel | string): boolean { - const identifier = pullRequest instanceof IssueModel ? - this.getPrIdentifier(pullRequest) : - pullRequest; - const prNotifications = this._notifications.get(identifier); - return prNotifications !== undefined && prNotifications.length > 0; - } - - private updateViewBadge() { - const treeView = this._gitHubPrsTree.view; - const singularMessage = vscode.l10n.t('1 notification'); - const pluralMessage = vscode.l10n.t('{0} notifications', this._notifications.size); - treeView.badge = this._notifications.size !== 0 ? { - tooltip: this._notifications.size === 1 ? singularMessage : pluralMessage, - value: this._notifications.size - } : undefined; - } - - private adaptPRNotifications(node: TreeNode | TreeNode[] | void) { - if (this._pollingHandler === undefined) { - this.startPolling(); - } - - const updateWithPRModel = (prModel: PullRequestModel) => { - const prNotifications = this._notifications.get(this.getPrIdentifier(prModel)); - if (prNotifications) { - for (const prNotification of prNotifications) { - if (prNotification) { - prNotification.pullRequestModel = prModel; - return; - } - } - } - }; - - if (node instanceof PRNode) { - updateWithPRModel(node.pullRequestModel); - } else if (Array.isArray(node)) { - for (const n of node) { - if (n instanceof PRNode) { - updateWithPRModel(n.pullRequestModel); - } - } - } - - this._gitHubPrsTree.cachedChildren().then(async (catNodes: CategoryTreeNode[]) => { - let allPrs: PullRequestModel[] = []; - - for (const catNode of catNodes) { - if (catNode.id === 'All Open') { - if (catNode.prs.size === 0) { - for (const prNode of await catNode.cachedChildren()) { - if (prNode instanceof PRNode) { - allPrs.push(prNode.pullRequestModel); - } - } - } - else { - allPrs = Array.from(catNode.prs.values()); - } - - } - } - - allPrs.forEach((pr) => { - const prNotifications = this._notifications.get(this.getPrIdentifier(pr)); - if (prNotifications) { - for (const prNotification of prNotifications) { - prNotification.pullRequestModel = pr; - } - } - }); - }); - } - - public refreshOrLaunchPolling() { - this._lastModified = ''; - this.checkNotificationSetting(); - } - - private checkNotificationSetting() { - const notificationsTurnedOn = NotificationProvider.isPRNotificationsOn(); - if (notificationsTurnedOn && this._pollingHandler === null) { - this.startPolling(); - } - else if (!notificationsTurnedOn && this._pollingHandler !== null) { - clearInterval(this._pollingHandler); - this._lastModified = ''; - this._pollingHandler = null; - this._pollingDuration = DEFAULT_POLLING_DURATION; - - this._onDidChangeNotifications.fire(this.uriFromNotifications()); - this._notifications.clear(); - this.updateViewBadge(); - } - } - - private uriFromNotifications(): vscode.Uri[] { - const notificationUris: vscode.Uri[] = []; - for (const [identifier, prNotifications] of this._notifications.entries()) { - if (prNotifications.length) { - notificationUris.push(createPRNodeUri(identifier)); - } - } - return notificationUris; - } - - private getGitHub(): GitHub | undefined { - return (this._authProvider !== undefined) ? - this._credentialStore.getHub(this._authProvider) : - undefined; - } - - private async getNotifications() { - const gitHub = this.getGitHub(); - if (gitHub === undefined) - return undefined; - const { data, headers } = await gitHub.octokit.call(gitHub.octokit.api.activity.listNotificationsForAuthenticatedUser, {}); - return { data: data, headers: headers }; - } - - private async markNotificationThreadAsRead(thredId) { - const github = this.getGitHub(); - if (!github) { - return; - } - await github.octokit.call(github.octokit.api.activity.markThreadAsRead, { - thread_id: thredId - }); - } - - public async markPrNotificationsAsRead(pullRequestModel: IssueModel) { - const identifier = this.getPrIdentifier(pullRequestModel); - const prNotifications = this._notifications.get(identifier); - if (prNotifications && prNotifications.length) { - for (const notification of prNotifications) { - await this.markNotificationThreadAsRead(notification.threadId); - } - - const uris = this.uriFromNotifications(); - this._onDidChangeNotifications.fire(uris); - this._notifications.delete(identifier); - this.updateViewBadge(); - } - } - - private async pollForNewNotifications() { - const response = await this.getNotifications(); - if (response === undefined) { - return; - } - const { data, headers } = response; - const pollTimeSuggested = Number(headers['x-poll-interval']); - - // Adapt polling interval if it has changed. - if (pollTimeSuggested !== this._pollingDuration) { - this._pollingDuration = pollTimeSuggested; - if (this._pollingHandler && NotificationProvider.isPRNotificationsOn()) { - Logger.appendLine('Notifications: Clearing interval', NotificationProvider.ID); - clearInterval(this._pollingHandler); - Logger.appendLine(`Notifications: Starting new polling interval with ${this._pollingDuration}`, NotificationProvider.ID); - this.startPolling(); - } - } - - // Only update if the user has new notifications - if (this._lastModified === headers['last-modified']) { - return; - } - this._lastModified = headers['last-modified'] ?? ''; - - const prNodesToUpdate = this.uriFromNotifications(); - this._notifications.clear(); - - const currentRepos = new Map(); - - this._reposManager.folderManagers.forEach(manager => { - manager.gitHubRepositories.forEach(repo => { - currentRepos.set(repo.remote.url, repo); - }); - }); - - await Promise.all(data.map(async (notification) => { - - const repoUrl = `${notification.repository.html_url}.git`; - const githubRepo = currentRepos.get(repoUrl); - - if (githubRepo && notification.subject.type === 'PullRequest') { - const splitPrUrl = notification.subject.url.split('/'); - const prNumber = Number(splitPrUrl[splitPrUrl.length - 1]); - const identifier = this.getPrIdentifier(notification); - - const { remote, query, schema } = await githubRepo.ensure(); - - const { data } = await query({ - query: schema.PullRequestState, - variables: { - owner: remote.owner, - name: remote.repositoryName, - number: prNumber, - }, - }); - - if (data.repository === null) { - Logger.error('Unexpected null repository when getting notifications', NotificationProvider.ID); - } - - // We only consider open PullRequests as these are displayed in the AllOpen PR category. - // Other categories could have queries with closed PRs, but its hard to figure out if a PR - // belongs to a query without loading each PR of that query. - if (data.repository?.pullRequest.state === 'OPEN') { - - const newNotification = new Notification( - identifier, - Number(notification.id), - notification.repository.name, - Number(prNumber) - ); - - const currentPrNotifications = this._notifications.get(identifier); - if (currentPrNotifications === undefined) { - this._notifications.set( - identifier, [newNotification] - ); - } - else { - currentPrNotifications.push(newNotification); - } - } - - } - })); - - this.adaptPRNotifications(); - - this.updateViewBadge(); - for (const uri of this.uriFromNotifications()) { - if (prNodesToUpdate.find(u => u.fsPath === uri.fsPath) === undefined) { - prNodesToUpdate.push(uri); - } - } - - this._onDidChangeNotifications.fire(prNodesToUpdate); - } - - private startPolling() { - this.pollForNewNotifications(); - this._pollingHandler = setInterval( - function (notificationProvider: NotificationProvider) { - notificationProvider.pollForNewNotifications(); - }, - this._pollingDuration * 1000, - this - ); - this._register({ dispose: () => clearInterval(this._pollingHandler!) }); - } -} \ No newline at end of file diff --git a/src/github/overviewRestorer.ts b/src/github/overviewRestorer.ts index d963087fcb..5092d0063f 100644 --- a/src/github/overviewRestorer.ts +++ b/src/github/overviewRestorer.ts @@ -4,9 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { Disposable } from '../common/lifecycle'; -import Logger from '../common/logger'; -import { ITelemetry } from '../common/telemetry'; import { CredentialStore } from './credentials'; import { FolderRepositoryManager } from './folderRepositoryManager'; import { GitHubRepository } from './githubRepository'; @@ -14,6 +11,9 @@ import { IssueOverviewPanel } from './issueOverview'; import { PullRequestOverviewPanel } from './pullRequestOverview'; import { RepositoriesManager } from './repositoriesManager'; import { PullRequest } from './views'; +import { Disposable } from '../common/lifecycle'; +import Logger from '../common/logger'; +import { ITelemetry } from '../common/telemetry'; export class OverviewRestorer extends Disposable implements vscode.WebviewPanelSerializer { private static ID = 'OverviewRestorer'; diff --git a/src/github/prComment.ts b/src/github/prComment.ts index 43d38d2bc6..4547387758 100644 --- a/src/github/prComment.ts +++ b/src/github/prComment.ts @@ -5,15 +5,15 @@ import * as path from 'path'; import * as vscode from 'vscode'; +import { GitHubRepository } from './githubRepository'; +import { IAccount } from './interface'; +import { updateCommentReactions } from './utils'; import { COPILOT_ACCOUNTS, IComment } from '../common/comment'; import { emojify, ensureEmojis } from '../common/emoji'; import Logger from '../common/logger'; import { DataUri } from '../common/uri'; import { ALLOWED_USERS, JSDOC_NON_USERS, PHPDOC_NON_USERS } from '../common/user'; import { escapeRegExp, stringReplaceAsync } from '../common/utils'; -import { GitHubRepository } from './githubRepository'; -import { IAccount } from './interface'; -import { updateCommentReactions } from './utils'; export interface GHPRCommentThread extends vscode.CommentThread2 { gitHubThreadId: string; @@ -190,7 +190,9 @@ export class TemporaryComment extends CommentBase { } get body(): string | vscode.MarkdownString { - return new vscode.MarkdownString(this.input); + const s = new vscode.MarkdownString(this.input); + s.supportAlertSyntax = true; + return s; } get author(): vscode.CommentAuthorInformation { @@ -332,6 +334,7 @@ export class GHPRComment extends CommentBase { if (match) { return suggestionBody ? suggestionBody : ''; } + return undefined; } public commentEditId() { @@ -476,11 +479,15 @@ ${lineContents} if (this.mode === vscode.CommentMode.Editing) { return this._rawBody; } - return new vscode.MarkdownString(this.replacedBody); + const s = new vscode.MarkdownString(this.replacedBody); + s.supportAlertSyntax = true; + return s; } protected getCancelEditBody() { - return new vscode.MarkdownString(this.rawComment.body); + const s = new vscode.MarkdownString(this.rawComment.body); + s.supportAlertSyntax = true; + return s; } } diff --git a/src/github/pullRequestGitHelper.ts b/src/github/pullRequestGitHelper.ts index 2d702ef582..599a3eda6d 100644 --- a/src/github/pullRequestGitHelper.ts +++ b/src/github/pullRequestGitHelper.ts @@ -7,12 +7,12 @@ * Inspired by and includes code from GitHub/VisualStudio project, obtained from https://github.com/github/VisualStudio/blob/165a97bdcab7559e0c4393a571b9ff2aed4ba8a7/src/GitHub.App/Services/PullRequestService.cs */ import * as vscode from 'vscode'; +import { IResolvedPullRequestModel, PullRequestModel } from './pullRequestModel'; import { Branch, Repository } from '../api/api'; import Logger from '../common/logger'; import { Protocol } from '../common/protocol'; import { parseRepositoryRemotes, Remote } from '../common/remote'; import { PR_SETTINGS_NAMESPACE, PULL_PR_BRANCH_BEFORE_CHECKOUT, PullPRBranchVariants } from '../common/settingKeys'; -import { IResolvedPullRequestModel, PullRequestModel } from './pullRequestModel'; const PullRequestRemoteMetadataKey = 'github-pr-remote'; export const PullRequestMetadataKey = 'github-pr-owner-number'; @@ -329,8 +329,9 @@ export class PullRequestGitHelper { ): Promise { try { const configKey = this.getMetadataKeyForBranch(branchName); - const configValue = await repository.getConfig(configKey); - return PullRequestGitHelper.parsePullRequestMetadata(configValue); + const allConfigs = await repository.getConfigs(); + const matchingConfigs = allConfigs.filter(config => config.key === configKey).sort((a, b) => b.value < a.value ? 1 : -1); + return PullRequestGitHelper.parsePullRequestMetadata(matchingConfigs[0].value); } catch (_) { return; } diff --git a/src/github/pullRequestModel.ts b/src/github/pullRequestModel.ts index 35b59951ff..0859707f7f 100644 --- a/src/github/pullRequestModel.ts +++ b/src/github/pullRequestModel.ts @@ -8,24 +8,12 @@ import * as path from 'path'; import equals from 'fast-deep-equal'; import gql from 'graphql-tag'; import * as vscode from 'vscode'; -import { Repository } from '../api/api'; -import { COPILOT_ACCOUNTS, DiffSide, IComment, IReviewThread, SubjectType, ViewedState } from '../common/comment'; -import { getGitChangeType, getModifiedContentFromDiffHunk, parseDiff } from '../common/diffHunk'; -import { commands } from '../common/executeCommands'; -import { GitChangeType, InMemFileChange, SlimFileChange } from '../common/file'; -import { GitHubRef } from '../common/githubRef'; -import Logger from '../common/logger'; -import { Remote } from '../common/remote'; -import { ITelemetry } from '../common/telemetry'; -import { ClosedEvent, EventType, ReviewEvent, TimelineEvent } from '../common/timelineEvent'; -import { resolvePath, Schemes, toGitHubCommitUri, toPRUri, toReviewUri } from '../common/uri'; -import { formatError, isDescendant } from '../common/utils'; -import { InMemFileChangeModel, RemoteFileChangeModel } from '../view/fileChangeModel'; import { OctokitCommon } from './common'; import { ConflictResolutionModel } from './conflictResolutionModel'; import { CredentialStore } from './credentials'; +import { showEmptyCommitWebview } from './emptyCommitWebview'; import { FolderRepositoryManager } from './folderRepositoryManager'; -import { GitHubRepository } from './githubRepository'; +import { GitHubRepository, GraphQLError, GraphQLErrorType } from './githubRepository'; import { AddCommentResponse, AddReactionResponse, @@ -37,8 +25,11 @@ import { EnqueuePullRequestResponse, FileContentResponse, GetReviewRequestsResponse, + MergeMethod as GraphQLMergeMethod, LatestReviewCommitResponse, MarkPullRequestReadyForReviewResponse, + MergePullRequestInput, + MergePullRequestResponse, PendingReviewIdResponse, PullRequestCommentsResponse, PullRequestFilesResponse, @@ -88,6 +79,20 @@ import { RestAccount, restPaginate, } from './utils'; +import { Repository } from '../api/api'; +import { COPILOT_ACCOUNTS, DiffSide, IComment, IReviewThread, SubjectType, ViewedState } from '../common/comment'; +import { getGitChangeType, getModifiedContentFromDiffHunk, parseDiff } from '../common/diffHunk'; +import { commands } from '../common/executeCommands'; +import { GitChangeType, InMemFileChange, SlimFileChange } from '../common/file'; +import { GitHubRef } from '../common/githubRef'; +import Logger from '../common/logger'; +import { Remote } from '../common/remote'; +import { DEFAULT_MERGE_METHOD, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; +import { ITelemetry } from '../common/telemetry'; +import { ClosedEvent, EventType, ReviewEvent, TimelineEvent } from '../common/timelineEvent'; +import { resolvePath, Schemes, toGitHubCommitUri, toPRUri, toReviewUri } from '../common/uri'; +import { formatError, isDescendant } from '../common/utils'; +import { InMemFileChangeModel, RemoteFileChangeModel } from '../view/fileChangeModel'; interface IPullRequestModel { head: GitHubRef | null; @@ -112,6 +117,8 @@ export interface FileViewedStateChangeEvent { export type FileViewedState = { [key: string]: ViewedState }; +type TreeDataMode = '100644' | '100755' | '120000'; + const BATCH_SIZE = 50; export class PullRequestModel extends IssueModel implements IPullRequestModel { @@ -374,6 +381,100 @@ export class PullRequestModel extends IssueModel implements IPullRe return action; } + async merge( + repository: Repository, + title?: string, + description?: string, + method?: 'merge' | 'squash' | 'rebase', + email?: string + ): Promise<{ merged: boolean, message: string, timeline?: TimelineEvent[] }> { + Logger.debug(`Merging PR: ${this.number} method: ${method} for user: "${email}" - enter`, PullRequestModel.ID); + const { mutate, schema } = await this.githubRepository.ensure(); + + const workingDirectorySHA = repository.state.HEAD?.commit; + const mergingPRSHA = this.head?.sha; + const workingDirectoryIsDirty = repository.state.workingTreeChanges.length > 0; + let expectedHeadOid: string | undefined = this.head?.sha; + + if (this.isActive) { + // We're on the branch of the pr being merged. + expectedHeadOid = workingDirectorySHA; + if (workingDirectorySHA !== mergingPRSHA) { + // We are looking at different commit than what will be merged + const { ahead } = repository.state.HEAD!; + const pluralMessage = vscode.l10n.t('You have {0} unpushed commits on this pull request branch.\n\nWould you like to proceed anyway?', ahead ?? 'unknown'); + const singularMessage = vscode.l10n.t('You have 1 unpushed commit on this pull request branch.\n\nWould you like to proceed anyway?'); + if (ahead && + (await vscode.window.showWarningMessage( + ahead > 1 ? pluralMessage : singularMessage, + { modal: true }, + vscode.l10n.t('Yes'), + )) === undefined) { + + return { + merged: false, + message: vscode.l10n.t('unpushed changes'), + }; + } + } + + if (workingDirectoryIsDirty) { + // We have made changes to the PR that are not committed + if ( + (await vscode.window.showWarningMessage( + vscode.l10n.t('You have uncommitted changes on this pull request branch.\n\n Would you like to proceed anyway?'), + { modal: true }, + vscode.l10n.t('Yes'), + )) === undefined + ) { + return { + merged: false, + message: vscode.l10n.t('uncommitted changes'), + }; + } + } + } + const input: MergePullRequestInput = { + pullRequestId: this.graphNodeId, + commitHeadline: title, + commitBody: description, + expectedHeadOid, + authorEmail: email, + mergeMethod: + (method?.toUpperCase() ?? + vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'merge' | 'squash' | 'rebase'>(DEFAULT_MERGE_METHOD, 'merge')?.toUpperCase()) as GraphQLMergeMethod, + }; + + return mutate({ + mutation: schema.MergePullRequest, + variables: { + input + } + }) + .then(async (result) => { + Logger.debug(`Merging PR: ${this.number} - done`, PullRequestModel.ID); + + /* __GDPR__ + "pr.merge.success" : {} + */ + this._telemetry.sendTelemetryEvent('pr.merge.success'); + this._onDidChange.fire({ state: true }); + return { merged: true, message: '', timeline: await parseCombinedTimelineEvents(result.data?.mergePullRequest.pullRequest.timelineItems.nodes ?? [], await this.getCopilotTimelineEvents(this), this.githubRepository) }; + }) + .catch(e => { + /* __GDPR__ + "pr.merge.failure" : {} + */ + this._telemetry.sendTelemetryErrorEvent('pr.merge.failure'); + const graphQLErrors = e.graphQLErrors as GraphQLError[] | undefined; + if (graphQLErrors?.length && graphQLErrors.find(error => error.type === GraphQLErrorType.Unprocessable && error.message?.includes('Head branch was modified'))) { + return { merged: false, message: vscode.l10n.t('Head branch was modified. Pull, review, then try again.') }; + } else { + throw e; + } + }); + } + /** * Close the pull request. */ @@ -428,7 +529,7 @@ export class PullRequestModel extends IssueModel implements IPullRe }); this._onDidChange.fire({ timeline: true }); - return convertRESTReviewEvent(data, this.githubRepository); + return convertRESTReviewEvent(data as OctokitCommon.PullsCreateReviewResponseData, this.githubRepository); } /** @@ -526,6 +627,10 @@ export class PullRequestModel extends IssueModel implements IPullRe */ async deleteReview(): Promise<{ deletedReviewId: number; deletedReviewComments: IComment[] }> { const pendingReviewId = await this.getPendingReviewId(); + if (!pendingReviewId) { + throw new Error(`No pending review found for pull request #${this.number}.`); + } + const { mutate, schema } = await this.githubRepository.ensure(); const { data } = await mutate({ mutation: schema.DeleteReview, @@ -535,15 +640,41 @@ export class PullRequestModel extends IssueModel implements IPullRe }); const { comments, databaseId } = data!.deletePullRequestReview.pullRequestReview; + const deletedReviewComments = comments.nodes.map(comment => parseGraphQLComment(comment, false, this.githubRepository)); + + // Update local state: remove all draft comments (and their threads if emptied) that belonged to the deleted review + const deletedCommentIds = new Set(deletedReviewComments.map(c => c.id)); + const changedThreads: IReviewThread[] = []; + const removedThreads: IReviewThread[] = []; + for (let i = this._reviewThreadsCache.length - 1; i >= 0; i--) { + const thread = this._reviewThreadsCache[i]; + const originalLength = thread.comments.length; + thread.comments = thread.comments.filter(c => !deletedCommentIds.has(c.id)); + if (thread.comments.length === 0 && originalLength > 0) { + // Entire thread was composed only of comments from the deleted review; remove it. + this._reviewThreadsCache.splice(i, 1); + removedThreads.push(thread); + } else if (thread.comments.length !== originalLength) { + changedThreads.push(thread); + } + } + if (changedThreads.length > 0 || removedThreads.length > 0) { + this._onDidChangeReviewThreads.fire({ added: [], changed: changedThreads, removed: removedThreads }); + } + + // Remove from flat comments collection + if (this._comments) { + this.comments = this._comments.filter(c => !deletedCommentIds.has(c.id)); + } this.hasPendingReview = false; await this.updateDraftModeContext(); - this.getReviewThreads(); - this._onDidChange.fire({ timeline: true }); + // Fire change event to update timeline & comment views + this._onDidChange.fire({ timeline: true, comments: true }); return { deletedReviewId: databaseId, - deletedReviewComments: comments.nodes.map(comment => parseGraphQLComment(comment, false, this.githubRepository)), + deletedReviewComments, }; } @@ -747,6 +878,7 @@ export class PullRequestModel extends IssueModel implements IPullRe const [data, latestReviewCommitInfo, currentUser, reviewThreads] = await Promise.all([ getTimelineEvents(), this.getViewerLatestReviewCommit(), + // eslint-disable-next-line @typescript-eslint/await-thenable (await this.githubRepository.getAuthenticatedUser()).login, this.getReviewThreads() ]); @@ -955,11 +1087,11 @@ export class PullRequestModel extends IssueModel implements IPullRe } const baseTreeData = baseTree.data.tree.find(f => f.path === file.filename); - const baseMode: '100644' | '100755' | '120000' = baseTreeData?.mode as any ?? '100644'; + const baseMode: TreeDataMode = (baseTreeData?.mode as TreeDataMode | undefined) ?? '100644'; const headTree = await octokit.call(octokit.api.git.getTree, { owner: model.prHeadOwner, repo: model.repositoryName, tree_sha: headTreeSha, recursive: 'true' }); const headTreeData = headTree.data.tree.find(f => f.path === file.filename); - const headMode: '100644' | '100755' | '120000' = headTreeData?.mode as any ?? '100644'; + const headMode: TreeDataMode = (headTreeData?.mode as TreeDataMode | undefined) ?? '100644'; if (file.status === 'removed') { // The file was removed so we use a null sha to indicate that (per GitHub's API). @@ -1099,16 +1231,20 @@ export class PullRequestModel extends IssueModel implements IPullRe */ async requestReview(reviewers: IAccount[], teamReviewers: ITeam[], union: boolean = false): Promise { const { mutate, schema } = await this.githubRepository.ensure(); + const input: { pullRequestId: string, teamIds: string[], userIds: string[], botIds?: string[], union: boolean } = { + pullRequestId: this.graphNodeId, + teamIds: teamReviewers.map(t => t.id), + userIds: reviewers.filter(r => r.accountType !== AccountType.Bot).map(r => r.id), + union + }; + if (!this.githubRepository.areQueriesLimited) { + input.botIds = reviewers.filter(r => r.accountType === AccountType.Bot).map(r => r.id); + } + const { data } = await mutate({ mutation: schema.AddReviewers, variables: { - input: { - pullRequestId: this.graphNodeId, - teamIds: teamReviewers.map(t => t.id), - userIds: reviewers.filter(r => r.accountType !== AccountType.Bot).map(r => r.id), - botIds: reviewers.filter(r => r.accountType === AccountType.Bot).map(r => r.id), - union - }, + input }, }); @@ -1129,6 +1265,9 @@ export class PullRequestModel extends IssueModel implements IPullRe * @param reviewer A GitHub Login */ async deleteReviewRequest(reviewers: IAccount[], teamReviewers: ITeam[]): Promise { + if (reviewers.length === 0 && teamReviewers.length === 0) { + return; + } const { octokit, remote } = await this.githubRepository.ensure(); await octokit.call(octokit.api.pulls.removeRequestedReviewers, { owner: remote.owner, @@ -1327,7 +1466,7 @@ export class PullRequestModel extends IssueModel implements IPullRe return vscode.commands.executeCommand('vscode.changes', vscode.l10n.t('Changes in Pull Request #{0}', pullRequestModel.number), args); } - static async openCommitChanges(githubRepository: GitHubRepository, commitSha: string) { + static async openCommitChanges(extensionUri: vscode.Uri, githubRepository: GitHubRepository, commitSha: string) { try { const parentCommit = await githubRepository.getCommitParent(commitSha); if (!parentCommit) { @@ -1337,7 +1476,8 @@ export class PullRequestModel extends IssueModel implements IPullRe const changes = await githubRepository.compareCommits(parentCommit, commitSha); if (!changes?.files || changes.files.length === 0) { - vscode.window.showInformationMessage(vscode.l10n.t('No changes found in commit {0}', commitSha.substring(0, 7))); + // Show a webview with the empty commit message instead of a notification + showEmptyCommitWebview(extensionUri, commitSha); return; } diff --git a/src/github/pullRequestOverview.ts b/src/github/pullRequestOverview.ts index 491405347a..137551f5dd 100644 --- a/src/github/pullRequestOverview.ts +++ b/src/github/pullRequestOverview.ts @@ -7,16 +7,6 @@ import * as vscode from 'vscode'; import { OpenCommitChangesArgs } from '../../common/views'; import { openPullRequestOnGitHub } from '../commands'; -import { IComment } from '../common/comment'; -import { COPILOT_SWE_AGENT, copilotEventToStatus, CopilotPRStatus, mostRecentCopilotEvent } from '../common/copilot'; -import { commands, contexts } from '../common/executeCommands'; -import { disposeAll } from '../common/lifecycle'; -import Logger from '../common/logger'; -import { DEFAULT_MERGE_METHOD, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; -import { ITelemetry } from '../common/telemetry'; -import { EventType, ReviewEvent, SessionLinkInfo, TimelineEvent } from '../common/timelineEvent'; -import { asPromise, formatError } from '../common/utils'; -import { IRequestMessage, PULL_REQUEST_OVERVIEW_VIEW_TYPE } from '../common/webview'; import { getCopilotApi } from './copilotApi'; import { SessionIdForPr } from './copilotRemoteAgent'; import { FolderRepositoryManager } from './folderRepositoryManager'; @@ -37,7 +27,17 @@ import { isCopilotOnMyBehalf, PullRequestModel } from './pullRequestModel'; import { PullRequestView } from './pullRequestOverviewCommon'; import { pickEmail, reviewersQuickPick } from './quickPicks'; import { parseReviewers } from './utils'; -import { CancelCodingAgentReply, MergeArguments, MergeResult, PullRequest, ReviewType, SubmitReviewReply } from './views'; +import { CancelCodingAgentReply, DeleteReviewResult, MergeArguments, MergeResult, PullRequest, ReviewType, SubmitReviewReply } from './views'; +import { IComment } from '../common/comment'; +import { COPILOT_SWE_AGENT, copilotEventToStatus, CopilotPRStatus, mostRecentCopilotEvent } from '../common/copilot'; +import { commands, contexts } from '../common/executeCommands'; +import { disposeAll } from '../common/lifecycle'; +import Logger from '../common/logger'; +import { DEFAULT_MERGE_METHOD, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; +import { ITelemetry } from '../common/telemetry'; +import { EventType, ReviewEvent, SessionLinkInfo, TimelineEvent } from '../common/timelineEvent'; +import { asPromise, formatError } from '../common/utils'; +import { IRequestMessage, PULL_REQUEST_OVERVIEW_VIEW_TYPE } from '../common/webview'; export class PullRequestOverviewPanel extends IssueOverviewPanel { public static override ID: string = 'PullRequestOverviewPanel'; @@ -134,28 +134,22 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { - this._postMessage({ - command: 'update-state', - state: GithubItemStateEnum.Merged, - }); - })); this._register(vscode.commands.registerCommand('review.approveDescription', (e) => this.approvePullRequestCommand(e))); this._register(vscode.commands.registerCommand('review.commentDescription', (e) => this.submitReviewCommand(e))); this._register(vscode.commands.registerCommand('review.requestChangesDescription', (e) => this.requestChangesCommand(e))); this._register(vscode.commands.registerCommand('review.approveOnDotComDescription', () => { - return openPullRequestOnGitHub(this._item, (this._item as any)._telemetry); + return openPullRequestOnGitHub(this._item, this._telemetry); })); this._register(vscode.commands.registerCommand('review.requestChangesOnDotComDescription', () => { - return openPullRequestOnGitHub(this._item, (this._item as any)._telemetry); + return openPullRequestOnGitHub(this._item, this._telemetry); })); } @@ -173,7 +167,7 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { - if (e.comments && !this._isUpdating) { + if ((e.state || e.comments) && !this._isUpdating) { this.refreshPanel(); } })); @@ -223,6 +217,10 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { + if (this._isUpdating) { + throw new Error('Already updating pull request webview'); + } + this._isUpdating = true; try { const [ pullRequest, @@ -329,6 +327,8 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel): Promise { try { - return vscode.window.showChatSession(COPILOT_SWE_AGENT, SessionIdForPr.getId(this._item.number, message.args.link.sessionIndex), {}); + const resource = SessionIdForPr.getResource(this._item.number, message.args.link.sessionIndex); + return vscode.commands.executeCommand('vscode.open', resource); } catch (e) { Logger.error(`Open session log view failed: ${formatError(e)}`, PullRequestOverviewPanel.ID); } @@ -551,7 +556,7 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel): Promise { try { const { commitSha } = message.args; - await PullRequestModel.openCommitChanges(this._item.githubRepository, commitSha); + await PullRequestModel.openCommitChanges(this._extensionUri, this._item.githubRepository, commitSha); this._replyMessage(message, {}); } catch (error) { Logger.error(`Failed to open commit changes: ${formatError(error)}`, PullRequestOverviewPanel.ID); @@ -593,30 +598,27 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel, - ): void { + ): Promise { const { title, description, method, email } = message.args; - this._folderRepositoryManager - .mergePullRequest(this._item, title, description, method, email) - .then(result => { - vscode.commands.executeCommand('pr.refreshList'); + try { + const result = await this._item.merge(this._folderRepositoryManager.repository, title, description, method, email); - if (!result.merged) { - vscode.window.showErrorMessage(`Merging pull request failed: ${result.message}`); - } + if (!result.merged) { + vscode.window.showErrorMessage(`Merging pull request failed: ${result.message}`); + } - const mergeResult: MergeResult = { - state: result.merged ? GithubItemStateEnum.Merged : GithubItemStateEnum.Open, - revertable: result.merged, - events: result.timeline - }; - this._replyMessage(message, mergeResult); - }) - .catch(e => { - vscode.window.showErrorMessage(`Unable to merge pull request. ${formatError(e)}`); - this._throwError(message, ''); - }); + const mergeResult: MergeResult = { + state: result.merged ? GithubItemStateEnum.Merged : GithubItemStateEnum.Open, + revertable: result.merged, + events: result.timeline + }; + this._replyMessage(message, mergeResult); + } catch (e) { + vscode.window.showErrorMessage(`Unable to merge pull request. ${formatError(e)}`); + this._throwError(message, ''); + } } private async changeEmail(message: IRequestMessage): Promise { @@ -649,10 +651,50 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel): Promise { + try { + const readyResult = await this._item.setReadyForReview(); + + try { + await this._item.approve(this._folderRepositoryManager.repository, ''); + } catch (e) { + vscode.window.showErrorMessage(`Pull request marked as ready for review, but failed to approve. ${formatError(e)}`); + this._replyMessage(message, readyResult); + return; + } + + try { + await this._item.enableAutoMerge(message.args.mergeMethod); + } catch (e) { + vscode.window.showErrorMessage(`Pull request marked as ready and approved, but failed to enable auto-merge. ${formatError(e)}`); + this._replyMessage(message, readyResult); + return; + } + + this._replyMessage(message, readyResult); + } catch (e) { + vscode.window.showErrorMessage(`Unable to mark pull request as ready for review. ${formatError(e)}`); + this._throwError(message, ''); + } + } + private async checkoutDefaultBranch(message: IRequestMessage): Promise { try { const prBranch = this._folderRepositoryManager.repository.state.HEAD?.name; await this._folderRepositoryManager.checkoutDefaultBranch(message.args); + // Check if we should pop the stash after successful checkout + const shouldPopStash = this._folderRepositoryManager.stashedOnCheckout; + if (shouldPopStash) { + try { + Logger.appendLine('Popping stash after returning to default branch', PullRequestOverviewPanel.ID); + await vscode.commands.executeCommand('git.stashPop', this._folderRepositoryManager.repository); + this._folderRepositoryManager.stashedOnCheckout = false; + Logger.appendLine('Stash popped successfully', PullRequestOverviewPanel.ID); + } catch (popError) { + Logger.error(`Failed to pop stash: ${formatError(popError)}`, PullRequestOverviewPanel.ID); + vscode.window.showWarningMessage(vscode.l10n.t('Failed to restore stashed changes: {0}', formatError(popError))); + } + } if (prBranch) { await this._folderRepositoryManager.cleanupAfterPullRequest(prBranch, this._item); } @@ -862,6 +904,17 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel) { + try { + const result: DeleteReviewResult = await this._item.deleteReview(); + await this._replyMessage(message, result); + } catch (e) { + Logger.error(formatError(e), PullRequestOverviewPanel.ID); + vscode.window.showErrorMessage(vscode.l10n.t('Deleting review failed. {0}', formatError(e))); + this._throwError(message, `${formatError(e)}`); + } + } + override dispose() { super.dispose(); disposeAll(this._prListeners); diff --git a/src/github/pullRequestOverviewCommon.ts b/src/github/pullRequestOverviewCommon.ts index 612fe17d4c..bd069dc329 100644 --- a/src/github/pullRequestOverviewCommon.ts +++ b/src/github/pullRequestOverviewCommon.ts @@ -5,6 +5,8 @@ 'use strict'; import * as vscode from 'vscode'; +import { FolderRepositoryManager } from './folderRepositoryManager'; +import { PullRequestModel } from './pullRequestModel'; import { DEFAULT_DELETION_METHOD, PR_SETTINGS_NAMESPACE, @@ -12,8 +14,6 @@ import { SELECT_REMOTE, } from '../common/settingKeys'; import { Schemes } from '../common/uri'; -import { FolderRepositoryManager } from './folderRepositoryManager'; -import { PullRequestModel } from './pullRequestModel'; export namespace PullRequestView { export async function deleteBranch(folderRepositoryManager: FolderRepositoryManager, item: PullRequestModel): Promise<{ isReply: boolean, message: any }> { @@ -114,7 +114,7 @@ export namespace PullRequestView { return; } } - await folderRepositoryManager.repository.checkout(defaultBranch); + await folderRepositoryManager.checkoutDefaultBranch(defaultBranch); } await folderRepositoryManager.repository.deleteBranch(branchInfo!.branch, true); return deletedBranchTypes.push(action.type); @@ -129,8 +129,6 @@ export namespace PullRequestView { await Promise.all(promises); - vscode.commands.executeCommand('pr.refreshList'); - return { isReply: false, message: { diff --git a/src/github/queries.gql b/src/github/queries.gql index 9412fd955a..524ed6f65c 100644 --- a/src/github/queries.gql +++ b/src/github/queries.gql @@ -341,6 +341,359 @@ query PullRequestMergeabilityMergeRequirements($owner: String!, $name: String!, } } +query LatestUpdates($owner: String!, $name: String!, $number: Int!, $since: DateTime!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reactions(orderBy:{direction:DESC, field: CREATED_AT}, first: 1) { + nodes { + createdAt + } + } + updatedAt + comments(orderBy: {direction:DESC, field: UPDATED_AT}, first: 1) { + nodes { + updatedAt + reactions(orderBy:{direction:DESC, field: CREATED_AT}, first: 1) { + nodes { + createdAt + } + } + } + } + timelineItems(since: $since, first: 1) { + nodes { + ... on AddedToMergeQueueEvent { + createdAt + } + ... on AddedToProjectEvent { + createdAt + } + ... on AssignedEvent { + createdAt + } + ... on AutoMergeDisabledEvent { + createdAt + } + ... on AutoMergeEnabledEvent { + createdAt + } + ... on AutoRebaseEnabledEvent { + createdAt + } + ... on AutoSquashEnabledEvent { + createdAt + } + ... on AutomaticBaseChangeFailedEvent { + createdAt + } + ... on AutomaticBaseChangeSucceededEvent { + createdAt + } + ... on BaseRefChangedEvent { + createdAt + } + ... on BaseRefDeletedEvent { + createdAt + } + ... on BaseRefForcePushedEvent { + createdAt + } + ... on ClosedEvent { + createdAt + } + ... on CommentDeletedEvent { + createdAt + } + ... on ConnectedEvent { + createdAt + } + ... on ConvertToDraftEvent { + createdAt + } + ... on ConvertedNoteToIssueEvent { + createdAt + } + ... on ConvertedToDiscussionEvent { + createdAt + } + ... on CrossReferencedEvent { + createdAt + } + ... on DemilestonedEvent { + createdAt + } + ... on DeployedEvent { + createdAt + } + ... on DeploymentEnvironmentChangedEvent { + createdAt + } + ... on DisconnectedEvent { + createdAt + } + ... on HeadRefDeletedEvent { + createdAt + } + ... on HeadRefForcePushedEvent { + createdAt + } + ... on HeadRefRestoredEvent { + createdAt + } + ... on IssueComment { + createdAt + } + ... on IssueTypeAddedEvent { + createdAt + } + ... on LabeledEvent { + createdAt + } + ... on LockedEvent { + createdAt + } + ... on MarkedAsDuplicateEvent { + createdAt + } + ... on MentionedEvent { + createdAt + } + ... on MergedEvent { + createdAt + } + ... on MilestonedEvent { + createdAt + } + ... on MovedColumnsInProjectEvent { + createdAt + } + ... on PinnedEvent { + createdAt + } + ... on PullRequestCommit { + commit { + committedDate + } + } + ... on PullRequestReview { + createdAt + } + ... on PullRequestReviewThread { + comments(last: 1) { + nodes { + createdAt + } + } + } + ... on PullRequestRevisionMarker { + createdAt + } + ... on ReadyForReviewEvent { + createdAt + } + ... on ReferencedEvent { + createdAt + } + ... on RemovedFromMergeQueueEvent { + createdAt + } + ... on RemovedFromProjectEvent { + createdAt + } + ... on RenamedTitleEvent { + createdAt + } + ... on ReopenedEvent { + createdAt + } + ... on ReviewDismissedEvent { + createdAt + } + ... on ReviewRequestRemovedEvent { + createdAt + } + ... on ReviewRequestedEvent { + createdAt + } + ... on SubscribedEvent { + createdAt + } + ... on TransferredEvent { + createdAt + } + ... on UnassignedEvent { + createdAt + } + ... on UnlabeledEvent { + createdAt + } + ... on UnlockedEvent { + createdAt + } + ... on UnmarkedAsDuplicateEvent { + createdAt + } + ... on UnpinnedEvent { + createdAt + } + ... on UnsubscribedEvent { + createdAt + } + ... on UserBlockedEvent { + createdAt + } + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query LatestIssueUpdates($owner: String!, $name: String!, $number: Int!, $since: DateTime!) { + repository(owner: $owner, name: $name) { + pullRequest: issue(number: $number) { + reactions(orderBy:{direction:DESC, field: CREATED_AT}, first: 1) { + nodes { + createdAt + } + } + updatedAt + comments(orderBy: {direction:DESC, field: UPDATED_AT}, first: 1) { + nodes { + updatedAt + reactions(orderBy:{direction:DESC, field: CREATED_AT}, first: 1) { + nodes { + createdAt + } + } + } + } + timelineItems(since: $since, first: 1) { + nodes { + ... on AddedToProjectEvent { + createdAt + } + ... on AssignedEvent { + createdAt + } + ... on ClosedEvent { + createdAt + } + ... on CommentDeletedEvent { + createdAt + } + ... on ConnectedEvent { + createdAt + } + ... on ConvertedNoteToIssueEvent { + createdAt + } + ... on ConvertedToDiscussionEvent { + createdAt + } + ... on CrossReferencedEvent { + createdAt + } + ... on DemilestonedEvent { + createdAt + } + ... on DisconnectedEvent { + createdAt + } + ... on IssueComment { + createdAt + } + ... on IssueTypeAddedEvent { + createdAt + } + ... on LabeledEvent { + createdAt + } + ... on LockedEvent { + createdAt + } + ... on MarkedAsDuplicateEvent { + createdAt + } + ... on MentionedEvent { + createdAt + } + ... on MilestonedEvent { + createdAt + } + ... on MovedColumnsInProjectEvent { + createdAt + } + ... on PinnedEvent { + createdAt + } + ... on ReferencedEvent { + createdAt + } + ... on RemovedFromProjectEvent { + createdAt + } + ... on RenamedTitleEvent { + createdAt + } + ... on ReopenedEvent { + createdAt + } + ... on SubscribedEvent { + createdAt + } + ... on TransferredEvent { + createdAt + } + ... on UnassignedEvent { + createdAt + } + ... on UnlabeledEvent { + createdAt + } + ... on UnlockedEvent { + createdAt + } + ... on UnmarkedAsDuplicateEvent { + createdAt + } + ... on UnpinnedEvent { + createdAt + } + ... on UnsubscribedEvent { + createdAt + } + ... on UserBlockedEvent { + createdAt + } + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetAssignableUsers($owner: String!, $name: String!, $first: Int!, $after: String) { + repository(owner: $owner, name: $name) { + assignableUsers(first: $first, after: $after) { + nodes { + ...User + } + pageInfo { + hasNextPage + endCursor + } + } + } + rateLimit { + ...RateLimit + } +} + mutation CreatePullRequest($input: CreatePullRequestInput!) { createPullRequest(input: $input) { pullRequest { diff --git a/src/github/queriesExtra.gql b/src/github/queriesExtra.gql index 41f74c639f..45967950d4 100644 --- a/src/github/queriesExtra.gql +++ b/src/github/queriesExtra.gql @@ -371,6 +371,359 @@ query GetSuggestedActors($owner: String!, $name: String!, $capabilities: [Reposi } } +query LatestUpdates($owner: String!, $name: String!, $number: Int!, $since: DateTime!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reactions(orderBy:{direction:DESC, field: CREATED_AT}, first: 1) { + nodes { + createdAt + } + } + updatedAt + comments(orderBy: {direction:DESC, field: UPDATED_AT}, first: 1) { + nodes { + updatedAt + reactions(orderBy:{direction:DESC, field: CREATED_AT}, first: 1) { + nodes { + createdAt + } + } + } + } + timelineItems(since: $since, first: 1) { + nodes { + ... on AddedToMergeQueueEvent { + createdAt + } + ... on AddedToProjectEvent { + createdAt + } + ... on AssignedEvent { + createdAt + } + ... on AutoMergeDisabledEvent { + createdAt + } + ... on AutoMergeEnabledEvent { + createdAt + } + ... on AutoRebaseEnabledEvent { + createdAt + } + ... on AutoSquashEnabledEvent { + createdAt + } + ... on AutomaticBaseChangeFailedEvent { + createdAt + } + ... on AutomaticBaseChangeSucceededEvent { + createdAt + } + ... on BaseRefChangedEvent { + createdAt + } + ... on BaseRefDeletedEvent { + createdAt + } + ... on BaseRefForcePushedEvent { + createdAt + } + ... on ClosedEvent { + createdAt + } + ... on CommentDeletedEvent { + createdAt + } + ... on ConnectedEvent { + createdAt + } + ... on ConvertToDraftEvent { + createdAt + } + ... on ConvertedNoteToIssueEvent { + createdAt + } + ... on ConvertedToDiscussionEvent { + createdAt + } + ... on CrossReferencedEvent { + createdAt + } + ... on DemilestonedEvent { + createdAt + } + ... on DeployedEvent { + createdAt + } + ... on DeploymentEnvironmentChangedEvent { + createdAt + } + ... on DisconnectedEvent { + createdAt + } + ... on HeadRefDeletedEvent { + createdAt + } + ... on HeadRefForcePushedEvent { + createdAt + } + ... on HeadRefRestoredEvent { + createdAt + } + ... on IssueComment { + createdAt + } + ... on IssueTypeAddedEvent { + createdAt + } + ... on LabeledEvent { + createdAt + } + ... on LockedEvent { + createdAt + } + ... on MarkedAsDuplicateEvent { + createdAt + } + ... on MentionedEvent { + createdAt + } + ... on MergedEvent { + createdAt + } + ... on MilestonedEvent { + createdAt + } + ... on MovedColumnsInProjectEvent { + createdAt + } + ... on PinnedEvent { + createdAt + } + ... on PullRequestCommit { + commit { + committedDate + } + } + ... on PullRequestReview { + createdAt + } + ... on PullRequestReviewThread { + comments(last: 1) { + nodes { + createdAt + } + } + } + ... on PullRequestRevisionMarker { + createdAt + } + ... on ReadyForReviewEvent { + createdAt + } + ... on ReferencedEvent { + createdAt + } + ... on RemovedFromMergeQueueEvent { + createdAt + } + ... on RemovedFromProjectEvent { + createdAt + } + ... on RenamedTitleEvent { + createdAt + } + ... on ReopenedEvent { + createdAt + } + ... on ReviewDismissedEvent { + createdAt + } + ... on ReviewRequestRemovedEvent { + createdAt + } + ... on ReviewRequestedEvent { + createdAt + } + ... on SubscribedEvent { + createdAt + } + ... on TransferredEvent { + createdAt + } + ... on UnassignedEvent { + createdAt + } + ... on UnlabeledEvent { + createdAt + } + ... on UnlockedEvent { + createdAt + } + ... on UnmarkedAsDuplicateEvent { + createdAt + } + ... on UnpinnedEvent { + createdAt + } + ... on UnsubscribedEvent { + createdAt + } + ... on UserBlockedEvent { + createdAt + } + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query LatestIssueUpdates($owner: String!, $name: String!, $number: Int!, $since: DateTime!) { + repository(owner: $owner, name: $name) { + pullRequest: issue(number: $number) { + reactions(orderBy:{direction:DESC, field: CREATED_AT}, first: 1) { + nodes { + createdAt + } + } + updatedAt + comments(orderBy: {direction:DESC, field: UPDATED_AT}, first: 1) { + nodes { + updatedAt + reactions(orderBy:{direction:DESC, field: CREATED_AT}, first: 1) { + nodes { + createdAt + } + } + } + } + timelineItems(since: $since, first: 1) { + nodes { + ... on AddedToProjectEvent { + createdAt + } + ... on AssignedEvent { + createdAt + } + ... on ClosedEvent { + createdAt + } + ... on CommentDeletedEvent { + createdAt + } + ... on ConnectedEvent { + createdAt + } + ... on ConvertedNoteToIssueEvent { + createdAt + } + ... on ConvertedToDiscussionEvent { + createdAt + } + ... on CrossReferencedEvent { + createdAt + } + ... on DemilestonedEvent { + createdAt + } + ... on DisconnectedEvent { + createdAt + } + ... on IssueComment { + createdAt + } + ... on IssueTypeAddedEvent { + createdAt + } + ... on LabeledEvent { + createdAt + } + ... on LockedEvent { + createdAt + } + ... on MarkedAsDuplicateEvent { + createdAt + } + ... on MentionedEvent { + createdAt + } + ... on MilestonedEvent { + createdAt + } + ... on MovedColumnsInProjectEvent { + createdAt + } + ... on PinnedEvent { + createdAt + } + ... on ReferencedEvent { + createdAt + } + ... on RemovedFromProjectEvent { + createdAt + } + ... on RenamedTitleEvent { + createdAt + } + ... on ReopenedEvent { + createdAt + } + ... on SubscribedEvent { + createdAt + } + ... on TransferredEvent { + createdAt + } + ... on UnassignedEvent { + createdAt + } + ... on UnlabeledEvent { + createdAt + } + ... on UnlockedEvent { + createdAt + } + ... on UnmarkedAsDuplicateEvent { + createdAt + } + ... on UnpinnedEvent { + createdAt + } + ... on UnsubscribedEvent { + createdAt + } + ... on UserBlockedEvent { + createdAt + } + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetAssignableUsers($owner: String!, $name: String!, $first: Int!, $after: String) { + repository(owner: $owner, name: $name) { + assignableUsers(first: $first, after: $after) { + nodes { + ...User + } + pageInfo { + hasNextPage + endCursor + } + } + } + rateLimit { + ...RateLimit + } +} + mutation CreatePullRequest($input: CreatePullRequestInput!) { createPullRequest(input: $input) { pullRequest { diff --git a/src/github/queriesLimited.gql b/src/github/queriesLimited.gql index 94150f9774..9ce0dffa9d 100644 --- a/src/github/queriesLimited.gql +++ b/src/github/queriesLimited.gql @@ -311,6 +311,336 @@ query GetAssignableUsers($owner: String!, $name: String!, $first: Int!, $after: } } +query LatestUpdates($owner: String!, $name: String!, $number: Int!, $since: DateTime!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reactions(orderBy:{direction:DESC, field: CREATED_AT}, first: 1) { + nodes { + createdAt + } + } + updatedAt + comments(orderBy: {direction:DESC, field: UPDATED_AT}, first: 1) { + nodes { + updatedAt + reactions(orderBy:{direction:DESC, field: CREATED_AT}, first: 1) { + nodes { + createdAt + } + } + } + } + timelineItems(since: $since, first: 1) { + nodes { + ... on AddedToMergeQueueEvent { + createdAt + } + ... on AddedToProjectEvent { + createdAt + } + ... on AssignedEvent { + createdAt + } + ... on AutoMergeDisabledEvent { + createdAt + } + ... on AutoMergeEnabledEvent { + createdAt + } + ... on AutoRebaseEnabledEvent { + createdAt + } + ... on AutoSquashEnabledEvent { + createdAt + } + ... on AutomaticBaseChangeFailedEvent { + createdAt + } + ... on AutomaticBaseChangeSucceededEvent { + createdAt + } + ... on BaseRefChangedEvent { + createdAt + } + ... on BaseRefDeletedEvent { + createdAt + } + ... on BaseRefForcePushedEvent { + createdAt + } + ... on ClosedEvent { + createdAt + } + ... on CommentDeletedEvent { + createdAt + } + ... on ConnectedEvent { + createdAt + } + ... on ConvertToDraftEvent { + createdAt + } + ... on ConvertedNoteToIssueEvent { + createdAt + } + ... on ConvertedToDiscussionEvent { + createdAt + } + ... on CrossReferencedEvent { + createdAt + } + ... on DemilestonedEvent { + createdAt + } + ... on DeployedEvent { + createdAt + } + ... on DeploymentEnvironmentChangedEvent { + createdAt + } + ... on DisconnectedEvent { + createdAt + } + ... on HeadRefDeletedEvent { + createdAt + } + ... on HeadRefForcePushedEvent { + createdAt + } + ... on HeadRefRestoredEvent { + createdAt + } + ... on IssueComment { + createdAt + } + ... on LabeledEvent { + createdAt + } + ... on LockedEvent { + createdAt + } + ... on MarkedAsDuplicateEvent { + createdAt + } + ... on MentionedEvent { + createdAt + } + ... on MergedEvent { + createdAt + } + ... on MilestonedEvent { + createdAt + } + ... on MovedColumnsInProjectEvent { + createdAt + } + ... on PinnedEvent { + createdAt + } + ... on PullRequestCommit { + commit { + committedDate + } + } + ... on PullRequestReview { + createdAt + } + ... on PullRequestReviewThread { + comments(last: 1) { + nodes { + createdAt + } + } + } + ... on PullRequestRevisionMarker { + createdAt + } + ... on ReadyForReviewEvent { + createdAt + } + ... on ReferencedEvent { + createdAt + } + ... on RemovedFromMergeQueueEvent { + createdAt + } + ... on RemovedFromProjectEvent { + createdAt + } + ... on RenamedTitleEvent { + createdAt + } + ... on ReopenedEvent { + createdAt + } + ... on ReviewDismissedEvent { + createdAt + } + ... on ReviewRequestRemovedEvent { + createdAt + } + ... on ReviewRequestedEvent { + createdAt + } + ... on SubscribedEvent { + createdAt + } + ... on TransferredEvent { + createdAt + } + ... on UnassignedEvent { + createdAt + } + ... on UnlabeledEvent { + createdAt + } + ... on UnlockedEvent { + createdAt + } + ... on UnmarkedAsDuplicateEvent { + createdAt + } + ... on UnpinnedEvent { + createdAt + } + ... on UnsubscribedEvent { + createdAt + } + ... on UserBlockedEvent { + createdAt + } + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query LatestIssueUpdates($owner: String!, $name: String!, $number: Int!, $since: DateTime!) { + repository(owner: $owner, name: $name) { + pullRequest: issue(number: $number) { + reactions(orderBy:{direction:DESC, field: CREATED_AT}, first: 1) { + nodes { + createdAt + } + } + updatedAt + comments(orderBy: {direction:DESC, field: UPDATED_AT}, first: 1) { + nodes { + updatedAt + reactions(orderBy:{direction:DESC, field: CREATED_AT}, first: 1) { + nodes { + createdAt + } + } + } + } + timelineItems(since: $since, first: 1) { + nodes { + ... on AddedToProjectEvent { + createdAt + } + ... on AssignedEvent { + createdAt + } + ... on ClosedEvent { + createdAt + } + ... on CommentDeletedEvent { + createdAt + } + ... on ConnectedEvent { + createdAt + } + ... on ConvertedNoteToIssueEvent { + createdAt + } + ... on ConvertedToDiscussionEvent { + createdAt + } + ... on CrossReferencedEvent { + createdAt + } + ... on DemilestonedEvent { + createdAt + } + ... on DisconnectedEvent { + createdAt + } + ... on IssueComment { + createdAt + } + ... on LabeledEvent { + createdAt + } + ... on LockedEvent { + createdAt + } + ... on MarkedAsDuplicateEvent { + createdAt + } + ... on MentionedEvent { + createdAt + } + ... on MilestonedEvent { + createdAt + } + ... on MovedColumnsInProjectEvent { + createdAt + } + ... on PinnedEvent { + createdAt + } + ... on ReferencedEvent { + createdAt + } + ... on RemovedFromProjectEvent { + createdAt + } + ... on RenamedTitleEvent { + createdAt + } + ... on ReopenedEvent { + createdAt + } + ... on SubscribedEvent { + createdAt + } + ... on TransferredEvent { + createdAt + } + ... on UnassignedEvent { + createdAt + } + ... on UnlabeledEvent { + createdAt + } + ... on UnlockedEvent { + createdAt + } + ... on UnmarkedAsDuplicateEvent { + createdAt + } + ... on UnpinnedEvent { + createdAt + } + ... on UnsubscribedEvent { + createdAt + } + ... on UserBlockedEvent { + createdAt + } + } + } + } + } + rateLimit { + ...RateLimit + } +} + mutation CreatePullRequest($input: CreatePullRequestInput!) { createPullRequest(input: $input) { pullRequest { diff --git a/src/github/queriesShared.gql b/src/github/queriesShared.gql index d6a0d7ed8c..7716dd9a4f 100644 --- a/src/github/queriesShared.gql +++ b/src/github/queriesShared.gql @@ -339,342 +339,6 @@ query IssueTimelineEvents($owner: String!, $name: String!, $number: Int!, $last: } } -query LatestUpdates($owner: String!, $name: String!, $number: Int!, $since: DateTime!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - reactions(orderBy:{direction:DESC, field: CREATED_AT}, first: 1) { - nodes { - createdAt - } - } - updatedAt - comments(orderBy: {direction:DESC, field: UPDATED_AT}, first: 1) { - nodes { - updatedAt - reactions(orderBy:{direction:DESC, field: CREATED_AT}, first: 1) { - nodes { - createdAt - } - } - } - } - timelineItems(since: $since, first: 1) { - nodes { - ... on AddedToMergeQueueEvent { - createdAt - } - ... on AddedToProjectEvent { - createdAt - } - ... on AssignedEvent { - createdAt - } - ... on AutoMergeDisabledEvent { - createdAt - } - ... on AutoMergeEnabledEvent { - createdAt - } - ... on AutoRebaseEnabledEvent { - createdAt - } - ... on AutoSquashEnabledEvent { - createdAt - } - ... on AutomaticBaseChangeFailedEvent { - createdAt - } - ... on AutomaticBaseChangeSucceededEvent { - createdAt - } - ... on BaseRefChangedEvent { - createdAt - } - ... on BaseRefDeletedEvent { - createdAt - } - ... on BaseRefForcePushedEvent { - createdAt - } - ... on ClosedEvent { - createdAt - } - ... on CommentDeletedEvent { - createdAt - } - ... on ConnectedEvent { - createdAt - } - ... on ConvertToDraftEvent { - createdAt - } - ... on ConvertedNoteToIssueEvent { - createdAt - } - ... on ConvertedToDiscussionEvent { - createdAt - } - ... on CrossReferencedEvent { - createdAt - } - ... on DemilestonedEvent { - createdAt - } - ... on DeployedEvent { - createdAt - } - ... on DeploymentEnvironmentChangedEvent { - createdAt - } - ... on DisconnectedEvent { - createdAt - } - ... on HeadRefDeletedEvent { - createdAt - } - ... on HeadRefForcePushedEvent { - createdAt - } - ... on HeadRefRestoredEvent { - createdAt - } - ... on IssueComment { - createdAt - } - ... on IssueTypeAddedEvent { - createdAt - } - ... on LabeledEvent { - createdAt - } - ... on LockedEvent { - createdAt - } - ... on MarkedAsDuplicateEvent { - createdAt - } - ... on MentionedEvent { - createdAt - } - ... on MergedEvent { - createdAt - } - ... on MilestonedEvent { - createdAt - } - ... on MovedColumnsInProjectEvent { - createdAt - } - ... on PinnedEvent { - createdAt - } - ... on PullRequestCommit { - commit { - committedDate - } - } - ... on PullRequestReview { - createdAt - } - ... on PullRequestReviewThread { - comments(last: 1) { - nodes { - createdAt - } - } - } - ... on PullRequestRevisionMarker { - createdAt - } - ... on ReadyForReviewEvent { - createdAt - } - ... on ReferencedEvent { - createdAt - } - ... on RemovedFromMergeQueueEvent { - createdAt - } - ... on RemovedFromProjectEvent { - createdAt - } - ... on RenamedTitleEvent { - createdAt - } - ... on ReopenedEvent { - createdAt - } - ... on ReviewDismissedEvent { - createdAt - } - ... on ReviewRequestRemovedEvent { - createdAt - } - ... on ReviewRequestedEvent { - createdAt - } - ... on SubscribedEvent { - createdAt - } - ... on TransferredEvent { - createdAt - } - ... on UnassignedEvent { - createdAt - } - ... on UnlabeledEvent { - createdAt - } - ... on UnlockedEvent { - createdAt - } - ... on UnmarkedAsDuplicateEvent { - createdAt - } - ... on UnpinnedEvent { - createdAt - } - ... on UnsubscribedEvent { - createdAt - } - ... on UserBlockedEvent { - createdAt - } - } - } - } - } - rateLimit { - ...RateLimit - } -} - -query LatestIssueUpdates($owner: String!, $name: String!, $number: Int!, $since: DateTime!) { - repository(owner: $owner, name: $name) { - pullRequest: issue(number: $number) { - reactions(orderBy:{direction:DESC, field: CREATED_AT}, first: 1) { - nodes { - createdAt - } - } - updatedAt - comments(orderBy: {direction:DESC, field: UPDATED_AT}, first: 1) { - nodes { - updatedAt - reactions(orderBy:{direction:DESC, field: CREATED_AT}, first: 1) { - nodes { - createdAt - } - } - } - } - timelineItems(since: $since, first: 1) { - nodes { - ... on AddedToProjectEvent { - createdAt - } - ... on AssignedEvent { - createdAt - } - ... on ClosedEvent { - createdAt - } - ... on CommentDeletedEvent { - createdAt - } - ... on ConnectedEvent { - createdAt - } - ... on ConvertedNoteToIssueEvent { - createdAt - } - ... on ConvertedToDiscussionEvent { - createdAt - } - ... on CrossReferencedEvent { - createdAt - } - ... on DemilestonedEvent { - createdAt - } - ... on DisconnectedEvent { - createdAt - } - ... on IssueComment { - createdAt - } - ... on IssueTypeAddedEvent { - createdAt - } - ... on LabeledEvent { - createdAt - } - ... on LockedEvent { - createdAt - } - ... on MarkedAsDuplicateEvent { - createdAt - } - ... on MentionedEvent { - createdAt - } - ... on MilestonedEvent { - createdAt - } - ... on MovedColumnsInProjectEvent { - createdAt - } - ... on PinnedEvent { - createdAt - } - ... on ReferencedEvent { - createdAt - } - ... on RemovedFromProjectEvent { - createdAt - } - ... on RenamedTitleEvent { - createdAt - } - ... on ReopenedEvent { - createdAt - } - ... on SubscribedEvent { - createdAt - } - ... on TransferredEvent { - createdAt - } - ... on UnassignedEvent { - createdAt - } - ... on UnlabeledEvent { - createdAt - } - ... on UnlockedEvent { - createdAt - } - ... on UnmarkedAsDuplicateEvent { - createdAt - } - ... on UnpinnedEvent { - createdAt - } - ... on UnsubscribedEvent { - createdAt - } - ... on UserBlockedEvent { - createdAt - } - } - } - } - } - rateLimit { - ...RateLimit - } -} - query LatestReviewCommit($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { diff --git a/src/github/quickPicks.ts b/src/github/quickPicks.ts index 9c23451019..78e723db45 100644 --- a/src/github/quickPicks.ts +++ b/src/github/quickPicks.ts @@ -6,21 +6,21 @@ import { Buffer } from 'buffer'; import * as vscode from 'vscode'; +import { FolderRepositoryManager } from './folderRepositoryManager'; +import { GitHubRepository, TeamReviewerRefreshKind } from './githubRepository'; +import { AccountType, IAccount, ILabel, IMilestone, IProject, isISuggestedReviewer, isITeam, ISuggestedReviewer, ITeam, reviewerId, ReviewState } from './interface'; +import { IssueModel } from './issueModel'; +import { DisplayLabel } from './views'; import { COPILOT_ACCOUNTS } from '../common/comment'; import { COPILOT_REVIEWER, COPILOT_REVIEWER_ID, COPILOT_SWE_AGENT } from '../common/copilot'; import { emojify, ensureEmojis } from '../common/emoji'; import Logger from '../common/logger'; import { DataUri } from '../common/uri'; import { formatError } from '../common/utils'; -import { FolderRepositoryManager } from './folderRepositoryManager'; -import { GitHubRepository, TeamReviewerRefreshKind } from './githubRepository'; -import { AccountType, IAccount, ILabel, IMilestone, IProject, isISuggestedReviewer, isITeam, ISuggestedReviewer, ITeam, reviewerId, ReviewState } from './interface'; -import { IssueModel } from './issueModel'; -import { DisplayLabel } from './views'; export async function chooseItem( itemsToChooseFrom: T[], - propertyGetter: (itemValue: T) => string, + propertyGetter: (itemValue: T) => { label: string; description?: string; }, options?: vscode.QuickPickOptions, ): Promise { if (itemsToChooseFrom.length === 0) { @@ -34,7 +34,7 @@ export async function chooseItem( } const items: Item[] = itemsToChooseFrom.map(currentItem => { return { - label: propertyGetter(currentItem), + ...propertyGetter(currentItem), itemValue: currentItem, }; }); diff --git a/src/github/repositoriesManager.ts b/src/github/repositoriesManager.ts index ea24ce7e59..e2fce7a325 100644 --- a/src/github/repositoriesManager.ts +++ b/src/github/repositoriesManager.ts @@ -4,6 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { CredentialStore } from './credentials'; +import { FolderRepositoryManager, ReposManagerState, ReposManagerStateContext } from './folderRepositoryManager'; +import { PullRequestChangeEvent } from './githubRepository'; +import { IssueModel } from './issueModel'; +import { findDotComAndEnterpriseRemotes, getEnterpriseUri, hasEnterpriseUri, setEnterpriseUri } from './utils'; import { Repository } from '../api/api'; import { AuthProvider } from '../common/authentication'; import { commands, contexts } from '../common/executeCommands'; @@ -13,11 +18,6 @@ import { ITelemetry } from '../common/telemetry'; import { EventType } from '../common/timelineEvent'; import { fromPRUri, fromRepoUri, Schemes } from '../common/uri'; import { compareIgnoreCase, isDescendant } from '../common/utils'; -import { CredentialStore } from './credentials'; -import { FolderRepositoryManager, ReposManagerState, ReposManagerStateContext } from './folderRepositoryManager'; -import { PullRequestChangeEvent } from './githubRepository'; -import { IssueModel } from './issueModel'; -import { findDotComAndEnterpriseRemotes, getEnterpriseUri, hasEnterpriseUri, setEnterpriseUri } from './utils'; export interface ItemsResponseResult { items: T[]; diff --git a/src/github/revertPRViewProvider.ts b/src/github/revertPRViewProvider.ts index 31dd9b7440..c50ad7eaa1 100644 --- a/src/github/revertPRViewProvider.ts +++ b/src/github/revertPRViewProvider.ts @@ -6,9 +6,6 @@ import * as vscode from 'vscode'; import { CreateParamsNew, CreatePullRequestNew } from '../../common/views'; import { openDescription } from '../commands'; -import { commands, contexts } from '../common/executeCommands'; -import { ITelemetry } from '../common/telemetry'; -import { IRequestMessage } from '../common/webview'; import { BaseCreatePullRequestViewProvider, BasePullRequestDataModel } from './createPRViewProvider'; import { FolderRepositoryManager, @@ -16,6 +13,9 @@ import { } from './folderRepositoryManager'; import { BaseBranchMetadata } from './pullRequestGitHelper'; import { PullRequestModel } from './pullRequestModel'; +import { commands, contexts } from '../common/executeCommands'; +import { ITelemetry } from '../common/telemetry'; +import { IRequestMessage } from '../common/webview'; export class RevertPullRequestViewProvider extends BaseCreatePullRequestViewProvider implements vscode.WebviewViewProvider { constructor( diff --git a/src/github/utils.ts b/src/github/utils.ts index 6f3d1a154d..fba9d565ec 100644 --- a/src/github/utils.ts +++ b/src/github/utils.ts @@ -7,22 +7,6 @@ import * as crypto from 'crypto'; import * as OctokitTypes from '@octokit/types'; import * as vscode from 'vscode'; -import { RemoteInfo } from '../../common/types'; -import { Repository } from '../api/api'; -import { GitApiImpl } from '../api/api1'; -import { AuthProvider, GitHubServerType } from '../common/authentication'; -import { COPILOT_ACCOUNTS, IComment, IReviewThread, SubjectType } from '../common/comment'; -import { COPILOT_SWE_AGENT } from '../common/copilot'; -import { DiffHunk, parseDiffHunk } from '../common/diffHunk'; -import { emojify } from '../common/emoji'; -import { GitHubRef } from '../common/githubRef'; -import Logger from '../common/logger'; -import { Remote } from '../common/remote'; -import { Resource } from '../common/resources'; -import { GITHUB_ENTERPRISE, OVERRIDE_DEFAULT_BRANCH, PR_SETTINGS_NAMESPACE, URI } from '../common/settingKeys'; -import * as Common from '../common/timelineEvent'; -import { DataUri, toOpenIssueWebviewUri, toOpenPullRequestWebviewUri } from '../common/uri'; -import { escapeRegExp, gitHubLabelColor, stringReplaceAsync, uniqBy } from '../common/utils'; import { OctokitCommon } from './common'; import { FolderRepositoryManager, PullRequestDefaults } from './folderRepositoryManager'; import { GitHubRepository, ViewerPermission } from './githubRepository'; @@ -55,6 +39,21 @@ import { } from './interface'; import { IssueModel } from './issueModel'; import { GHPRComment, GHPRCommentThread } from './prComment'; +import { RemoteInfo } from '../../common/types'; +import { Repository } from '../api/api'; +import { GitApiImpl } from '../api/api1'; +import { AuthProvider, GitHubServerType } from '../common/authentication'; +import { COPILOT_ACCOUNTS, IComment, IReviewThread, SubjectType } from '../common/comment'; +import { COPILOT_SWE_AGENT } from '../common/copilot'; +import { DiffHunk, parseDiffHunk } from '../common/diffHunk'; +import { emojify } from '../common/emoji'; +import { GitHubRef } from '../common/githubRef'; +import Logger from '../common/logger'; +import { Remote } from '../common/remote'; +import { GITHUB_ENTERPRISE, OVERRIDE_DEFAULT_BRANCH, PR_SETTINGS_NAMESPACE, URI } from '../common/settingKeys'; +import * as Common from '../common/timelineEvent'; +import { DataUri, toOpenIssueWebviewUri, toOpenPullRequestWebviewUri } from '../common/uri'; +import { escapeRegExp, gitHubLabelColor, stringReplaceAsync, uniqBy } from '../common/utils'; export const ISSUE_EXPRESSION = /(([A-Za-z0-9_.\-]+)\/([A-Za-z0-9_.\-]+))?(#|GH-)([1-9][0-9]*)($|\b)/; export const ISSUE_OR_URL_EXPRESSION = /(https?:\/\/github\.com\/(([^\s]+)\/([^\s]+))\/([^\s]+\/)?(issues|pull)\/([0-9]+)(#issuecomment\-([0-9]+))?)|(([A-Za-z0-9_.\-]+)\/([A-Za-z0-9_.\-]+))?(#|GH-)([1-9][0-9]*)($|\b)/; @@ -439,7 +438,7 @@ export function convertRESTReviewEvent( return { event: Common.EventType.Reviewed, comments: [], - submittedAt: (review as any).submitted_at, // TODO fix typings upstream + submittedAt: review.submitted_at, body: review.body, bodyHTML: review.body, htmlUrl: review.html_url, @@ -653,10 +652,13 @@ export function parseAccount( } // In some places, Copilot comes in as a user, and in others as a bot + + const finalAvatarUrl = githubRepository ? getAvatarWithEnterpriseFallback(avatarUrl, undefined, githubRepository.remote.isEnterprise) : avatarUrl; + return { login: author.login, url: COPILOT_ACCOUNTS[author.login]?.url ?? url, - avatarUrl: githubRepository ? getAvatarWithEnterpriseFallback(avatarUrl, undefined, githubRepository.remote.isEnterprise) : avatarUrl, + avatarUrl: finalAvatarUrl, email: author.email ?? undefined, id, name: author.name ?? COPILOT_ACCOUNTS[author.login]?.name ?? undefined, @@ -1118,6 +1120,7 @@ export async function parseCombinedTimelineEvents( | GraphQL.AssignedEvent | GraphQL.HeadRefDeletedEvent | GraphQL.CrossReferencedEvent + | null )[], restEvents: Common.TimelineEvent[], githubRepository: GitHubRepository, @@ -1147,6 +1150,9 @@ export async function parseCombinedTimelineEvents( // TODO: work the rest events into the appropriate place in the timeline for (const event of events) { + if (!event) { + continue; + } const type = convertGraphQLEventType(event.__typename); switch (type) { @@ -1335,50 +1341,42 @@ export function getReactionGroup(): { title: string; label: string; icon?: strin { title: 'THUMBS_UP', // allow-any-unicode-next-line - label: '👍', - icon: Resource.icons.reactions.THUMBS_UP, + label: '👍' }, { title: 'THUMBS_DOWN', // allow-any-unicode-next-line - label: '👎', - icon: Resource.icons.reactions.THUMBS_DOWN, + label: '👎' }, { title: 'LAUGH', // allow-any-unicode-next-line - label: '😄', - icon: Resource.icons.reactions.LAUGH, + label: '😄' }, { title: 'HOORAY', // allow-any-unicode-next-line - label: '🎉', - icon: Resource.icons.reactions.HOORAY, + label: '🎉' }, { title: 'CONFUSED', // allow-any-unicode-next-line - label: '😕', - icon: Resource.icons.reactions.CONFUSED, + label: '😕' }, { title: 'HEART', // allow-any-unicode-next-line - label: '❤️', - icon: Resource.icons.reactions.HEART, + label: '❤️' }, { title: 'ROCKET', // allow-any-unicode-next-line - label: '🚀', - icon: Resource.icons.reactions.ROCKET, + label: '🚀' }, { title: 'EYES', // allow-any-unicode-next-line - label: '👀', - icon: Resource.icons.reactions.EYES, + label: '👀' }, ]; @@ -1393,6 +1391,7 @@ export async function restPaginate(r do { const result = await request( { + // eslint-disable-next-line rulesdir/no-cast-to-any ...(variables as any), per_page, page @@ -1557,7 +1556,7 @@ export function parseNotification(notification: OctokitCommon.Notification): Not lastReadAt: notification.last_read_at ? new Date(notification.last_read_at) : undefined, reason: notification.reason, unread: notification.unread, - updatedAd: new Date(notification.updated_at), + updatedAt: new Date(notification.updated_at), }; } @@ -1637,8 +1636,21 @@ export function generateGravatarUrl(gravatarId: string | undefined, size: number } export function getAvatarWithEnterpriseFallback(avatarUrl: string, email: string | undefined, isEnterpriseRemote: boolean): string | undefined { - return !isEnterpriseRemote ? avatarUrl : (email ? generateGravatarUrl( - crypto.createHash('sha256').update(email?.trim()?.toLowerCase()).digest('hex')) : undefined); + + // For non-enterprise, always use the provided avatarUrl + if (!isEnterpriseRemote) { + return avatarUrl; + } + + // For enterprise, prefer GitHub avatarUrl if available, fallback to Gravatar only if needed + if (avatarUrl && avatarUrl.trim()) { + return avatarUrl; + } + + // Only fallback to Gravatar if no avatarUrl is available and email is provided + const gravatarUrl = email ? generateGravatarUrl( + crypto.createHash('sha256').update(email.trim().toLowerCase()).digest('hex')) : undefined; + return gravatarUrl; } export function getPullsUrl(repo: GitHubRepository) { diff --git a/src/github/views.ts b/src/github/views.ts index fe7cb3ba41..70a9dc245a 100644 --- a/src/github/views.ts +++ b/src/github/views.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CommentEvent, ReviewEvent, SessionLinkInfo, TimelineEvent } from '../common/timelineEvent'; import { GithubItemStateEnum, IAccount, @@ -20,6 +19,8 @@ import { ReviewState, StateReason, } from './interface'; +import { IComment } from '../common/comment'; +import { CommentEvent, ReviewEvent, SessionLinkInfo, TimelineEvent } from '../common/timelineEvent'; export enum ReviewType { Comment = 'comment', @@ -138,6 +139,11 @@ export interface MergeResult { events?: TimelineEvent[]; } +export interface DeleteReviewResult { + deletedReviewId: number; + deletedReviewComments: IComment[]; +} + export enum PreReviewState { None = 0, Available, diff --git a/src/integrations/gitlens/gitlensImpl.ts b/src/integrations/gitlens/gitlensImpl.ts index 0ba73b1b0a..a589aa1252 100644 --- a/src/integrations/gitlens/gitlensImpl.ts +++ b/src/integrations/gitlens/gitlensImpl.ts @@ -4,8 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { Disposable } from '../../common/lifecycle'; import { CreatePullRequestActionContext, GitLensApi } from './gitlens'; +import { Disposable } from '../../common/lifecycle'; export class GitLensIntegration extends Disposable { private _extensionsDisposable: vscode.Disposable; diff --git a/src/issues/currentIssue.ts b/src/issues/currentIssue.ts index 4abd882cb9..2839991f30 100644 --- a/src/issues/currentIssue.ts +++ b/src/issues/currentIssue.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { IssueState, StateManager } from './stateManager'; import { Branch, Repository } from '../api/api'; import { GitErrorCodes } from '../api/api1'; import { Disposable } from '../common/lifecycle'; @@ -20,7 +21,6 @@ import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRe import { GithubItemStateEnum } from '../github/interface'; import { IssueModel } from '../github/issueModel'; import { variableSubstitution } from '../github/utils'; -import { IssueState, StateManager } from './stateManager'; export class CurrentIssue extends Disposable { private _branchName: string | undefined; diff --git a/src/issues/issueCompletionProvider.ts b/src/issues/issueCompletionProvider.ts index 505282a1e4..63bd4a527d 100644 --- a/src/issues/issueCompletionProvider.ts +++ b/src/issues/issueCompletionProvider.ts @@ -11,17 +11,17 @@ import { } from '../common/settingKeys'; import { fromNewIssueUri, Schemes } from '../common/uri'; import { EXTENSION_ID } from '../constants'; +import { IssueQueryResult, StateManager } from './stateManager'; +import { + getRootUriFromScmInputUri, + isComment, +} from './util'; import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; import { IMilestone } from '../github/interface'; import { IssueModel } from '../github/issueModel'; import { issueMarkdown } from '../github/markdownUtils'; import { RepositoriesManager } from '../github/repositoriesManager'; import { getIssueNumberLabel, variableSubstitution } from '../github/utils'; -import { IssueQueryResult, StateManager } from './stateManager'; -import { - getRootUriFromScmInputUri, - isComment, -} from './util'; class IssueCompletionItem extends vscode.CompletionItem { constructor(public readonly issue: IssueModel) { @@ -100,7 +100,9 @@ export class IssueCompletionProvider implements vscode.CompletionItemProvider { return []; } - if ((document.languageId !== 'scminput') && (document.languageId !== 'git-commit') && !(await isComment(document, position))) { + const isPositionComment = document.languageId === 'plaintext' || document.languageId === 'markdown' || await isComment(document, position); + + if ((document.languageId !== 'scminput') && (document.languageId !== 'git-commit') && !isPositionComment) { return []; } diff --git a/src/issues/issueFeatureRegistrar.ts b/src/issues/issueFeatureRegistrar.ts index cf62dad245..cd575b7d33 100644 --- a/src/issues/issueFeatureRegistrar.ts +++ b/src/issues/issueFeatureRegistrar.ts @@ -5,6 +5,8 @@ import { basename } from 'path'; import * as vscode from 'vscode'; +import { CurrentIssue } from './currentIssue'; +import { IssueCompletionProvider } from './issueCompletionProvider'; import { Remote } from '../api/api'; import { GitApiImpl } from '../api/api1'; import { COPILOT_ACCOUNTS } from '../common/comment'; @@ -23,20 +25,6 @@ import { editQuery } from '../common/settingsUtils'; import { ITelemetry } from '../common/telemetry'; import { fromRepoUri, RepoUriParams, Schemes, toNewIssueUri } from '../common/uri'; import { EXTENSION_ID } from '../constants'; -import { OctokitCommon } from '../github/common'; -import { CopilotRemoteAgentManager } from '../github/copilotRemoteAgent'; -import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; -import { IProject } from '../github/interface'; -import { IssueModel } from '../github/issueModel'; -import { IssueOverviewPanel } from '../github/issueOverview'; -import { RepositoriesManager } from '../github/repositoriesManager'; -import { ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput } from '../github/utils'; -import { chatCommand } from '../lm/utils'; -import { ReviewManager } from '../view/reviewManager'; -import { ReviewsManager } from '../view/reviewsManager'; -import { PRNode } from '../view/treeNodes/pullRequestNode'; -import { CurrentIssue } from './currentIssue'; -import { IssueCompletionProvider } from './issueCompletionProvider'; import { ASSIGNEES, extractMetadataFromFile, @@ -69,6 +57,19 @@ import { pushAndCreatePR, USER_EXPRESSION, } from './util'; +import { truncate } from '../common/utils'; +import { OctokitCommon } from '../github/common'; +import { CopilotRemoteAgentManager } from '../github/copilotRemoteAgent'; +import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; +import { IProject } from '../github/interface'; +import { IssueModel } from '../github/issueModel'; +import { IssueOverviewPanel } from '../github/issueOverview'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput } from '../github/utils'; +import { chatCommand } from '../lm/utils'; +import { ReviewManager } from '../view/reviewManager'; +import { ReviewsManager } from '../view/reviewsManager'; +import { PRNode } from '../view/treeNodes/pullRequestNode'; const CREATING_ISSUE_FROM_FILE_CONTEXT = 'issues.creatingFromFile'; @@ -147,8 +148,8 @@ export class IssueFeatureRegistrar extends Disposable { 'issue.startCodingAgentFromTodo', (todoInfo?: { document: vscode.TextDocument; lineNumber: number; line: string; insertIndex: number; range: vscode.Range }) => { /* __GDPR__ - "issue.startCodingAgentFromTodo" : {} - */ + "issue.startCodingAgentFromTodo" : {} + */ this.telemetry.sendTelemetryEvent('issue.startCodingAgentFromTodo'); return this.startCodingAgentFromTodo(todoInfo); }, @@ -575,8 +576,12 @@ export class IssueFeatureRegistrar extends Disposable { this._register( vscode.languages.registerHoverProvider('*', new UserHoverProvider(this.manager, this.telemetry)), ); + const todoProvider = new IssueTodoProvider(this.context, this.copilotRemoteAgentManager); this._register( - vscode.languages.registerCodeActionsProvider('*', new IssueTodoProvider(this.context, this.copilotRemoteAgentManager), { providedCodeActionKinds: [vscode.CodeActionKind.QuickFix] }), + vscode.languages.registerCodeActionsProvider('*', todoProvider, { providedCodeActionKinds: [vscode.CodeActionKind.QuickFix] }), + ); + this._register( + vscode.languages.registerCodeLensProvider('*', todoProvider), ); }); } @@ -1488,28 +1493,27 @@ ${options?.body ?? ''}\n } const { document, line, insertIndex } = todoInfo; - - // Extract the TODO text after the trigger word const todoText = line.substring(insertIndex).trim(); - if (!todoText) { vscode.window.showWarningMessage(vscode.l10n.t('No task description found in TODO comment')); return; } - // Create a prompt for the coding agent const relativePath = vscode.workspace.asRelativePath(document.uri); const prompt = vscode.l10n.t('Work on TODO: {0} (from {1})', todoText, relativePath); - - // Start the coding agent session - try { - await this.copilotRemoteAgentManager.commandImpl({ - userPrompt: prompt, - source: 'todo' - }); - } catch (error) { - vscode.window.showErrorMessage(vscode.l10n.t('Failed to start coding agent session: {0}', error.message)); - } + return vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: vscode.l10n.t('Delegating \'{0}\' to coding agent', truncate(todoText, 20)) + }, async (_progress) => { + try { + await this.copilotRemoteAgentManager.commandImpl({ + userPrompt: prompt, + source: 'todo' + }); + } catch (error) { + vscode.window.showErrorMessage(vscode.l10n.t('Failed to start coding agent session: {0}', error.message)); + } + }); } async assignToCodingAgent(issueModel: any) { diff --git a/src/issues/issueHoverProvider.ts b/src/issues/issueHoverProvider.ts index 0e8e677e8f..5a33c43f00 100644 --- a/src/issues/issueHoverProvider.ts +++ b/src/issues/issueHoverProvider.ts @@ -4,16 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { ITelemetry } from '../common/telemetry'; -import { FolderRepositoryManager } from '../github/folderRepositoryManager'; -import { issueMarkdown } from '../github/markdownUtils'; -import { RepositoriesManager } from '../github/repositoriesManager'; -import { ISSUE_OR_URL_EXPRESSION, ParsedIssue, parseIssueExpressionOutput } from '../github/utils'; import { StateManager } from './stateManager'; import { getIssue, shouldShowHover, } from './util'; +import { ITelemetry } from '../common/telemetry'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { issueMarkdown } from '../github/markdownUtils'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { ISSUE_OR_URL_EXPRESSION, ParsedIssue, parseIssueExpressionOutput } from '../github/utils'; export class IssueHoverProvider implements vscode.HoverProvider { constructor( diff --git a/src/issues/issueLinkProvider.ts b/src/issues/issueLinkProvider.ts deleted file mode 100644 index 3410d1a837..0000000000 --- a/src/issues/issueLinkProvider.ts +++ /dev/null @@ -1,89 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import * as vscode from 'vscode'; -import { EDITOR, WORD_WRAP } from '../common/settingKeys'; -import { ReposManagerState } from '../github/folderRepositoryManager'; -import { RepositoriesManager } from '../github/repositoriesManager'; -import { ISSUE_EXPRESSION, ParsedIssue, parseIssueExpressionOutput } from '../github/utils'; -import { StateManager } from './stateManager'; -import { - getIssue, - isComment, - MAX_LINE_LENGTH, -} from './util'; - -const MAX_LINE_COUNT = 2000; - -class IssueDocumentLink extends vscode.DocumentLink { - constructor( - range: vscode.Range, - public readonly mappedLink: { readonly value: string; readonly parsed: ParsedIssue }, - public readonly uri: vscode.Uri, - ) { - super(range); - } -} - -export class IssueLinkProvider implements vscode.DocumentLinkProvider { - constructor(private manager: RepositoriesManager, private stateManager: StateManager) { } - - async provideDocumentLinks( - document: vscode.TextDocument, - _token: vscode.CancellationToken, - ): Promise { - const links: vscode.DocumentLink[] = []; - const wraps: boolean = vscode.workspace.getConfiguration(EDITOR, document).get(WORD_WRAP, 'off') !== 'off'; - for (let i = 0; i < Math.min(document.lineCount, MAX_LINE_COUNT); i++) { - let searchResult = -1; - let lineOffset = 0; - const line = document.lineAt(i).text; - const lineLength = wraps ? line.length : Math.min(line.length, MAX_LINE_LENGTH); - let lineSubstring = line.substring(0, lineLength); - while ((searchResult = lineSubstring.search(ISSUE_EXPRESSION)) >= 0) { - const match = lineSubstring.match(ISSUE_EXPRESSION); - const parsed = parseIssueExpressionOutput(match); - if (match && parsed) { - const startPosition = new vscode.Position(i, searchResult + lineOffset); - if (await isComment(document, startPosition)) { - const link = new IssueDocumentLink( - new vscode.Range( - startPosition, - new vscode.Position(i, searchResult + lineOffset + match[0].length - 1), - ), - { value: match[0], parsed }, - document.uri, - ); - links.push(link); - } - } - lineOffset += searchResult + (match ? match[0].length : 0); - lineSubstring = line.substring(lineOffset, line.length); - } - } - return links; - } - - async resolveDocumentLink( - link: IssueDocumentLink, - _token: vscode.CancellationToken, - ): Promise { - if (this.manager.state === ReposManagerState.RepositoriesLoaded) { - const folderManager = this.manager.getManagerForFile(link.uri); - if (!folderManager) { - return; - } - const issue = await getIssue( - this.stateManager, - folderManager, - link.mappedLink.value, - link.mappedLink.parsed, - ); - if (issue) { - link.target = await vscode.env.asExternalUri(vscode.Uri.parse(issue.html_url)); - } - return link; - } - } -} diff --git a/src/issues/issueTodoProvider.ts b/src/issues/issueTodoProvider.ts index fc3b9bbd3b..383e6007aa 100644 --- a/src/issues/issueTodoProvider.ts +++ b/src/issues/issueTodoProvider.ts @@ -4,13 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { CREATE_ISSUE_TRIGGERS, ISSUES_SETTINGS_NAMESPACE } from '../common/settingKeys'; +import { isComment, MAX_LINE_LENGTH } from './util'; +import { CODING_AGENT, CREATE_ISSUE_TRIGGERS, ISSUES_SETTINGS_NAMESPACE, SHOW_CODE_LENS } from '../common/settingKeys'; import { escapeRegExp } from '../common/utils'; import { CopilotRemoteAgentManager } from '../github/copilotRemoteAgent'; import { ISSUE_OR_URL_EXPRESSION } from '../github/utils'; -import { MAX_LINE_LENGTH } from './util'; -export class IssueTodoProvider implements vscode.CodeActionProvider { +export class IssueTodoProvider implements vscode.CodeActionProvider, vscode.CodeLensProvider { private expression: RegExp | undefined; constructor( @@ -30,6 +30,24 @@ export class IssueTodoProvider implements vscode.CodeActionProvider { this.expression = triggers.length > 0 ? new RegExp(triggers.map(trigger => escapeRegExp(trigger)).join('|')) : undefined; } + private findTodoInLine(line: string): { match: RegExpMatchArray; search: number; insertIndex: number } | undefined { + const truncatedLine = line.substring(0, MAX_LINE_LENGTH); + const matches = truncatedLine.match(ISSUE_OR_URL_EXPRESSION); + if (matches) { + return undefined; + } + const match = truncatedLine.match(this.expression!); + const search = match?.index ?? -1; + if (search >= 0 && match) { + const indexOfWhiteSpace = truncatedLine.substring(search).search(/\s/); + const insertIndex = + search + + (indexOfWhiteSpace > 0 ? indexOfWhiteSpace : truncatedLine.match(this.expression!)![0].length); + return { match, search, insertIndex }; + } + return undefined; + } + async provideCodeActions( document: vscode.TextDocument, range: vscode.Range | vscode.Selection, @@ -43,48 +61,79 @@ export class IssueTodoProvider implements vscode.CodeActionProvider { let lineNumber = range.start.line; do { const line = document.lineAt(lineNumber).text; - const truncatedLine = line.substring(0, MAX_LINE_LENGTH); - const matches = truncatedLine.match(ISSUE_OR_URL_EXPRESSION); - if (!matches) { - const match = truncatedLine.match(this.expression); - const search = match?.index ?? -1; - if (search >= 0 && match) { - // Create GitHub Issue action - const createIssueAction: vscode.CodeAction = new vscode.CodeAction( - vscode.l10n.t('Create GitHub Issue'), + const todoInfo = this.findTodoInLine(line); + if (todoInfo) { + const { match, search, insertIndex } = todoInfo; + // Create GitHub Issue action + const createIssueAction: vscode.CodeAction = new vscode.CodeAction( + vscode.l10n.t('Create GitHub Issue'), + vscode.CodeActionKind.QuickFix, + ); + createIssueAction.ranges = [new vscode.Range(lineNumber, search, lineNumber, search + match[0].length)]; + createIssueAction.command = { + title: vscode.l10n.t('Create GitHub Issue'), + command: 'issue.createIssueFromSelection', + arguments: [{ document, lineNumber, line, insertIndex, range }], + }; + codeActions.push(createIssueAction); + + // Start Coding Agent Session action (if copilot manager is available) + if (this.copilotRemoteAgentManager) { + const startAgentAction: vscode.CodeAction = new vscode.CodeAction( + vscode.l10n.t('Delegate to agent'), vscode.CodeActionKind.QuickFix, ); - createIssueAction.ranges = [new vscode.Range(lineNumber, search, lineNumber, search + match[0].length)]; - const indexOfWhiteSpace = truncatedLine.substring(search).search(/\s/); - const insertIndex = - search + - (indexOfWhiteSpace > 0 ? indexOfWhiteSpace : truncatedLine.match(this.expression)![0].length); - createIssueAction.command = { - title: vscode.l10n.t('Create GitHub Issue'), - command: 'issue.createIssueFromSelection', + startAgentAction.ranges = [new vscode.Range(lineNumber, search, lineNumber, search + match[0].length)]; + startAgentAction.command = { + title: vscode.l10n.t('Delegate to agent'), + command: 'issue.startCodingAgentFromTodo', arguments: [{ document, lineNumber, line, insertIndex, range }], }; - codeActions.push(createIssueAction); - - // Start Coding Agent Session action (if copilot manager is available) - if (this.copilotRemoteAgentManager) { - const startAgentAction: vscode.CodeAction = new vscode.CodeAction( - vscode.l10n.t('Delegate to coding agent'), - vscode.CodeActionKind.QuickFix, - ); - startAgentAction.ranges = [new vscode.Range(lineNumber, search, lineNumber, search + match[0].length)]; - startAgentAction.command = { - title: vscode.l10n.t('Delegate to coding agent'), - command: 'issue.startCodingAgentFromTodo', - arguments: [{ document, lineNumber, line, insertIndex, range }], - }; - codeActions.push(startAgentAction); - } - break; + codeActions.push(startAgentAction); } + break; } lineNumber++; } while (range.end.line >= lineNumber); return codeActions; } + + async provideCodeLenses( + document: vscode.TextDocument, + _token: vscode.CancellationToken, + ): Promise { + if (this.expression === undefined) { + return []; + } + + // Check if CodeLens is enabled + const isCodeLensEnabled = vscode.workspace.getConfiguration(CODING_AGENT).get(SHOW_CODE_LENS, true); + if (!isCodeLensEnabled) { + return []; + } + + const codeLenses: vscode.CodeLens[] = []; + for (let lineNumber = 0; lineNumber < document.lineCount; lineNumber++) { + const textLine = document.lineAt(lineNumber); + const { text: line, firstNonWhitespaceCharacterIndex } = textLine; + const todoInfo = this.findTodoInLine(line); + if (!todoInfo) { + continue; + } + if (!(await isComment(document, new vscode.Position(lineNumber, firstNonWhitespaceCharacterIndex)))) { + continue; + } + const { match, search, insertIndex } = todoInfo; + const range = new vscode.Range(lineNumber, search, lineNumber, search + match[0].length); + if (this.copilotRemoteAgentManager && (await this.copilotRemoteAgentManager.isAvailable())) { + const startAgentCodeLens = new vscode.CodeLens(range, { + title: vscode.l10n.t('Delegate to agent'), + command: 'issue.startCodingAgentFromTodo', + arguments: [{ document, lineNumber, line, insertIndex, range }], + }); + codeLenses.push(startAgentCodeLens); + } + } + return codeLenses; + } } diff --git a/src/issues/issuesView.ts b/src/issues/issuesView.ts index 4b0fd18065..d839c2d6e8 100644 --- a/src/issues/issuesView.ts +++ b/src/issues/issuesView.ts @@ -5,6 +5,8 @@ import * as path from 'path'; import * as vscode from 'vscode'; +import { issueBodyHasLink } from './issueLinkLookup'; +import { IssueItem, QueryGroup, StateManager } from './stateManager'; import { commands, contexts } from '../common/executeCommands'; import { ISSUE_AVATAR_DISPLAY, ISSUES_SETTINGS_NAMESPACE } from '../common/settingKeys'; import { DataUri } from '../common/uri'; @@ -14,8 +16,6 @@ import { IAccount } from '../github/interface'; import { IssueModel } from '../github/issueModel'; import { issueMarkdown } from '../github/markdownUtils'; import { RepositoriesManager } from '../github/repositoriesManager'; -import { issueBodyHasLink } from './issueLinkLookup'; -import { IssueItem, QueryGroup, StateManager } from './stateManager'; export class QueryNode { constructor( @@ -117,7 +117,12 @@ export class IssuesTreeData }; if (this.stateManager.currentIssue(element.uri)?.issue.number === element.number) { - treeItem.label = `✓ ${treeItem.label as string}`; + // Escape any $(...) syntax to avoid rendering issue titles as icons. + const escapedTitle = element.title.replace(/\$\([a-zA-Z0-9~-]+\)/g, '\\$&'); + const label: vscode.TreeItemLabel2 = { + label: new vscode.MarkdownString(`$(check) ${escapedTitle}`, true) + }; + treeItem.label = label as vscode.TreeItemLabel; treeItem.contextValue = 'currentissue'; } else { const savedState = this.stateManager.getSavedIssueState(element.number); diff --git a/src/issues/shareProviders.ts b/src/issues/shareProviders.ts index 21fbe163ef..2b98217004 100644 --- a/src/issues/shareProviders.ts +++ b/src/issues/shareProviders.ts @@ -5,6 +5,7 @@ import * as pathLib from 'path'; import * as vscode from 'vscode'; +import { encodeURIComponentExceptSlashes, getBestPossibleUpstream, getOwnerAndRepo, getSimpleUpstream, getUpstreamOrigin, rangeString } from './util'; import { Commit, Remote, Repository } from '../api/api'; import { GitApiImpl } from '../api/api1'; import { Disposable, disposeAll } from '../common/lifecycle'; @@ -12,7 +13,6 @@ import Logger from '../common/logger'; import { fromReviewUri, Schemes } from '../common/uri'; import { FolderRepositoryManager } from '../github/folderRepositoryManager'; import { RepositoriesManager } from '../github/repositoriesManager'; -import { encodeURIComponentExceptSlashes, getBestPossibleUpstream, getOwnerAndRepo, getSimpleUpstream, getUpstreamOrigin, rangeString } from './util'; export class ShareProviderManager extends Disposable { diff --git a/src/issues/stateManager.ts b/src/issues/stateManager.ts index 075238af04..b02b45ad07 100644 --- a/src/issues/stateManager.ts +++ b/src/issues/stateManager.ts @@ -5,6 +5,7 @@ import LRUCache from 'lru-cache'; import * as vscode from 'vscode'; +import { CurrentIssue } from './currentIssue'; import { Repository } from '../api/api'; import { GitApiImpl } from '../api/api1'; import { AuthProvider } from '../common/authentication'; @@ -25,7 +26,6 @@ import { IAccount } from '../github/interface'; import { IssueModel } from '../github/issueModel'; import { RepositoriesManager } from '../github/repositoriesManager'; import { getIssueNumberLabel, variableSubstitution } from '../github/utils'; -import { CurrentIssue } from './currentIssue'; const CURRENT_ISSUE_KEY = 'currentIssue'; @@ -343,7 +343,7 @@ export class StateManager { private setIssues(folderManager: FolderRepositoryManager, query: string): Promise { return new Promise(async resolve => { - const issues = await folderManager.getIssues(query); + const issues = await folderManager.getIssues(query, { fetchNextPage: false, fetchOnePagePerRepo: true }); this._onDidChangeIssueData.fire(); resolve( issues?.items.map(item => { diff --git a/src/issues/userCompletionProvider.ts b/src/issues/userCompletionProvider.ts index ff7f28416f..fbd5413cfd 100644 --- a/src/issues/userCompletionProvider.ts +++ b/src/issues/userCompletionProvider.ts @@ -11,14 +11,14 @@ import { TimelineEvent } from '../common/timelineEvent'; import { fromNewIssueUri, fromPRUri, Schemes } from '../common/uri'; import { compareIgnoreCase, isDescendant } from '../common/utils'; import { EXTENSION_ID } from '../constants'; +import { ASSIGNEES } from './issueFile'; +import { StateManager } from './stateManager'; +import { getRootUriFromScmInputUri, isComment, UserCompletion } from './util'; import { FolderRepositoryManager } from '../github/folderRepositoryManager'; import { IAccount, User } from '../github/interface'; import { userMarkdown } from '../github/markdownUtils'; import { RepositoriesManager } from '../github/repositoriesManager'; import { getRelatedUsersFromTimelineEvents } from '../github/utils'; -import { ASSIGNEES } from './issueFile'; -import { StateManager } from './stateManager'; -import { getRootUriFromScmInputUri, isComment, UserCompletion } from './util'; export class UserCompletionProvider implements vscode.CompletionItemProvider { private static readonly ID: string = 'UserCompletionProvider'; @@ -77,7 +77,9 @@ export class UserCompletionProvider implements vscode.CompletionItemProvider { return []; } - if (!this.isCodeownersFiles(document.uri) && (document.languageId !== 'scminput') && (document.languageId !== 'git-commit') && !(await isComment(document, position))) { + const isPositionComment = document.languageId === 'plaintext' || document.languageId === 'markdown' || await isComment(document, position); + + if (!this.isCodeownersFiles(document.uri) && (document.languageId !== 'scminput') && (document.languageId !== 'git-commit') && !isPositionComment) { return []; } diff --git a/src/issues/userHoverProvider.ts b/src/issues/userHoverProvider.ts index 6dc94c405c..671e71510f 100644 --- a/src/issues/userHoverProvider.ts +++ b/src/issues/userHoverProvider.ts @@ -4,11 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { shouldShowHover, USER_EXPRESSION } from './util'; import { ITelemetry } from '../common/telemetry'; import { DOXYGEN_NON_USERS, JSDOC_NON_USERS, PHPDOC_NON_USERS } from '../common/user'; import { userMarkdown } from '../github/markdownUtils'; import { RepositoriesManager } from '../github/repositoriesManager'; -import { shouldShowHover, USER_EXPRESSION } from './util'; export class UserHoverProvider implements vscode.HoverProvider { constructor(private manager: RepositoriesManager, private telemetry: ITelemetry) { } diff --git a/src/issues/util.ts b/src/issues/util.ts index c0a2400a72..87a4e7e382 100644 --- a/src/issues/util.ts +++ b/src/issues/util.ts @@ -6,6 +6,7 @@ import LRUCache from 'lru-cache'; import 'url-search-params-polyfill'; import * as vscode from 'vscode'; +import { StateManager } from './stateManager'; import { Ref, Remote, Repository, UpstreamRef } from '../api/api'; import { GitApiImpl } from '../api/api1'; import Logger from '../common/logger'; @@ -16,7 +17,6 @@ import { IssueModel } from '../github/issueModel'; import { RepositoriesManager } from '../github/repositoriesManager'; import { getEnterpriseUri, getRepositoryForFile, ISSUE_OR_URL_EXPRESSION, ParsedIssue, parseIssueExpressionOutput } from '../github/utils'; import { ReviewManager } from '../view/reviewManager'; -import { StateManager } from './stateManager'; export const USER_EXPRESSION: RegExp = /\@([^\s]+)/; @@ -320,7 +320,7 @@ export async function createSinglePermalink( if (!rawUpstream || !rawUpstream.fetchUrl) { return { permalink: undefined, error: vscode.l10n.t('The selection may not exist on any remote.'), originalFile: uri }; } - const upstream: Remote & { fetchUrl: string } = rawUpstream as any; + const upstream: Remote & { fetchUrl: string } = rawUpstream as Remote & { fetchUrl: string }; Logger.debug(`upstream: ${upstream.fetchUrl}`, PERMALINK_COMPONENT); @@ -517,12 +517,11 @@ export async function pushAndCreatePR( } export async function isComment(document: vscode.TextDocument, position: vscode.Position): Promise { - if (document.languageId !== 'markdown' && document.languageId !== 'plaintext') { - const tokenInfo = await vscode.languages.getTokenInformationAtPosition(document, position); - if (tokenInfo.type !== vscode.StandardTokenType.Comment) { - return false; - } + const tokenInfo = await vscode.languages.getTokenInformationAtPosition(document, position); + if (tokenInfo.type !== vscode.StandardTokenType.Comment) { + return false; } + return true; } diff --git a/src/lm/participants.ts b/src/lm/participants.ts index 614eccd7d5..6f2e339940 100644 --- a/src/lm/participants.ts +++ b/src/lm/participants.ts @@ -6,8 +6,8 @@ 'use strict'; import { renderPrompt } from '@vscode/prompt-tsx'; import * as vscode from 'vscode'; -import { Disposable } from '../common/lifecycle'; import { ParticipantsPrompt } from './participantsPrompt'; +import { Disposable } from '../common/lifecycle'; import { IToolCall, TOOL_COMMAND_RESULT, TOOL_MARKDOWN_RESULT } from './tools/toolsUtils'; export class ChatParticipantState { @@ -36,6 +36,7 @@ export class ChatParticipantState { } } } + return undefined; } get messages(): vscode.LanguageModelChatMessage[] { diff --git a/src/lm/participantsPrompt.ts b/src/lm/participantsPrompt.ts new file mode 100644 index 0000000000..9533fd073d --- /dev/null +++ b/src/lm/participantsPrompt.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AssistantMessage, BasePromptElementProps, Chunk, PromptElement, PromptPiece, PromptSizing, UserMessage } from '@vscode/prompt-tsx'; + +interface ParticipantsPromptProps extends BasePromptElementProps { + readonly userMessage: string; +} + +export class ParticipantsPrompt extends PromptElement { + render(_state: void, _sizing: PromptSizing): PromptPiece { + const instructions = [ + 'Instructions:', + '- The user will ask a question related to GitHub, and it may require lots of research to answer correctly. There is a selection of tools that let you perform actions or retrieve helpful context to answer the user\'s question.', + "- If you aren't sure which tool is relevant, you can call multiple tools. You can call tools repeatedly to take actions or gather as much context as needed until you have completed the task fully. Don't give up unless you are sure the request cannot be fulfilled with the tools you have.", + "- Don't ask the user for confirmation to use tools, just use them.", + '- When talking about issues, be as concise as possible while still conveying all the information you need to. Avoid mentioning the following:', + ' - The fact that there are no comments.', + ' - Any info that seems like template info.' + ].join('\n'); + + const assistantPiece: PromptPiece = { + ctor: AssistantMessage, + props: {}, + children: [instructions] + }; + + const userPiece: PromptPiece = { + ctor: UserMessage, + props: {}, + children: [this.props.userMessage] + }; + + const container: PromptPiece = { + ctor: Chunk, + props: {}, + children: [assistantPiece, userPiece] + }; + return container; + } +} \ No newline at end of file diff --git a/src/lm/participantsPrompt.tsx b/src/lm/participantsPrompt.tsx deleted file mode 100644 index 1c2461f57f..0000000000 --- a/src/lm/participantsPrompt.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { AssistantMessage, BasePromptElementProps, PromptElement, UserMessage } from '@vscode/prompt-tsx'; - -interface ParticipantsPromptProps extends BasePromptElementProps { - readonly userMessage: string; -} - -export class ParticipantsPrompt extends PromptElement { - render() { - return ( - <> - - Instructions:
- - The user will ask a question related to GitHub, and it may require lots of research to answer correctly. There is a selection of tools that let you perform actions or retrieve helpful context to answer the user's question.
- - If you aren't sure which tool is relevant, you can call multiple tools. You can call tools repeatedly to take actions or gather as much context as needed until you have completed the task fully. Don't give up unless you are sure the request cannot be fulfilled with the tools you have.
- - Don't ask the user for confirmation to use tools, just use them.
- - When talking about issues, be as concise as possible while still conveying all the information you need to. Avoid mentioning the following:
- - The fact that there are no comments.
- - Any info that seems like template info. -
- - {this.props.userMessage} - - - ); - } -} \ No newline at end of file diff --git a/src/lm/tools/activePullRequestTool.ts b/src/lm/tools/activePullRequestTool.ts index 081ac81ced..4cd4db515d 100644 --- a/src/lm/tools/activePullRequestTool.ts +++ b/src/lm/tools/activePullRequestTool.ts @@ -5,6 +5,7 @@ 'use strict'; import * as vscode from 'vscode'; +import { FetchIssueResult } from './fetchIssueTool'; import { COPILOT_LOGINS } from '../../common/copilot'; import { GitChangeType, InMemFileChange } from '../../common/file'; import Logger from '../../common/logger'; @@ -12,7 +13,6 @@ import { CommentEvent, EventType, ReviewEvent } from '../../common/timelineEvent import { CopilotRemoteAgentManager } from '../../github/copilotRemoteAgent'; import { PullRequestModel } from '../../github/pullRequestModel'; import { RepositoriesManager } from '../../github/repositoriesManager'; -import { FetchIssueResult } from './fetchIssueTool'; export abstract class PullRequestTool implements vscode.LanguageModelTool { constructor( diff --git a/src/lm/tools/copilotRemoteAgentTool.ts b/src/lm/tools/copilotRemoteAgentTool.ts index 920feafd04..822d2d556a 100644 --- a/src/lm/tools/copilotRemoteAgentTool.ts +++ b/src/lm/tools/copilotRemoteAgentTool.ts @@ -11,6 +11,7 @@ import { toOpenPullRequestWebviewUri } from '../../common/uri'; import { CopilotRemoteAgentManager } from '../../github/copilotRemoteAgent'; import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; import { PlainTextRenderer } from '../../github/markdownUtils'; +import { PrsTreeModel } from '../../view/prsTreeModel'; export interface CopilotRemoteAgentToolParameters { // The LLM is inconsistent in providing repo information. @@ -27,7 +28,7 @@ export interface CopilotRemoteAgentToolParameters { export class CopilotRemoteAgentTool implements vscode.LanguageModelTool { public static readonly toolId = 'github-pull-request_copilot-coding-agent'; - constructor(private manager: CopilotRemoteAgentManager, private telemetry: ITelemetry) { } + constructor(private manager: CopilotRemoteAgentManager, private telemetry: ITelemetry, private prsTreeModel: PrsTreeModel) { } async prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions): Promise { const { title, existingPullRequest } = options.input; @@ -141,12 +142,12 @@ export class CopilotRemoteAgentTool implements vscode.LanguageModelTool { + protected async getActivePullRequestWithSession(repoInfo: { repo: string; owner: string; fm: FolderRepositoryManager } | undefined): Promise { if (!repoInfo) { return; } const activePR = repoInfo.fm.activePullRequest; - if (activePR && this.manager.getStateForPR(repoInfo.owner, repoInfo.repo, activePR.number)) { + if (activePR && this.prsTreeModel.getCopilotStateForPR(repoInfo.owner, repoInfo.repo, activePR.number)) { return activePR.number; } } diff --git a/src/lm/tools/fetchIssueTool.ts b/src/lm/tools/fetchIssueTool.ts index 33317d6b03..e63b81b9b9 100644 --- a/src/lm/tools/fetchIssueTool.ts +++ b/src/lm/tools/fetchIssueTool.ts @@ -5,10 +5,10 @@ 'use strict'; import * as vscode from 'vscode'; +import { RepoToolBase } from './toolsUtils'; import { InMemFileChange } from '../../common/file'; import { isITeam } from '../../github/interface'; import { PullRequestModel } from '../../github/pullRequestModel'; -import { RepoToolBase } from './toolsUtils'; interface FetchIssueToolParameters { issueNumber?: number; diff --git a/src/lm/tools/fetchNotificationTool.ts b/src/lm/tools/fetchNotificationTool.ts index 4437298359..ef4eb2dd72 100644 --- a/src/lm/tools/fetchNotificationTool.ts +++ b/src/lm/tools/fetchNotificationTool.ts @@ -5,10 +5,10 @@ 'use strict'; import * as vscode from 'vscode'; +import { RepoToolBase } from './toolsUtils'; import { InMemFileChange } from '../../common/file'; import { PullRequestModel } from '../../github/pullRequestModel'; import { getNotificationKey } from '../../github/utils'; -import { RepoToolBase } from './toolsUtils'; interface FetchNotificationToolParameters { thread_id?: number; diff --git a/src/lm/tools/openPullRequestTool.ts b/src/lm/tools/openPullRequestTool.ts index a8a18b3802..74b5d2f0bc 100644 --- a/src/lm/tools/openPullRequestTool.ts +++ b/src/lm/tools/openPullRequestTool.ts @@ -4,10 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { PullRequestTool } from './activePullRequestTool'; import { fromPRUri, fromReviewUri, Schemes } from '../../common/uri'; import { PullRequestModel } from '../../github/pullRequestModel'; import { PullRequestOverviewPanel } from '../../github/pullRequestOverview'; -import { PullRequestTool } from './activePullRequestTool'; export class OpenPullRequestTool extends PullRequestTool { public static readonly toolId = 'github-pull-request_openPullRequest'; diff --git a/src/lm/tools/searchTools.ts b/src/lm/tools/searchTools.ts index 987a9d940b..4d5c99273a 100644 --- a/src/lm/tools/searchTools.ts +++ b/src/lm/tools/searchTools.ts @@ -5,11 +5,11 @@ 'use strict'; import * as vscode from 'vscode'; +import { concatAsyncIterable, RepoToolBase } from './toolsUtils'; import Logger from '../../common/logger'; import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; import { ILabel } from '../../github/interface'; import { escapeMarkdown } from '../../issues/util'; -import { concatAsyncIterable, RepoToolBase } from './toolsUtils'; interface ConvertToQuerySyntaxParameters { naturalLanguageString?: string; diff --git a/src/lm/tools/tools.ts b/src/lm/tools/tools.ts index 43b24d7eb4..09c833a7a9 100644 --- a/src/lm/tools/tools.ts +++ b/src/lm/tools/tools.ts @@ -20,13 +20,14 @@ import { ConvertToSearchSyntaxTool, SearchTool } from './searchTools'; import { SuggestFixTool } from './suggestFixTool'; import { IssueSummarizationTool } from './summarizeIssueTool'; import { NotificationSummarizationTool } from './summarizeNotificationsTool'; +import { PrsTreeModel } from '../../view/prsTreeModel'; -export function registerTools(context: vscode.ExtensionContext, credentialStore: CredentialStore, repositoriesManager: RepositoriesManager, chatParticipantState: ChatParticipantState, copilotRemoteAgentManager: CopilotRemoteAgentManager, telemetry: ITelemetry) { +export function registerTools(context: vscode.ExtensionContext, credentialStore: CredentialStore, repositoriesManager: RepositoriesManager, chatParticipantState: ChatParticipantState, copilotRemoteAgentManager: CopilotRemoteAgentManager, telemetry: ITelemetry, prsTreeModel: PrsTreeModel) { registerFetchingTools(context, credentialStore, repositoriesManager, chatParticipantState); registerSummarizationTools(context); registerSuggestFixTool(context, credentialStore, repositoriesManager, chatParticipantState); registerSearchTools(context, credentialStore, repositoriesManager, chatParticipantState); - registerCopilotAgentTools(context, copilotRemoteAgentManager, telemetry); + registerCopilotAgentTools(context, copilotRemoteAgentManager, telemetry, prsTreeModel); context.subscriptions.push(vscode.lm.registerTool(ActivePullRequestTool.toolId, new ActivePullRequestTool(repositoriesManager, copilotRemoteAgentManager))); context.subscriptions.push(vscode.lm.registerTool(OpenPullRequestTool.toolId, new OpenPullRequestTool(repositoriesManager, copilotRemoteAgentManager))); } @@ -45,8 +46,8 @@ function registerSuggestFixTool(context: vscode.ExtensionContext, credentialStor context.subscriptions.push(vscode.lm.registerTool(SuggestFixTool.toolId, new SuggestFixTool(credentialStore, repositoriesManager, chatParticipantState))); } -function registerCopilotAgentTools(context: vscode.ExtensionContext, copilotRemoteAgentManager: CopilotRemoteAgentManager, telemetry: ITelemetry) { - context.subscriptions.push(vscode.lm.registerTool(CopilotRemoteAgentTool.toolId, new CopilotRemoteAgentTool(copilotRemoteAgentManager, telemetry))); +function registerCopilotAgentTools(context: vscode.ExtensionContext, copilotRemoteAgentManager: CopilotRemoteAgentManager, telemetry: ITelemetry, prsTreeModel: PrsTreeModel) { + context.subscriptions.push(vscode.lm.registerTool(CopilotRemoteAgentTool.toolId, new CopilotRemoteAgentTool(copilotRemoteAgentManager, telemetry, prsTreeModel))); } function registerSearchTools(context: vscode.ExtensionContext, credentialStore: CredentialStore, repositoriesManager: RepositoriesManager, chatParticipantState: ChatParticipantState) { diff --git a/src/notifications/notificationDecorationProvider.ts b/src/notifications/notificationDecorationProvider.ts index 62e02efeb6..486a29e9ca 100644 --- a/src/notifications/notificationDecorationProvider.ts +++ b/src/notifications/notificationDecorationProvider.ts @@ -4,10 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { NotificationsManager, NotificationsSortMethod } from './notificationsManager'; import { Disposable } from '../common/lifecycle'; import { EXPERIMENTAL_NOTIFICATIONS_SCORE, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; import { fromNotificationUri, toNotificationUri } from '../common/uri'; -import { NotificationsManager, NotificationsSortMethod } from './notificationsManager'; export class NotificationsDecorationProvider extends Disposable implements vscode.FileDecorationProvider { private _readonlyOnDidChangeFileDecorations: vscode.EventEmitter = this._register(new vscode.EventEmitter()); diff --git a/src/notifications/notificationsFeatureRegistar.ts b/src/notifications/notificationsFeatureRegistar.ts index 6bc547889b..708233f040 100644 --- a/src/notifications/notificationsFeatureRegistar.ts +++ b/src/notifications/notificationsFeatureRegistar.ts @@ -4,17 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { Disposable } from '../common/lifecycle'; import { ITelemetry } from '../common/telemetry'; import { onceEvent } from '../common/utils'; import { EXTENSION_ID } from '../constants'; -import { CredentialStore } from '../github/credentials'; -import { RepositoriesManager } from '../github/repositoriesManager'; -import { chatCommand } from '../lm/utils'; import { NotificationsDecorationProvider } from './notificationDecorationProvider'; import { isNotificationTreeItem, NotificationID, NotificationTreeDataItem } from './notificationItem'; import { NotificationsManager, NotificationsSortMethod } from './notificationsManager'; -import { NotificationsProvider } from './notificationsProvider'; +import { Disposable } from '../common/lifecycle'; +import { CredentialStore } from '../github/credentials'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { chatCommand } from '../lm/utils'; export class NotificationsFeatureRegister extends Disposable { @@ -23,15 +22,9 @@ export class NotificationsFeatureRegister extends Disposable { readonly credentialStore: CredentialStore, private readonly _repositoriesManager: RepositoriesManager, private readonly _telemetry: ITelemetry, - private readonly _context: vscode.ExtensionContext + notificationsManager: NotificationsManager ) { super(); - const notificationsProvider = new NotificationsProvider(credentialStore, this._repositoriesManager); - this._register(notificationsProvider); - - const notificationsManager = new NotificationsManager(notificationsProvider, credentialStore, this._repositoriesManager, this._context); - this._register(notificationsManager); - // Decorations const decorationsProvider = new NotificationsDecorationProvider(notificationsManager); this._register(vscode.window.registerFileDecorationProvider(decorationsProvider)); diff --git a/src/notifications/notificationsManager.ts b/src/notifications/notificationsManager.ts index 121a26fc89..86fdfcaeab 100644 --- a/src/notifications/notificationsManager.ts +++ b/src/notifications/notificationsManager.ts @@ -4,8 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { isNotificationTreeItem, NotificationTreeDataItem, NotificationTreeItem } from './notificationItem'; +import { NotificationsProvider } from './notificationsProvider'; import { commands, contexts } from '../common/executeCommands'; import { Disposable } from '../common/lifecycle'; +import Logger from '../common/logger'; +import { NOTIFICATION_SETTING, NotificationVariants, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; import { EventType, TimelineEvent } from '../common/timelineEvent'; import { toNotificationUri } from '../common/uri'; import { CredentialStore } from '../github/credentials'; @@ -13,13 +17,14 @@ import { NotificationSubjectType } from '../github/interface'; import { IssueModel } from '../github/issueModel'; import { issueMarkdown } from '../github/markdownUtils'; import { PullRequestModel } from '../github/pullRequestModel'; +import { PullRequestOverviewPanel } from '../github/pullRequestOverview'; import { RepositoriesManager } from '../github/repositoriesManager'; -import { isNotificationTreeItem, NotificationTreeDataItem, NotificationTreeItem } from './notificationItem'; -import { NotificationsProvider } from './notificationsProvider'; export interface INotificationTreeItems { readonly notifications: NotificationTreeItem[]; readonly hasNextPage: boolean + readonly pollInterval: number; + readonly lastModified: string; } export enum NotificationsSortMethod { @@ -28,6 +33,8 @@ export enum NotificationsSortMethod { } export class NotificationsManager extends Disposable implements vscode.TreeDataProvider { + private static ID = 'NotificationsManager'; + private _onDidChangeTreeData: vscode.EventEmitter = this._register(new vscode.EventEmitter()); readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; @@ -40,6 +47,10 @@ export class NotificationsManager extends Disposable implements vscode.TreeDataP private _fetchNotifications: boolean = false; private _notifications = new Map(); + private _pollingDuration: number = 60; // Default polling duration + private _pollingHandler: NodeJS.Timeout | null; + private _pollingLastModified: string; + private _sortingMethod: NotificationsSortMethod = NotificationsSortMethod.Timestamp; get sortingMethod(): NotificationsSortMethod { return this._sortingMethod; } @@ -52,6 +63,17 @@ export class NotificationsManager extends Disposable implements vscode.TreeDataP super(); this._register(this._onDidChangeTreeData); this._register(this._onDidChangeNotifications); + this._startPolling(); + this._register(vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${NOTIFICATION_SETTING}`)) { + if (this.isPRNotificationsOn() && !this._pollingHandler) { + this._startPolling(); + } + } + })); + this._register(PullRequestOverviewPanel.onVisible(e => { + this.markPrNotificationsAsRead(e); + })); } //#region TreeDataProvider @@ -160,7 +182,7 @@ export class NotificationsManager extends Disposable implements vscode.TreeDataP markdown.appendMarkdown(`[${ownerName}](https://github.com/${ownerName}) \n`); markdown.appendMarkdown(`**${notification.subject.title}** \n`); markdown.appendMarkdown(`Type: ${notification.subject.type} \n`); - markdown.appendMarkdown(`Updated: ${notification.updatedAd.toLocaleString()} \n`); + markdown.appendMarkdown(`Updated: ${notification.updatedAt.toLocaleString()} \n`); markdown.appendMarkdown(`Reason: ${notification.reason} \n`); return markdown; @@ -168,13 +190,21 @@ export class NotificationsManager extends Disposable implements vscode.TreeDataP //#endregion + public get prNotifications(): PullRequestModel[] { + return Array.from(this._notifications.values()).filter(notification => notification.notification.subject.type === NotificationSubjectType.PullRequest).map(n => n.model) as PullRequestModel[]; + } + public async getNotifications(): Promise { + let pollInterval = this._pollingDuration; + let lastModified = this._pollingLastModified; if (this._fetchNotifications) { // Get raw notifications const notificationsData = await this._notificationProvider.getNotifications(this._dateTime.toISOString(), this._pageCount); if (!notificationsData) { return undefined; } + pollInterval = notificationsData.pollInterval; + lastModified = notificationsData.lastModified; // Resolve notifications const notificationTreeItems = new Map(); @@ -224,7 +254,9 @@ export class NotificationsManager extends Disposable implements vscode.TreeDataP return { notifications: this._sortNotifications(notifications), - hasNextPage: this._hasNextPage + hasNextPage: this._hasNextPage, + pollInterval, + lastModified }; } @@ -368,11 +400,86 @@ export class NotificationsManager extends Disposable implements vscode.TreeDataP private _sortNotifications(notifications: NotificationTreeItem[]): NotificationTreeItem[] { if (this._sortingMethod === NotificationsSortMethod.Timestamp) { - return notifications.sort((n1, n2) => n2.notification.updatedAd.getTime() - n1.notification.updatedAd.getTime()); + return notifications.sort((n1, n2) => n2.notification.updatedAt.getTime() - n1.notification.updatedAt.getTime()); } else if (this._sortingMethod === NotificationsSortMethod.Priority) { return notifications.sort((n1, n2) => Number(n2.priority) - Number(n1.priority)); } return notifications; } + + public isPRNotificationsOn() { + return (vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(NOTIFICATION_SETTING) === 'pullRequests'); + } + + private async _pollForNewNotifications() { + this._pageCount = 1; + this._dateTime = new Date(); + this._notifications.clear(); + this._fetchNotifications = true; + + const response = await this.getNotifications(); + if (!response) { + return; + } + + // Adapt polling interval if it has changed. + if (response.pollInterval !== this._pollingDuration) { + this._pollingDuration = response.pollInterval; + if (this._pollingHandler && this.isPRNotificationsOn()) { + Logger.appendLine('Notifications: Clearing interval', NotificationsManager.ID); + clearInterval(this._pollingHandler); + Logger.appendLine(`Notifications: Starting new polling interval with ${this._pollingDuration}`, NotificationsManager.ID); + this._startPolling(); + } + } + if (response.lastModified !== this._pollingLastModified) { + this._pollingLastModified = response.lastModified; + this._onDidChangeTreeData.fire(); + } + // this._onDidChangeNotifications.fire(oldPRNodesToUpdate); + } + + private _startPolling() { + if (!this.isPRNotificationsOn()) { + return; + } + this._pollForNewNotifications(); + this._pollingHandler = setInterval( + function (notificationProvider: NotificationsManager) { + notificationProvider._pollForNewNotifications(); + }, + this._pollingDuration * 1000, + this + ); + this._register({ dispose: () => clearInterval(this._pollingHandler!) }); + } + + private _findNotificationKeyForIssueModel(issueModel: IssueModel | PullRequestModel | { owner: string; repo: string; number: number }): string | undefined { + for (const [key, notification] of this._notifications.entries()) { + if ((issueModel instanceof IssueModel || issueModel instanceof PullRequestModel)) { + if (notification.model.equals(issueModel)) { + return key; + } + } else { + if (notification.notification.owner === issueModel.owner && + notification.notification.name === issueModel.repo && + notification.model.number === issueModel.number) { + return key; + } + } + } + return undefined; + } + + public markPrNotificationsAsRead(issueModel: IssueModel): void { + const notificationKey = this._findNotificationKeyForIssueModel(issueModel); + if (notificationKey) { + this.markAsRead({ threadId: this._notifications.get(notificationKey)!.notification.id, notificationKey }); + } + } + + public hasNotification(issueModel: IssueModel | PullRequestModel | { owner: string; repo: string; number: number }): boolean { + return this._findNotificationKeyForIssueModel(issueModel) !== undefined; + } } \ No newline at end of file diff --git a/src/notifications/notificationsProvider.ts b/src/notifications/notificationsProvider.ts index 2a8457b538..e1120bc882 100644 --- a/src/notifications/notificationsProvider.ts +++ b/src/notifications/notificationsProvider.ts @@ -4,8 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { NotificationTreeItem } from './notificationItem'; import { AuthProvider } from '../common/authentication'; import { Disposable } from '../common/lifecycle'; +import Logger from '../common/logger'; import { EXPERIMENTAL_NOTIFICATIONS_PAGE_SIZE, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; import { OctokitCommon } from '../github/common'; import { CredentialStore, GitHub } from '../github/credentials'; @@ -15,11 +17,12 @@ import { PullRequestModel } from '../github/pullRequestModel'; import { RepositoriesManager } from '../github/repositoriesManager'; import { hasEnterpriseUri, parseNotification } from '../github/utils'; import { concatAsyncIterable } from '../lm/tools/toolsUtils'; -import { NotificationTreeItem } from './notificationItem'; export interface INotifications { readonly notifications: Notification[]; readonly hasNextPage: boolean; + readonly pollInterval: number; + readonly lastModified: string; } export interface INotificationPriority { @@ -29,27 +32,25 @@ export interface INotificationPriority { } export class NotificationsProvider extends Disposable { + private static readonly ID = 'NotificationsProvider'; private _authProvider: AuthProvider | undefined; - constructor( private readonly _credentialStore: CredentialStore, private readonly _repositoriesManager: RepositoriesManager ) { super(); - if (_credentialStore.isAuthenticated(AuthProvider.githubEnterprise) && hasEnterpriseUri()) { - this._authProvider = AuthProvider.githubEnterprise; - } else if (_credentialStore.isAuthenticated(AuthProvider.github)) { - this._authProvider = AuthProvider.github; - } + const setAuthProvider = () => { + if (_credentialStore.isAuthenticated(AuthProvider.githubEnterprise) && hasEnterpriseUri()) { + this._authProvider = AuthProvider.githubEnterprise; + } else if (_credentialStore.isAuthenticated(AuthProvider.github)) { + this._authProvider = AuthProvider.github; + } + }; + setAuthProvider(); this._register( _credentialStore.onDidChangeSessions(_ => { - if (_credentialStore.isAuthenticated(AuthProvider.githubEnterprise) && hasEnterpriseUri()) { - this._authProvider = AuthProvider.githubEnterprise; - } - if (_credentialStore.isAuthenticated(AuthProvider.github)) { - this._authProvider = AuthProvider.github; - } + setAuthProvider(); }) ); } @@ -101,7 +102,9 @@ export class NotificationsProvider extends Disposable { .map((notification: OctokitCommon.Notification) => parseNotification(notification)) .filter(notification => !!notification) as Notification[]; - return { notifications, hasNextPage: headers.link?.includes(`rel="next"`) === true }; + const pollInterval = Number(headers['x-poll-interval']); + Logger.debug(`Notifications: Fetched ${notifications.length} notifications. Poll interval: ${pollInterval}`, NotificationsProvider.ID); + return { notifications, hasNextPage: headers.link?.includes(`rel="next"`) === true, pollInterval, lastModified: headers['last-modified'] ?? '' }; } async getNotificationModel(notification: Notification): Promise | undefined> { @@ -114,9 +117,22 @@ export class NotificationsProvider extends Disposable { return undefined; } const folderManager = this._repositoriesManager.getManagerForRepository(notification.owner, notification.name) ?? this._repositoriesManager.folderManagers[0]; - const model = notification.subject.type === NotificationSubjectType.Issue ? - await folderManager.resolveIssue(notification.owner, notification.name, parseInt(issueOrPrNumber), true) : - await folderManager.resolvePullRequest(notification.owner, notification.name, parseInt(issueOrPrNumber)); + let model: IssueModel | undefined; + const isIssue = notification.subject.type === NotificationSubjectType.Issue; + + model = isIssue + ? await folderManager.resolveIssue(notification.owner, notification.name, parseInt(issueOrPrNumber), true, true) + : await folderManager.resolvePullRequest(notification.owner, notification.name, parseInt(issueOrPrNumber), true); + + if (model) { + const modelCheckedForUpdates = model.lastCheckedForUpdatesAt; + const notificationUpdated = notification.updatedAt; + if (notificationUpdated.getTime() > (modelCheckedForUpdates?.getTime() ?? 0)) { + model = isIssue + ? await folderManager.resolveIssue(notification.owner, notification.name, parseInt(issueOrPrNumber), true, false) + : await folderManager.resolvePullRequest(notification.owner, notification.name, parseInt(issueOrPrNumber), false); + } + } return model; } diff --git a/src/test/common/uri.test.ts b/src/test/common/uri.test.ts new file mode 100644 index 0000000000..ce9ab11d2f --- /dev/null +++ b/src/test/common/uri.test.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { default as assert } from 'assert'; +import * as vscode from 'vscode'; +import { fromOpenOrCheckoutPullRequestWebviewUri } from '../../common/uri'; + +describe('uri', () => { + describe('fromOpenOrCheckoutPullRequestWebviewUri', () => { + it('should parse the new simplified format with uri parameter', () => { + const uri = vscode.Uri.parse('vscode://github.vscode-pull-request-github/checkout-pull-request?uri=https://github.com/microsoft/vscode-css-languageservice/pull/460'); + const result = fromOpenOrCheckoutPullRequestWebviewUri(uri); + + assert.strictEqual(result?.owner, 'microsoft'); + assert.strictEqual(result?.repo, 'vscode-css-languageservice'); + assert.strictEqual(result?.pullRequestNumber, 460); + }); + + it('should parse the new simplified format with http (not https)', () => { + const uri = vscode.Uri.parse('vscode://github.vscode-pull-request-github/checkout-pull-request?uri=http://github.com/owner/repo/pull/123'); + const result = fromOpenOrCheckoutPullRequestWebviewUri(uri); + + assert.strictEqual(result?.owner, 'owner'); + assert.strictEqual(result?.repo, 'repo'); + assert.strictEqual(result?.pullRequestNumber, 123); + }); + + it('should parse the old JSON format for backward compatibility', () => { + const uri = vscode.Uri.parse('vscode://github.vscode-pull-request-github/checkout-pull-request?%7B%22owner%22%3A%22microsoft%22%2C%22repo%22%3A%22vscode-css-languageservice%22%2C%22pullRequestNumber%22%3A460%7D'); + const result = fromOpenOrCheckoutPullRequestWebviewUri(uri); + + assert.strictEqual(result?.owner, 'microsoft'); + assert.strictEqual(result?.repo, 'vscode-css-languageservice'); + assert.strictEqual(result?.pullRequestNumber, 460); + }); + + it('should work for open-pull-request-webview path', () => { + const uri = vscode.Uri.parse('vscode://github.vscode-pull-request-github/open-pull-request-webview?uri=https://github.com/test/example/pull/789'); + const result = fromOpenOrCheckoutPullRequestWebviewUri(uri); + + assert.strictEqual(result?.owner, 'test'); + assert.strictEqual(result?.repo, 'example'); + assert.strictEqual(result?.pullRequestNumber, 789); + }); + + it('should return undefined for invalid authority', () => { + const uri = vscode.Uri.parse('vscode://invalid-authority/checkout-pull-request?uri=https://github.com/owner/repo/pull/1'); + const result = fromOpenOrCheckoutPullRequestWebviewUri(uri); + + assert.strictEqual(result, undefined); + }); + + it('should return undefined for invalid path', () => { + const uri = vscode.Uri.parse('vscode://github.vscode-pull-request-github/invalid-path?uri=https://github.com/owner/repo/pull/1'); + const result = fromOpenOrCheckoutPullRequestWebviewUri(uri); + + assert.strictEqual(result, undefined); + }); + + it('should return undefined for invalid GitHub URL format', () => { + const uri = vscode.Uri.parse('vscode://github.vscode-pull-request-github/checkout-pull-request?uri=https://example.com/owner/repo/pull/1'); + const result = fromOpenOrCheckoutPullRequestWebviewUri(uri); + + assert.strictEqual(result, undefined); + }); + + it('should return undefined for non-numeric pull request number', () => { + const uri = vscode.Uri.parse('vscode://github.vscode-pull-request-github/checkout-pull-request?uri=https://github.com/owner/repo/pull/abc'); + const result = fromOpenOrCheckoutPullRequestWebviewUri(uri); + + assert.strictEqual(result, undefined); + }); + + it('should handle repos with dots and dashes', () => { + const uri = vscode.Uri.parse('vscode://github.vscode-pull-request-github/checkout-pull-request?uri=https://github.com/my-org/my.awesome-repo/pull/42'); + const result = fromOpenOrCheckoutPullRequestWebviewUri(uri); + + assert.strictEqual(result?.owner, 'my-org'); + assert.strictEqual(result?.repo, 'my.awesome-repo'); + assert.strictEqual(result?.pullRequestNumber, 42); + }); + + it('should handle repos with underscores', () => { + const uri = vscode.Uri.parse('vscode://github.vscode-pull-request-github/checkout-pull-request?uri=https://github.com/owner/repo_name/pull/1'); + const result = fromOpenOrCheckoutPullRequestWebviewUri(uri); + + assert.strictEqual(result?.owner, 'owner'); + assert.strictEqual(result?.repo, 'repo_name'); + assert.strictEqual(result?.pullRequestNumber, 1); + }); + + it('should validate owner and repo names', () => { + // Invalid owner (empty) + const uri1 = vscode.Uri.parse('vscode://github.vscode-pull-request-github/checkout-pull-request?uri=https://github.com//repo/pull/1'); + const result1 = fromOpenOrCheckoutPullRequestWebviewUri(uri1); + assert.strictEqual(result1, undefined); + }); + + it('should reject URLs with extra path segments after PR number', () => { + // URL with /files suffix should be rejected + const uri1 = vscode.Uri.parse('vscode://github.vscode-pull-request-github/checkout-pull-request?uri=https://github.com/owner/repo/pull/123/files'); + const result1 = fromOpenOrCheckoutPullRequestWebviewUri(uri1); + assert.strictEqual(result1, undefined); + + // URL with /commits suffix should be rejected + const uri2 = vscode.Uri.parse('vscode://github.vscode-pull-request-github/checkout-pull-request?uri=https://github.com/owner/repo/pull/456/commits'); + const result2 = fromOpenOrCheckoutPullRequestWebviewUri(uri2); + assert.strictEqual(result2, undefined); + }); + }); +}); diff --git a/src/test/github/copilotPrWatcher.test.ts b/src/test/github/copilotPrWatcher.test.ts new file mode 100644 index 0000000000..5df018ce63 --- /dev/null +++ b/src/test/github/copilotPrWatcher.test.ts @@ -0,0 +1,140 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { default as assert } from 'assert'; +import { CopilotStateModel } from '../../github/copilotPrWatcher'; +import { CopilotPRStatus } from '../../common/copilot'; +import { PullRequestModel } from '../../github/pullRequestModel'; + +describe('Copilot PR watcher', () => { + + describe('CopilotStateModel', () => { + + const createPullRequest = (owner: string, repo: string, number: number): PullRequestModel => { + return { + number, + remote: { owner, repositoryName: repo }, + author: { login: 'copilot' } + } as unknown as PullRequestModel; + }; + + it('stores statuses and emits notifications after initialization', () => { + const model = new CopilotStateModel(); + let changeEvents = 0; + const notifications: PullRequestModel[][] = []; + model.onDidChangeCopilotStates(() => changeEvents++); + model.onDidChangeCopilotNotifications(items => notifications.push(items)); + + const pr = createPullRequest('octo', 'repo', 1); + model.set([{ item: pr, status: CopilotPRStatus.Started }]); + + assert.strictEqual(model.get('octo', 'repo', 1), CopilotPRStatus.Started); + assert.strictEqual(changeEvents, 1); + assert.strictEqual(notifications.length, 0); + assert.strictEqual(model.notifications.size, 0); + + model.set([{ item: pr, status: CopilotPRStatus.Started }]); + assert.strictEqual(changeEvents, 1); + + model.setInitialized(); + const updated = createPullRequest('octo', 'repo', 1); + model.set([{ item: updated, status: CopilotPRStatus.Completed }]); + + assert.strictEqual(model.get('octo', 'repo', 1), CopilotPRStatus.Completed); + assert.strictEqual(changeEvents, 2); + assert.strictEqual(notifications.length, 1); + assert.deepStrictEqual(notifications[0], [updated]); + assert.ok(model.notifications.has('octo/repo#1')); + }); + + it('deletes keys and clears related notifications', () => { + const model = new CopilotStateModel(); + let changeEvents = 0; + const notifications: PullRequestModel[][] = []; + model.onDidChangeCopilotStates(() => changeEvents++); + model.onDidChangeCopilotNotifications(items => notifications.push(items)); + + model.setInitialized(); + const pr = createPullRequest('octo', 'repo', 42); + model.set([{ item: pr, status: CopilotPRStatus.Started }]); + + assert.strictEqual(model.notifications.size, 1); + assert.strictEqual(changeEvents, 1); + + model.deleteKey('octo/repo#42'); + assert.strictEqual(model.get('octo', 'repo', 42), CopilotPRStatus.None); + assert.strictEqual(changeEvents, 2); + assert.strictEqual(model.notifications.size, 0); + assert.strictEqual(notifications.length, 2); + assert.deepStrictEqual(notifications[1], [pr]); + assert.deepStrictEqual(model.keys(), []); + }); + + it('clears individual notifications and reports changes', () => { + const model = new CopilotStateModel(); + const notifications: PullRequestModel[][] = []; + model.onDidChangeCopilotNotifications(items => notifications.push(items)); + + model.setInitialized(); + const pr = createPullRequest('octo', 'repo', 5); + model.set([{ item: pr, status: CopilotPRStatus.Started }]); + assert.strictEqual(model.notifications.size, 1); + assert.strictEqual(notifications.length, 1); + + model.clearNotification('octo', 'repo', 5); + assert.strictEqual(model.notifications.size, 0); + assert.strictEqual(notifications.length, 2); + assert.deepStrictEqual(notifications[1], [pr]); + + model.clearNotification('octo', 'repo', 5); + assert.strictEqual(notifications.length, 2); + }); + + it('supports clearing notifications by repository or entirely', () => { + const model = new CopilotStateModel(); + const notifications: PullRequestModel[][] = []; + model.onDidChangeCopilotNotifications(items => notifications.push(items)); + + assert.strictEqual(model.isInitialized, false); + model.setInitialized(); + assert.strictEqual(model.isInitialized, true); + + const prOne = createPullRequest('octo', 'repo', 1); + const prTwo = createPullRequest('octo', 'repo', 2); + const prThree = createPullRequest('other', 'repo', 3); + model.set([ + { item: prOne, status: CopilotPRStatus.Started }, + { item: prTwo, status: CopilotPRStatus.Failed }, + { item: prThree, status: CopilotPRStatus.Completed } + ]); + + assert.strictEqual(model.notifications.size, 3); + assert.strictEqual(notifications.length, 1); + assert.deepStrictEqual(notifications[0], [prOne, prTwo, prThree]); + assert.strictEqual(model.getNotificationsCount('octo', 'repo'), 2); + assert.deepStrictEqual(model.keys().sort(), ['octo/repo#1', 'octo/repo#2', 'other/repo#3']); + + model.clearAllNotifications('octo', 'repo'); + assert.strictEqual(model.notifications.size, 1); + assert.strictEqual(model.getNotificationsCount('octo', 'repo'), 0); + assert.strictEqual(notifications.length, 2); + assert.deepStrictEqual(notifications[1], [prOne, prTwo]); + + model.clearAllNotifications(); + assert.strictEqual(model.notifications.size, 0); + assert.strictEqual(notifications.length, 3); + assert.deepStrictEqual(notifications[2], [prThree]); + + const counts = model.getCounts('octo', 'repo'); + assert.deepStrictEqual(counts, { total: 3, inProgress: 1, error: 1 }); + + const allStates = model.all; + assert.strictEqual(allStates.length, 3); + assert.deepStrictEqual(allStates.map(v => v.status).sort(), [CopilotPRStatus.Started, CopilotPRStatus.Completed, CopilotPRStatus.Failed]); + }); + }); + + +}); \ No newline at end of file diff --git a/src/test/github/copilotRemoteAgent.test.ts b/src/test/github/copilotRemoteAgent.test.ts index 6a667bb2c6..f0618b8bb1 100644 --- a/src/test/github/copilotRemoteAgent.test.ts +++ b/src/test/github/copilotRemoteAgent.test.ts @@ -6,21 +6,22 @@ import { default as assert } from 'assert'; import { SinonSandbox, createSandbox } from 'sinon'; import * as vscode from 'vscode'; -import { CopilotRemoteAgentManager } from '../../github/copilotRemoteAgent'; +import { CopilotRemoteAgentManager, SessionIdForPr } from '../../github/copilotRemoteAgent'; import { MockCommandRegistry } from '../mocks/mockCommandRegistry'; import { MockTelemetry } from '../mocks/mockTelemetry'; import { CredentialStore } from '../../github/credentials'; import { RepositoriesManager } from '../../github/repositoriesManager'; import { MockExtensionContext } from '../mocks/mockExtensionContext'; -import { Resource } from '../../common/resources'; import { PullRequestModel } from '../../github/pullRequestModel'; import { MockGitHubRepository } from '../mocks/mockGitHubRepository'; import { GitHubRemote } from '../../common/remote'; import { Protocol } from '../../common/protocol'; import { GitHubServerType } from '../../common/authentication'; import { ReposManagerState } from '../../github/folderRepositoryManager'; -import { CopilotPRStatus } from '../../common/copilot'; import { GitApiImpl } from '../../api/api1'; +import { MockPrsTreeModel } from '../mocks/mockPRsTreeModel'; +import { PrsTreeModel } from '../../view/prsTreeModel'; +import { COPILOT_SWE_AGENT } from '../../common/copilot'; const telemetry = new MockTelemetry(); const protocol = new Protocol('https://github.com/github/test.git'); @@ -34,6 +35,7 @@ describe('CopilotRemoteAgentManager', function () { let context: MockExtensionContext; let mockRepo: MockGitHubRepository; let gitAPIImp: GitApiImpl; + let mockPrsTreeModel: MockPrsTreeModel; beforeEach(function () { sinon = createSandbox(); @@ -66,8 +68,8 @@ describe('CopilotRemoteAgentManager', function () { gitAPIImp = new GitApiImpl(reposManager); - manager = new CopilotRemoteAgentManager(credentialStore, reposManager, telemetry, context, gitAPIImp); - Resource.initialize(context); + mockPrsTreeModel = new MockPrsTreeModel(); + manager = new CopilotRemoteAgentManager(credentialStore, reposManager, telemetry, context, gitAPIImp, mockPrsTreeModel as unknown as PrsTreeModel); }); afterEach(function () { @@ -245,29 +247,6 @@ describe('CopilotRemoteAgentManager', function () { }); }); - describe('hasNotification()', function () { - it('should return false when no notification exists', function () { - const result = manager.hasNotification('owner', 'repo', 123); - assert.strictEqual(result, false); - }); - }); - - describe('getStateForPR()', function () { - it('should return default state for unknown PR', function () { - const result = manager.getStateForPR('owner', 'repo', 123); - // Should return a valid CopilotPRStatus - assert(Object.values(CopilotPRStatus).includes(result)); - }); - }); - - describe('notificationsCount', function () { - it('should return non-negative number', function () { - const count = manager.notificationsCount; - assert.strictEqual(typeof count, 'number'); - assert(count >= 0); - }); - }); - describe('provideChatSessions()', function () { it('should return empty array when copilot API is not available', async function () { const token = new vscode.CancellationTokenSource().token; @@ -293,7 +272,7 @@ describe('CopilotRemoteAgentManager', function () { it('should return empty session when copilot API is not available', async function () { const token = new vscode.CancellationTokenSource().token; - const result = await manager.provideChatSessionContent('123', token); + const result = await manager.provideChatSessionContent(SessionIdForPr.getResource(123, 0), token); assert.strictEqual(Array.isArray(result.history), true); assert.strictEqual(result.history.length, 0); @@ -304,7 +283,7 @@ describe('CopilotRemoteAgentManager', function () { const tokenSource = new vscode.CancellationTokenSource(); tokenSource.cancel(); - const result = await manager.provideChatSessionContent('123', tokenSource.token); + const result = await manager.provideChatSessionContent(SessionIdForPr.getResource(123, 0), tokenSource.token); assert.strictEqual(Array.isArray(result.history), true); assert.strictEqual(result.history.length, 0); @@ -313,7 +292,7 @@ describe('CopilotRemoteAgentManager', function () { it('should return empty session for invalid PR number', async function () { const token = new vscode.CancellationTokenSource().token; - const result = await manager.provideChatSessionContent('invalid', token); + const result = await manager.provideChatSessionContent(vscode.Uri.from({ scheme: COPILOT_SWE_AGENT, path: '/invalid' }), token); assert.strictEqual(Array.isArray(result.history), true); assert.strictEqual(result.history.length, 0); diff --git a/src/test/github/pullRequestGitHelper.test.ts b/src/test/github/pullRequestGitHelper.test.ts index 8986e97786..590b35f0aa 100644 --- a/src/test/github/pullRequestGitHelper.test.ts +++ b/src/test/github/pullRequestGitHelper.test.ts @@ -128,7 +128,7 @@ describe('PullRequestGitHelper', function () { // Verify that the original local branch is preserved with its commit const originalBranch = await repository.getBranch('my-branch'); assert.strictEqual(originalBranch.commit, 'local-commit-hash', 'Original branch should be preserved'); - + // Verify that a unique branch was created and checked out const uniqueBranch = await repository.getBranch('pr/me/100'); assert.strictEqual(uniqueBranch.commit, 'remote-commit-hash', 'Unique branch should have remote commit'); diff --git a/src/test/github/pullRequestModel.test.ts b/src/test/github/pullRequestModel.test.ts index 7a976af689..5497cde4ab 100644 --- a/src/test/github/pullRequestModel.test.ts +++ b/src/test/github/pullRequestModel.test.ts @@ -16,7 +16,6 @@ import { PullRequestBuilder } from '../builders/rest/pullRequestBuilder'; import { MockTelemetry } from '../mocks/mockTelemetry'; import { MockGitHubRepository } from '../mocks/mockGitHubRepository'; import { NetworkStatus } from 'apollo-client'; -import { Resource } from '../../common/resources'; import { MockExtensionContext } from '../mocks/mockExtensionContext'; import { GitHubServerType } from '../../common/authentication'; import { mergeQuerySchemaWithShared } from '../../github/common'; @@ -66,7 +65,6 @@ describe('PullRequestModel', function () { context = new MockExtensionContext(); credentials = new CredentialStore(telemetry, context); repo = new MockGitHubRepository(remote, credentials, telemetry, sinon); - Resource.initialize(context); }); afterEach(function () { diff --git a/src/test/index.ts b/src/test/index.ts index 05ef9f9859..6ded19c0c2 100644 --- a/src/test/index.ts +++ b/src/test/index.ts @@ -37,7 +37,19 @@ async function runAllExtensionTests(testsRoot: string, clb: (error: Error | null const importAll = (r: __WebpackModuleApi.RequireContext) => r.keys().forEach(r); importAll(require.context('./', true, /\.test$/)); } catch (e) { - console.log('Error loading tests:', e); + // Fallback if 'require.context' is not available (e.g., in non-webpack environments) + const files = glob.sync('**/*.test.js', { + cwd: testsRoot, + absolute: true, + // Browser/webview tests are loaded via the separate browser runner + ignore: ['browser/**'] + }); + if (!files.length) { + console.log('Fallback test discovery found no test files. Original error:', e); + } + for (const f of files) { + mocha.addFile(f); + } } if (process.env.TEST_JUNIT_XML_PATH) { diff --git a/src/test/issues/issueTodoProvider.test.ts b/src/test/issues/issueTodoProvider.test.ts index 62ab38c018..1551294b82 100644 --- a/src/test/issues/issueTodoProvider.test.ts +++ b/src/test/issues/issueTodoProvider.test.ts @@ -6,16 +6,40 @@ import { default as assert } from 'assert'; import * as vscode from 'vscode'; import { IssueTodoProvider } from '../../issues/issueTodoProvider'; +import { CopilotRemoteAgentManager } from '../../github/copilotRemoteAgent'; +import { CODING_AGENT, CREATE_ISSUE_TRIGGERS, ISSUES_SETTINGS_NAMESPACE, SHOW_CODE_LENS } from '../../common/settingKeys'; +import * as issueUtil from '../../issues/util'; + +const mockCopilotManager: Partial = { + isAvailable: () => Promise.resolve(true) +} describe('IssueTodoProvider', function () { + // Mock isComment + // We don't have a real 'vscode.TextDocument' in these tests, which + // causes 'vscode.languages.getTokenInformationAtPosition' to throw. + const originalIsComment = issueUtil.isComment; + before(() => { + (issueUtil as any).isComment = async (document: vscode.TextDocument, position: vscode.Position) => { + try { + const lineText = document.lineAt(position.line).text; + return lineText.trim().startsWith('//'); + } catch { + return false; + } + }; + }); + after(() => { + (issueUtil as any).isComment = originalIsComment; + }); + it('should provide both actions when CopilotRemoteAgentManager is available', async function () { const mockContext = { subscriptions: [] } as any as vscode.ExtensionContext; - const mockCopilotManager = {} as any; // Mock CopilotRemoteAgentManager - const provider = new IssueTodoProvider(mockContext, mockCopilotManager); + const provider = new IssueTodoProvider(mockContext, mockCopilotManager as CopilotRemoteAgentManager); // Create a mock document with TODO comment const document = { @@ -34,12 +58,129 @@ describe('IssueTodoProvider', function () { // Find the actions const createIssueAction = actions.find(a => a.title === 'Create GitHub Issue'); - const startAgentAction = actions.find(a => a.title === 'Delegate to coding agent'); + const startAgentAction = actions.find(a => a.title === 'Delegate to agent'); assert.ok(createIssueAction, 'Should have Create GitHub Issue action'); - assert.ok(startAgentAction, 'Should have Delegate to coding agent action'); + assert.ok(startAgentAction, 'Should have Delegate to agent action'); assert.strictEqual(createIssueAction?.command?.command, 'issue.createIssueFromSelection'); assert.strictEqual(startAgentAction?.command?.command, 'issue.startCodingAgentFromTodo'); }); + + it('should provide code lenses for TODO comments', async function () { + const mockContext = { + subscriptions: [] + } as any as vscode.ExtensionContext; + + const provider = new IssueTodoProvider(mockContext, mockCopilotManager as CopilotRemoteAgentManager); + + // Create a mock document with TODO comment + const document = { + lineAt: (line: number) => ({ + text: line === 1 ? ' // TODO: Fix this' : 'function test() {}' + }), + lineCount: 4 + } as vscode.TextDocument; + + const originalGetConfiguration = vscode.workspace.getConfiguration; + vscode.workspace.getConfiguration = (section?: string) => { + if (section === ISSUES_SETTINGS_NAMESPACE) { + return { + get: (key: string, defaultValue?: any) => { + if (key === CREATE_ISSUE_TRIGGERS) { + return ['TODO', 'todo', 'BUG', 'FIXME', 'ISSUE', 'HACK']; + } + return defaultValue; + } + } as any; + } else if (section === CODING_AGENT) { + return { + get: (key: string, defaultValue?: any) => { + if (key === SHOW_CODE_LENS) { + return true; + } + return defaultValue; + } + } as any; + } + return originalGetConfiguration(section); + }; + + try { + // Update triggers to ensure the expression is set + (provider as any).updateTriggers(); + + const codeLenses = await provider.provideCodeLenses(document, new vscode.CancellationTokenSource().token); + + assert.strictEqual(codeLenses.length, 1); + + // Verify the code lenses + const startAgentLens = codeLenses.find(cl => cl.command?.title === 'Delegate to agent'); + + assert.ok(startAgentLens, 'Should have Delegate to agent CodeLens'); + + assert.strictEqual(startAgentLens?.command?.command, 'issue.startCodingAgentFromTodo'); + + // Verify the range points to the TODO text + assert.strictEqual(startAgentLens?.range.start.line, 1); + } finally { + // Restore original configuration + vscode.workspace.getConfiguration = originalGetConfiguration; + } + }); + + it('should not provide code lenses when codeLens setting is disabled', async function () { + const mockContext = { + subscriptions: [] + } as any as vscode.ExtensionContext; + + const provider = new IssueTodoProvider(mockContext, mockCopilotManager as CopilotRemoteAgentManager); + + // Create a mock document with TODO comment + const document = { + lineAt: (lineNo: number) => ({ + text: lineNo === 1 ? ' // TODO: Fix this' : 'function test() {}', + firstNonWhitespaceCharacterIndex: lineNo === 1 ? 2 : 0, + } as vscode.TextLine), + lineCount: 4, + languageId: 'javascript' + } as vscode.TextDocument; + + const originalGetConfiguration = vscode.workspace.getConfiguration; + vscode.workspace.getConfiguration = (section?: string) => { + if (section === ISSUES_SETTINGS_NAMESPACE) { + return { + get: (key: string, defaultValue?: any) => { + if (key === CREATE_ISSUE_TRIGGERS) { + return ['TODO', 'todo', 'BUG', 'FIXME', 'ISSUE', 'HACK']; + } + return defaultValue; + } + } as any; + } else if (section === CODING_AGENT) { + return { + get: (key: string, defaultValue?: any) => { + if (key === SHOW_CODE_LENS) { + return false; + } + return defaultValue; + } + } as any; + } + return originalGetConfiguration(section); + }; + + try { + // Update triggers to ensure the expression is set + (provider as any).updateTriggers(); + + const codeLenses = await provider.provideCodeLenses(document, new vscode.CancellationTokenSource().token); + + // Should return empty array when CodeLens is disabled + assert.strictEqual(codeLenses.length, 0, 'Should not provide code lenses when setting is disabled'); + } finally { + // Restore original configuration + vscode.workspace.getConfiguration = originalGetConfiguration; + } + }); }); \ No newline at end of file diff --git a/src/test/issues/stateManager.test.ts b/src/test/issues/stateManager.test.ts index d41ec9a8dc..481f2687e8 100644 --- a/src/test/issues/stateManager.test.ts +++ b/src/test/issues/stateManager.test.ts @@ -11,12 +11,12 @@ import { USE_BRANCH_FOR_ISSUES, ISSUES_SETTINGS_NAMESPACE } from '../../common/s // Mock classes for testing class MockFolderRepositoryManager { - constructor(public repository: { rootUri: vscode.Uri }) {} + constructor(public repository: { rootUri: vscode.Uri }) { } } class MockSingleRepoState { currentIssue?: MockCurrentIssue; - constructor(public folderManager: MockFolderRepositoryManager) {} + constructor(public folderManager: MockFolderRepositoryManager) { } } class MockCurrentIssue { diff --git a/src/test/lm/tools/copilotRemoteAgentTool.test.ts b/src/test/lm/tools/copilotRemoteAgentTool.test.ts index a2f4a5e93e..26440130c8 100644 --- a/src/test/lm/tools/copilotRemoteAgentTool.test.ts +++ b/src/test/lm/tools/copilotRemoteAgentTool.test.ts @@ -10,12 +10,22 @@ import { CopilotRemoteAgentTool, CopilotRemoteAgentToolParameters } from '../../ import { CopilotRemoteAgentManager } from '../../../github/copilotRemoteAgent'; import { MockTelemetry } from '../../mocks/mockTelemetry'; import { RemoteAgentResult } from '../../../github/common'; +import { MockPrsTreeModel } from '../../mocks/mockPRsTreeModel'; +import { PrsTreeModel } from '../../../view/prsTreeModel'; +import { FolderRepositoryManager } from '../../../github/folderRepositoryManager'; + +class TestCopilotRemoteAgentTool extends CopilotRemoteAgentTool { + publicGetActivePullRequestWithSession(repoInfo?: { repo: string; owner: string; fm: FolderRepositoryManager }): Promise { + return this.getActivePullRequestWithSession(repoInfo); + } +} describe('CopilotRemoteAgentTool', function () { let sinon: SinonSandbox; - let tool: CopilotRemoteAgentTool; + let tool: TestCopilotRemoteAgentTool; let mockManager: sinon.SinonStubbedInstance; let telemetry: MockTelemetry; + let mockPrsTreeModel: PrsTreeModel; beforeEach(function () { sinon = createSandbox(); @@ -30,8 +40,9 @@ describe('CopilotRemoteAgentTool', function () { Extension: 2 }; } + mockPrsTreeModel = new MockPrsTreeModel() as unknown as PrsTreeModel; - tool = new CopilotRemoteAgentTool(mockManager as any, telemetry); + tool = new TestCopilotRemoteAgentTool(mockManager as any, telemetry, mockPrsTreeModel); }); afterEach(function () { @@ -129,10 +140,9 @@ describe('CopilotRemoteAgentTool', function () { repository: {} as any, ghRepository: {} as any, fm: { - activePullRequest: { number: 456 } as any + activePullRequest: { number: 123 } as any } as any }); - mockManager.getStateForPR.returns({} as any); // Non-falsy state // Mock the config getter to avoid access issues Object.defineProperty(mockManager, 'autoCommitAndPushEnabled', { @@ -146,7 +156,7 @@ describe('CopilotRemoteAgentTool', function () { // Handle both string and MarkdownString types const message = result.confirmationMessages?.message; const messageText = typeof message === 'string' ? message : message?.value || ''; - assert(messageText.includes('existing pull request **#456**')); + assert(messageText.includes('existing pull request **#123**')); }); }); @@ -326,9 +336,9 @@ describe('CopilotRemoteAgentTool', function () { }); }); - describe('getActivePullRequestWithSession()', function () { + describe('publicGetActivePullRequestWithSession()', function () { it('should return undefined when no repo info is provided', async function () { - const result = await (tool as any).getActivePullRequestWithSession(undefined); + const result = await tool.publicGetActivePullRequestWithSession(undefined); assert.strictEqual(result, undefined); }); @@ -338,10 +348,10 @@ describe('CopilotRemoteAgentTool', function () { repo: 'test-repo', fm: { activePullRequest: undefined - } + } as FolderRepositoryManager }; - const result = await (tool as any).getActivePullRequestWithSession(repoInfo); + const result = await tool.publicGetActivePullRequestWithSession(repoInfo); assert.strictEqual(result, undefined); }); @@ -350,13 +360,11 @@ describe('CopilotRemoteAgentTool', function () { owner: 'test', repo: 'test-repo', fm: { - activePullRequest: { number: 123 } - } + activePullRequest: { number: 456 } + } as FolderRepositoryManager }; - mockManager.getStateForPR.returns(undefined as any); - - const result = await (tool as any).getActivePullRequestWithSession(repoInfo); + const result = await tool.publicGetActivePullRequestWithSession(repoInfo); assert.strictEqual(result, undefined); }); @@ -366,12 +374,10 @@ describe('CopilotRemoteAgentTool', function () { repo: 'test-repo', fm: { activePullRequest: { number: 123 } - } + } as FolderRepositoryManager }; - mockManager.getStateForPR.returns({} as any); // Non-falsy state - - const result = await (tool as any).getActivePullRequestWithSession(repoInfo); + const result = await tool.publicGetActivePullRequestWithSession(repoInfo); assert.strictEqual(result, 123); }); }); diff --git a/src/test/mocks/mockNotificationManager.ts b/src/test/mocks/mockNotificationManager.ts new file mode 100644 index 0000000000..8977615583 --- /dev/null +++ b/src/test/mocks/mockNotificationManager.ts @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { Event, EventEmitter } from 'vscode'; +import { PullRequestModel } from '../../github/pullRequestModel'; +import { NotificationTreeDataItem, NotificationTreeItem } from '../../notifications/notificationItem'; + +export class MockNotificationManager { + onDidChangeTreeData: Event = new EventEmitter().event; + onDidChangeNotifications: Event = new EventEmitter().event; + hasNotification(_issueModel: PullRequestModel): boolean { return false; } + markPrNotificationsAsRead(_issueModel: PullRequestModel): void { /* no-op */ } + dispose(): void { /* no-op */ } +} diff --git a/src/test/mocks/mockPRsTreeModel.ts b/src/test/mocks/mockPRsTreeModel.ts new file mode 100644 index 0000000000..26420a9a87 --- /dev/null +++ b/src/test/mocks/mockPRsTreeModel.ts @@ -0,0 +1,108 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event, Disposable, EventEmitter } from "vscode"; +import { CopilotPRStatus } from "../../common/copilot"; +import { CodingAgentPRAndStatus, CopilotStateModel } from "../../github/copilotPrWatcher"; +import { FolderRepositoryManager, ItemsResponseResult } from "../../github/folderRepositoryManager"; +import { PullRequestChangeEvent } from "../../github/githubRepository"; +import { PullRequestModel } from "../../github/pullRequestModel"; +import { PRStatusChange, PrsTreeModel } from "../../view/prsTreeModel"; +import { TreeNode } from "../../view/treeNodes/treeNode"; + +export class MockPrsTreeModel implements Partial { + onDidChangeCopilotStates: Event = new EventEmitter().event; + onDidChangeCopilotNotifications: Event = new EventEmitter().event; + clearCopilotCaches(): false | undefined { + throw new Error("Method not implemented."); + } + async refreshCopilotStateChanges(clearCache?: boolean): Promise { + return false; + } + getCopilotPullRequests(clearCache?: boolean): Promise { + throw new Error("Method not implemented."); + } + public onDidChangePrStatus: Event = new EventEmitter().event; + public onDidChangeData: Event = new EventEmitter().event; + public onLoaded: Event; + public copilotStateModel: CopilotStateModel; + public updateExpandedQueries(element: TreeNode, isExpanded: boolean): void { + throw new Error("Method not implemented."); + } + get expandedQueries(): Set | undefined { + return new Set(['All Open']); + } + get hasLoaded(): boolean { + return true; + } + set hasLoaded(value: boolean) { + throw new Error("Method not implemented."); + } + public cachedPRStatus(identifier: string): PRStatusChange | undefined { + throw new Error("Method not implemented."); + } + public forceClearCache(): void { + throw new Error("Method not implemented."); + } + public hasPullRequest(pr: PullRequestModel): boolean { + throw new Error("Method not implemented."); + } + public clearCache(silent?: boolean): void { + throw new Error("Method not implemented."); + } + async getLocalPullRequests(folderRepoManager: FolderRepositoryManager, update?: boolean): Promise> { + return { + hasMorePages: false, + items: this._localPullRequests, + hasUnsearchedRepositories: false + }; + } + getPullRequestsForQuery(folderRepoManager: FolderRepositoryManager, fetchNextPage: boolean, query: string): Promise> { + throw new Error("Method not implemented."); + } + getAllPullRequests(folderRepoManager: FolderRepositoryManager, fetchNextPage: boolean, update?: boolean): Promise> { + throw new Error("Method not implemented."); + } + getCopilotNotificationsCount(owner: string, repo: string): number { + return 0; + } + get copilotNotificationsCount(): number { + return 0; + } + clearAllCopilotNotifications(owner?: string, repo?: string): void { + throw new Error("Method not implemented."); + } + clearCopilotNotification(owner: string, repo: string, pullRequestNumber: number): void { + throw new Error("Method not implemented."); + } + hasCopilotNotification(owner: string, repo: string, pullRequestNumber?: number): boolean { + throw new Error("Method not implemented."); + } + getCopilotStateForPR(owner: string, repo: string, prNumber: number): CopilotPRStatus { + if (prNumber === 123) { + return CopilotPRStatus.Started; + } else { + return CopilotPRStatus.None; + } + } + getCopilotCounts(owner: string, repo: string): { total: number; inProgress: number; error: number; } { + throw new Error("Method not implemented."); + } + dispose(): void { + throw new Error("Method not implemented."); + } + protected _isDisposed: boolean; + protected _register(value: T): T { + throw new Error("Method not implemented."); + } + protected get isDisposed(): boolean { + throw new Error("Method not implemented."); + } + + private _localPullRequests: PullRequestModel[] = []; + addLocalPullRequest(pr: PullRequestModel): void { + this._localPullRequests.push(pr); + } +} \ No newline at end of file diff --git a/src/test/view/prsTree.test.ts b/src/test/view/prsTree.test.ts index 1f72287dd1..fc98daf733 100644 --- a/src/test/view/prsTree.test.ts +++ b/src/test/view/prsTree.test.ts @@ -9,9 +9,11 @@ import { default as assert } from 'assert'; import { Octokit } from '@octokit/rest'; import { PullRequestsTreeDataProvider } from '../../view/prsTreeDataProvider'; +import { NotificationsManager } from '../../notifications/notificationsManager'; import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; import { MockTelemetry } from '../mocks/mockTelemetry'; +import { MockNotificationManager } from '../mocks/mockNotificationManager'; import { MockExtensionContext } from '../mocks/mockExtensionContext'; import { MockRepository } from '../mocks/mockRepository'; import { MockCommandRegistry } from '../mocks/mockCommandRegistry'; @@ -22,7 +24,6 @@ import { GitHubRemote, Remote } from '../../common/remote'; import { Protocol } from '../../common/protocol'; import { CredentialStore, GitHub } from '../../github/credentials'; import { parseGraphQLPullRequest } from '../../github/utils'; -import { Resource } from '../../common/resources'; import { GitApiImpl } from '../../api/api1'; import { RepositoriesManager } from '../../github/repositoriesManager'; import { LoggingOctokit, RateLogger } from '../../github/loggingOctokit'; @@ -33,6 +34,8 @@ import { asPromise } from '../../common/utils'; import { CreatePullRequestHelper } from '../../view/createPullRequestHelper'; import { CopilotRemoteAgentManager } from '../../github/copilotRemoteAgent'; import { MockThemeWatcher } from '../mocks/mockThemeWatcher'; +import { MockPrsTreeModel } from '../mocks/mockPRsTreeModel'; +import { PrsTreeModel } from '../../view/prsTreeModel'; describe('GitHub Pull Requests view', function () { let sinon: SinonSandbox; @@ -45,6 +48,8 @@ describe('GitHub Pull Requests view', function () { let copilotManager: CopilotRemoteAgentManager; let mockThemeWatcher: MockThemeWatcher; let gitAPI: GitApiImpl; + let mockNotificationsManager: MockNotificationManager; + let prsTreeModel: PrsTreeModel; beforeEach(function () { sinon = createSandbox(); @@ -58,10 +63,12 @@ describe('GitHub Pull Requests view', function () { credentialStore, telemetry, ); + prsTreeModel = new PrsTreeModel(telemetry, reposManager, context); credentialStore = new CredentialStore(telemetry, context); gitAPI = new GitApiImpl(reposManager); - copilotManager = new CopilotRemoteAgentManager(credentialStore, reposManager, telemetry, context, gitAPI); - provider = new PullRequestsTreeDataProvider(telemetry, context, reposManager, copilotManager); + copilotManager = new CopilotRemoteAgentManager(credentialStore, reposManager, telemetry, context, gitAPI, prsTreeModel); + provider = new PullRequestsTreeDataProvider(prsTreeModel, telemetry, context, reposManager, copilotManager); + mockNotificationsManager = new MockNotificationManager(); createPrHelper = new CreatePullRequestHelper(); // For tree view unit tests, we don't test the authentication flow, so `showSignInNotification` returns @@ -79,8 +86,6 @@ describe('GitHub Pull Requests view', function () { return github; }); - - Resource.initialize(context); }); afterEach(function () { @@ -109,7 +114,7 @@ describe('GitHub Pull Requests view', function () { const repository = new MockRepository(); repository.addRemote('origin', 'git@github.com:aaa/bbb'); reposManager.insertFolderManager(new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(reposManager), credentialStore, createPrHelper, mockThemeWatcher)); - provider.initialize([], credentialStore); + provider.initialize([], mockNotificationsManager as NotificationsManager); const rootNodes = await provider.getChildren(); assert.strictEqual(rootNodes.length, 0); @@ -123,7 +128,7 @@ describe('GitHub Pull Requests view', function () { reposManager.insertFolderManager(folderManager); sinon.stub(credentialStore, 'isAuthenticated').returns(true); await reposManager.folderManagers[0].updateRepositories(); - provider.initialize([], credentialStore); + provider.initialize([], mockNotificationsManager as NotificationsManager); const rootNodes = await provider.getChildren(); @@ -137,6 +142,40 @@ describe('GitHub Pull Requests view', function () { ); }); + it('refreshes tree when GitHub repositories are discovered in existing folder manager', async function () { + const repository = new MockRepository(); + repository.addRemote('origin', 'git@github.com:aaa/bbb'); + const folderManager = new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(reposManager), credentialStore, createPrHelper, mockThemeWatcher); + sinon.stub(folderManager, 'getPullRequestDefaults').returns(Promise.resolve({ owner: 'aaa', repo: 'bbb', base: 'main' })); + reposManager.insertFolderManager(folderManager); + provider.initialize([], mockNotificationsManager as NotificationsManager); + + // Initially no children because no GitHub repositories are loaded yet + let rootNodes = await provider.getChildren(); + assert.strictEqual(rootNodes.length, 0); + + // Listen to the prsTreeModel's onDidChangeData event which is what actually drives the tree refresh + const onDidChangeDataSpy = sinon.spy(); + provider.prsTreeModel.onDidChangeData(onDidChangeDataSpy); + + // Simulate GitHub repositories being discovered (as happens when remotes load after activation) + sinon.stub(credentialStore, 'isAuthenticated').returns(true); + await folderManager.updateRepositories(); + + // Verify that the tree model's data change event was triggered + assert(onDidChangeDataSpy.calledWith(folderManager), + 'Tree model should fire data change event with the folder manager when GitHub repositories are discovered'); + + // Verify tree now has content + rootNodes = await provider.getChildren(); + const treeItems = await Promise.all(rootNodes.map(node => node.getTreeItem())); + assert.deepStrictEqual( + treeItems.map(n => n.label), + ['Copilot on My Behalf', 'Local Pull Request Branches', 'Waiting For My Review', 'Created By Me', 'All Open'], + 'Tree should display categories after GitHub repositories are discovered', + ); + }); + describe('Local Pull Request Branches', function () { it('creates a node for each local pull request', async function () { const url = 'git@github.com:aaa/bbb'; @@ -202,7 +241,7 @@ describe('GitHub Pull Requests view', function () { return Promise.resolve(users.map(user => user.avatarUrl ? vscode.Uri.parse(user.avatarUrl) : undefined)); }); await manager.updateRepositories(); - provider.initialize([], credentialStore); + provider.initialize([], mockNotificationsManager as NotificationsManager); manager.activePullRequest = pullRequest1; const rootNodes = await provider.getChildren(); @@ -217,14 +256,18 @@ describe('GitHub Pull Requests view', function () { assert.strictEqual(localChildren.length, 2); const [localItem0, localItem1] = await Promise.all(localChildren.map(node => node.getTreeItem())); - assert.strictEqual(localItem0.label, 'zero'); + const label0 = (localItem0.label as vscode.TreeItemLabel2).label; + assert.ok(label0 instanceof vscode.MarkdownString); + assert.equal(label0.value, 'zero'); assert.strictEqual(localItem0.tooltip, undefined); assert.strictEqual(localItem0.description, 'by @me'); assert.strictEqual(localItem0.collapsibleState, vscode.TreeItemCollapsibleState.Collapsed); assert.strictEqual(localItem0.contextValue, 'pullrequest:local:nonactive:hasHeadRef'); assert.deepStrictEqual(localItem0.iconPath!.toString(), 'https://avatars.com/me.jpg'); - assert.strictEqual(localItem1.label, '✓ one'); + const label1 = (localItem1.label as vscode.TreeItemLabel2).label; + assert.ok(label1 instanceof vscode.MarkdownString); + assert.equal(label1.value, '$(check) one'); assert.strictEqual(localItem1.tooltip, undefined); assert.strictEqual(localItem1.description, 'by @you'); assert.strictEqual(localItem1.collapsibleState, vscode.TreeItemCollapsibleState.Collapsed); diff --git a/src/test/view/reviewCommentController.test.ts b/src/test/view/reviewCommentController.test.ts index c9d9a652ca..ba473d2bd8 100644 --- a/src/test/view/reviewCommentController.test.ts +++ b/src/test/view/reviewCommentController.test.ts @@ -30,7 +30,6 @@ import { ReviewManager, ShowPullRequest } from '../../view/reviewManager'; import { PullRequestChangesTreeDataProvider } from '../../view/prChangesTreeDataProvider'; import { MockExtensionContext } from '../mocks/mockExtensionContext'; import { ReviewModel } from '../../view/reviewModel'; -import { Resource } from '../../common/resources'; import { RepositoriesManager } from '../../github/repositoriesManager'; import { GitFileChangeModel } from '../../view/fileChangeModel'; import { WebviewViewCoordinator } from '../../view/webviewViewCoordinator'; @@ -40,6 +39,9 @@ import { mergeQuerySchemaWithShared } from '../../github/common'; import { AccountType } from '../../github/interface'; import { CopilotRemoteAgentManager } from '../../github/copilotRemoteAgent'; import { MockThemeWatcher } from '../mocks/mockThemeWatcher'; +import { asPromise } from '../../common/utils'; +import { PrsTreeModel } from '../../view/prsTreeModel'; +import { MockPrsTreeModel } from '../mocks/mockPRsTreeModel'; const schema = mergeQuerySchemaWithShared(require('../../github/queries.gql'), require('../../github/queriesShared.gql')) as any; const protocol = new Protocol('https://github.com/github/test.git'); @@ -65,6 +67,7 @@ describe('ReviewCommentController', function () { let gitApiImpl: GitApiImpl; let copilotManager: CopilotRemoteAgentManager; let mockThemeWatcher: MockThemeWatcher; + let mockPrsTreeModel: PrsTreeModel; beforeEach(async function () { sinon = createSandbox(); @@ -79,11 +82,11 @@ describe('ReviewCommentController', function () { repository.addRemote('origin', 'git@github.com:aaa/bbb'); reposManager = new RepositoriesManager(credentialStore, telemetry); gitApiImpl = new GitApiImpl(reposManager); - copilotManager = new CopilotRemoteAgentManager(credentialStore, reposManager, telemetry, context, gitApiImpl); - provider = new PullRequestsTreeDataProvider(telemetry, context, reposManager, copilotManager); + mockPrsTreeModel = new MockPrsTreeModel() as unknown as PrsTreeModel; + copilotManager = new CopilotRemoteAgentManager(credentialStore, reposManager, telemetry, context, gitApiImpl, mockPrsTreeModel); + provider = new PullRequestsTreeDataProvider(mockPrsTreeModel, telemetry, context, reposManager, copilotManager); const activePrViewCoordinator = new WebviewViewCoordinator(context); const createPrHelper = new CreatePullRequestHelper(); - Resource.initialize(context); manager = new FolderRepositoryManager(0, context, repository, telemetry, gitApiImpl, credentialStore, createPrHelper, mockThemeWatcher); reposManager.insertFolderManager(manager); const tree = new PullRequestChangesTreeDataProvider(gitApiImpl, reposManager); @@ -328,8 +331,9 @@ describe('ReviewCommentController', function () { } ) + const newReviewThreadPromise = asPromise(activePullRequest.onDidChangeReviewThreads); await reviewCommentController.createOrReplyComment(thread, 'hello world', false); - + await newReviewThreadPromise; assert.strictEqual(thread.comments.length, 1); assert.strictEqual(thread.comments[0].parent, thread); diff --git a/src/uriHandler.ts b/src/uriHandler.ts index b1b4a8839b..817f6e17ee 100644 --- a/src/uriHandler.ts +++ b/src/uriHandler.ts @@ -4,16 +4,108 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { GitApiImpl } from './api/api1'; +import { commands } from './common/executeCommands'; +import Logger from './common/logger'; import { ITelemetry } from './common/telemetry'; -import { fromOpenIssueWebviewUri, fromOpenPullRequestWebviewUri, UriHandlerPaths } from './common/uri'; +import { fromOpenIssueWebviewUri, fromOpenOrCheckoutPullRequestWebviewUri, UriHandlerPaths } from './common/uri'; +import { FolderRepositoryManager } from './github/folderRepositoryManager'; import { IssueOverviewPanel } from './github/issueOverview'; +import { PullRequestModel } from './github/pullRequestModel'; import { PullRequestOverviewPanel } from './github/pullRequestOverview'; import { RepositoriesManager } from './github/repositoriesManager'; +import { ReviewsManager } from './view/reviewsManager'; + +export const PENDING_CHECKOUT_PULL_REQUEST_KEY = 'pendingCheckoutPullRequest'; + +interface PendingCheckoutPayload { + owner: string; + repo: string; + pullRequestNumber: number; + timestamp: number; // epoch millis when the pending checkout was stored +} + +function withCheckoutProgress(owner: string, repo: string, prNumber: number, task: (progress: vscode.Progress<{ message?: string; increment?: number }>, token: vscode.CancellationToken) => Promise): Promise { + return vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: vscode.l10n.t('Checking out pull request #{0} from {1}/{2}', prNumber, owner, repo), + cancellable: true + }, async (progress, token) => { + if (token.isCancellationRequested) { + return Promise.resolve(undefined as unknown as T); + } + return task(progress, token); + }) as Promise; +} + +async function performPullRequestCheckout(reviewsManager: ReviewsManager, folderManager: FolderRepositoryManager, owner: string, repo: string, prNumber: number): Promise { + try { + let pullRequest: PullRequestModel | undefined; + await withCheckoutProgress(owner, repo, prNumber, async (progress, _token) => { + progress.report({ message: vscode.l10n.t('Resolving pull request') }); + pullRequest = await folderManager.resolvePullRequest(owner, repo, prNumber); + }); + if (!pullRequest) { + vscode.window.showErrorMessage(vscode.l10n.t('Pull request #{0} not found in {1}/{2}.', prNumber, owner, repo)); + Logger.warn(`Pull request #${prNumber} not found for checkout.`, UriHandler.ID); + return; + } + + const proceed = await showCheckoutPrompt(owner, repo, prNumber); + if (!proceed) { + return; + } + + await reviewsManager.switchToPr(folderManager, pullRequest, undefined, false); + } catch (e) { + Logger.error(`Error during pull request checkout: ${e instanceof Error ? e.message : String(e)}`, UriHandler.ID); + } +} + +export async function resumePendingCheckout(reviewsManager: ReviewsManager, context: vscode.ExtensionContext, reposManager: RepositoriesManager): Promise { + const pending = context.globalState.get(PENDING_CHECKOUT_PULL_REQUEST_KEY); + if (!pending) { + return; + } + // Validate freshness (5 minutes) + const maxAgeMs = 5 * 60 * 1000; + if (!pending.timestamp || Date.now() - pending.timestamp > maxAgeMs) { + await context.globalState.update(PENDING_CHECKOUT_PULL_REQUEST_KEY, undefined); + Logger.debug('Stale pending checkout entry cleared (older than 5 minutes).', UriHandler.ID); + return; + } + const attempt = async () => { + const folderManager = reposManager.getManagerForRepository(pending.owner, pending.repo); + if (!folderManager) { + return false; + } + await performPullRequestCheckout(reviewsManager, folderManager, pending.owner, pending.repo, pending.pullRequestNumber); + await context.globalState.update(PENDING_CHECKOUT_PULL_REQUEST_KEY, undefined); + return true; + }; + if (!(await attempt())) { + const disposable = reposManager.onDidLoadAnyRepositories(async () => { + if (await attempt()) { + disposable.dispose(); + } + }); + } +} + +export async function showCheckoutPrompt(owner: string, repo: string, prNumber: number): Promise { + const message = vscode.l10n.t('Checkout pull request #{0} from {1}/{2}?', prNumber, owner, repo); + const confirm = vscode.l10n.t('Checkout'); + const selection = await vscode.window.showInformationMessage(message, { modal: true }, confirm); + return selection === confirm; +} export class UriHandler implements vscode.UriHandler { + public static readonly ID = 'UriHandler'; constructor(private readonly _reposManagers: RepositoriesManager, + private readonly _reviewsManagers: ReviewsManager, private readonly _telemetry: ITelemetry, - private readonly _context: vscode.ExtensionContext + private readonly _context: vscode.ExtensionContext, + private readonly _git: GitApiImpl ) { } async handleUri(uri: vscode.Uri): Promise { @@ -22,6 +114,10 @@ export class UriHandler implements vscode.UriHandler { return this._openIssueWebview(uri); case UriHandlerPaths.OpenPullRequestWebview: return this._openPullRequestWebview(uri); + case UriHandlerPaths.CheckoutPullRequest: + // Simplified format example: vscode-insiders://github.vscode-pull-request-github/checkout-pull-request?uri=https://github.com/microsoft/vscode-css-languageservice/pull/460 + // Legacy format example: vscode-insiders://github.vscode-pull-request-github/checkout-pull-request?%7B%22owner%22%3A%22alexr00%22%2C%22repo%22%3A%22playground%22%2C%22pullRequestNumber%22%3A714%7D + return this._checkoutPullRequest(uri); } } @@ -39,7 +135,7 @@ export class UriHandler implements vscode.UriHandler { } private async _openPullRequestWebview(uri: vscode.Uri): Promise { - const params = fromOpenPullRequestWebviewUri(uri); + const params = fromOpenOrCheckoutPullRequestWebviewUri(uri); if (!params) { return; } @@ -51,4 +147,66 @@ export class UriHandler implements vscode.UriHandler { return PullRequestOverviewPanel.createOrShow(this._telemetry, this._context.extensionUri, folderManager, pullRequest); } -} \ No newline at end of file + private async _savePendingCheckoutAndOpenFolder(params: { owner: string; repo: string; pullRequestNumber: number }, folderUri: vscode.Uri): Promise { + const payload: PendingCheckoutPayload = { ...params, timestamp: Date.now() }; + await this._context.globalState.update(PENDING_CHECKOUT_PULL_REQUEST_KEY, payload); + const isEmpty = vscode.workspace.workspaceFolders === undefined || vscode.workspace.workspaceFolders.length === 0; + await commands.openFolder(folderUri, { forceNewWindow: !isEmpty, forceReuseWindow: isEmpty }); + } + + private async _checkoutPullRequest(uri: vscode.Uri): Promise { + const params = fromOpenOrCheckoutPullRequestWebviewUri(uri); + if (!params) { + return; + } + const folderManager = this._reposManagers.getManagerForRepository(params.owner, params.repo); + if (folderManager) { + return performPullRequestCheckout(this._reviewsManagers, folderManager, params.owner, params.repo, params.pullRequestNumber); + } + // Folder not found; request workspace open then resume later. + await withCheckoutProgress(params.owner, params.repo, params.pullRequestNumber, async (progress, token) => { + if (token.isCancellationRequested) { + return; + } + try { + progress.report({ message: vscode.l10n.t('Locating workspace') }); + const remoteUri = vscode.Uri.parse(`https://github.com/${params.owner}/${params.repo}`); + const workspaces = await this._git.getRepositoryWorkspace(remoteUri); + if (token.isCancellationRequested) { + return; + } + if (workspaces && workspaces.length) { + Logger.appendLine(`Found workspaces for ${remoteUri.toString()}: ${workspaces.map(w => w.toString()).join(', ')}`, UriHandler.ID); + progress.report({ message: vscode.l10n.t('Opening workspace') }); + await this._savePendingCheckoutAndOpenFolder(params, workspaces[0]); + } else { + this._showCloneOffer(remoteUri, params); + } + } catch (e) { + Logger.error(`Failed attempting workspace open for checkout PR: ${e instanceof Error ? e.message : String(e)}`, UriHandler.ID); + } + }); + } + + private async _showCloneOffer(remoteUri: vscode.Uri, params: { owner: string; repo: string; pullRequestNumber: number }): Promise { + const cloneLabel = vscode.l10n.t('Clone Repository'); + const choice = await vscode.window.showErrorMessage( + vscode.l10n.t('Could not find a folder for repository {0}/{1}. Please clone or open the repository manually.', params.owner, params.repo), + cloneLabel + ); + Logger.warn(`No repository workspace found for ${remoteUri.toString()}`, UriHandler.ID); + if (choice === cloneLabel) { + try { + const clonedWorkspaceUri = await this._git.clone(remoteUri, { postCloneAction: 'none' }); + if (clonedWorkspaceUri) { + await this._savePendingCheckoutAndOpenFolder(params, clonedWorkspaceUri); + } else { + Logger.warn(`Clone API returned null for ${remoteUri.toString()}`, UriHandler.ID); + } + } catch (err) { + Logger.error(`Failed to clone repository via API: ${err instanceof Error ? err.message : String(err)}`, UriHandler.ID); + } + } + } + +} diff --git a/src/view/commentDecorationProvider.ts b/src/view/commentDecorationProvider.ts index 84cb4b15a9..86a14aa2fd 100644 --- a/src/view/commentDecorationProvider.ts +++ b/src/view/commentDecorationProvider.ts @@ -4,11 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { TreeDecorationProvider } from './treeDecorationProviders'; import { fromFileChangeNodeUri, Schemes } from '../common/uri'; import { FolderRepositoryManager } from '../github/folderRepositoryManager'; import { PullRequestModel } from '../github/pullRequestModel'; import { RepositoriesManager } from '../github/repositoriesManager'; -import { TreeDecorationProvider } from './treeDecorationProviders'; export class CommentDecorationProvider extends TreeDecorationProvider { diff --git a/src/view/compareChangesTreeDataProvider.ts b/src/view/compareChangesTreeDataProvider.ts index 611716ba65..edae2c9721 100644 --- a/src/view/compareChangesTreeDataProvider.ts +++ b/src/view/compareChangesTreeDataProvider.ts @@ -5,6 +5,7 @@ import * as pathLib from 'path'; import * as vscode from 'vscode'; +import { CreatePullRequestDataModel } from './createPullRequestDataModel'; import { Change, Commit } from '../api/api'; import { Status } from '../api/api1'; import { getGitChangeType } from '../common/diffHunk'; @@ -15,7 +16,6 @@ import { Schemes } from '../common/uri'; import { dateFromNow } from '../common/utils'; import { OctokitCommon } from '../github/common'; import { FolderRepositoryManager } from '../github/folderRepositoryManager'; -import { CreatePullRequestDataModel } from './createPullRequestDataModel'; import { GitHubFileChangeNode } from './treeNodes/fileChangeNode'; import { BaseTreeNode, TreeNode, TreeNodeParent } from './treeNodes/treeNode'; diff --git a/src/view/createPullRequestDataModel.ts b/src/view/createPullRequestDataModel.ts index 48e5f40557..050f83a2ae 100644 --- a/src/view/createPullRequestDataModel.ts +++ b/src/view/createPullRequestDataModel.ts @@ -4,13 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { ChangesContentProvider, GitContentProvider, GitHubContentProvider } from './gitHubContentProvider'; import { Change, Commit } from '../api/api'; import { Disposable } from '../common/lifecycle'; import Logger from '../common/logger'; import { OctokitCommon } from '../github/common'; import { FolderRepositoryManager } from '../github/folderRepositoryManager'; import { GitHubRepository } from '../github/githubRepository'; -import { ChangesContentProvider, GitContentProvider, GitHubContentProvider } from './gitHubContentProvider'; export interface CreateModelChangeEvent { baseOwner?: string; diff --git a/src/view/createPullRequestHelper.ts b/src/view/createPullRequestHelper.ts index 16cc77feae..63339033e8 100644 --- a/src/view/createPullRequestHelper.ts +++ b/src/view/createPullRequestHelper.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { CompareChanges } from './compareChangesTreeDataProvider'; +import { CreatePullRequestDataModel } from './createPullRequestDataModel'; import { Repository } from '../api/api'; import { commands } from '../common/executeCommands'; import { addDisposable, Disposable, disposeAll } from '../common/lifecycle'; @@ -12,8 +14,6 @@ import { BaseCreatePullRequestViewProvider, BasePullRequestDataModel, CreatePull import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; import { PullRequestModel } from '../github/pullRequestModel'; import { RevertPullRequestViewProvider } from '../github/revertPRViewProvider'; -import { CompareChanges } from './compareChangesTreeDataProvider'; -import { CreatePullRequestDataModel } from './createPullRequestDataModel'; export class CreatePullRequestHelper extends Disposable { private _currentDisposables: vscode.Disposable[] = []; diff --git a/src/view/emojiCompletionProvider.ts b/src/view/emojiCompletionProvider.ts new file mode 100644 index 0000000000..ffb75844cb --- /dev/null +++ b/src/view/emojiCompletionProvider.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { ensureEmojis } from '../common/emoji'; +import { Schemes } from '../common/uri'; + +export class EmojiCompletionProvider implements vscode.CompletionItemProvider { + private _emojiCompletions: vscode.CompletionItem[] = []; + + constructor(private _context: vscode.ExtensionContext) { + void this.buildEmojiCompletions(); + } + + private async buildEmojiCompletions(): Promise { + const emojis = await ensureEmojis(this._context); + + for (const [name, emoji] of Object.entries(emojis)) { + const completionItem = new vscode.CompletionItem({ label: emoji, description: `:${name}:` }, vscode.CompletionItemKind.Text); + completionItem.filterText = `:${name}:`; + completionItem.sortText = name; + this._emojiCompletions.push(completionItem); + } + } + + provideCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + _token: vscode.CancellationToken, + context: vscode.CompletionContext + ): vscode.ProviderResult { + // Only provide completions for comment documents + if (document.uri.scheme !== Schemes.Comment) { + return []; + } + + const word = document.getWordRangeAtPosition(position, /:([-+_a-z0-9]+:?)?/i); + if (!word) { + return []; + } + + // If invoked by trigger charcter, ignore if this is the start of an emoji (single ':') and there is no preceding space + if (context.triggerKind === vscode.CompletionTriggerKind.TriggerCharacter) { + if (word.end.character - word.start.character === 1 && word.start.character > 0) { + const charBefore = document.getText(new vscode.Range(word.start.translate(0, -1), word.start)); + if (!/\s/.test(charBefore)) { + return []; + } + } + } + + // Update the range on cached items directly + for (const item of this._emojiCompletions) { + item.range = word; + } + + return new vscode.CompletionList(this._emojiCompletions, false); + } +} diff --git a/src/view/fileChangeModel.ts b/src/view/fileChangeModel.ts index eb831e52f8..06661245b1 100644 --- a/src/view/fileChangeModel.ts +++ b/src/view/fileChangeModel.ts @@ -116,7 +116,7 @@ export class GitFileChangeModel extends FileChangeModel { } } - private _show: Promise + private _show: Promise; async showBase(): Promise { if (!this._show && this.change.status !== GitChangeType.ADD) { const commit = ((this.change instanceof InMemFileChange || this.change instanceof SlimFileChange) ? this.change.baseCommit : this.sha!); diff --git a/src/view/fileTypeDecorationProvider.ts b/src/view/fileTypeDecorationProvider.ts index 25e8d8e2a3..3d6c3283f5 100644 --- a/src/view/fileTypeDecorationProvider.ts +++ b/src/view/fileTypeDecorationProvider.ts @@ -5,11 +5,11 @@ import * as path from 'path'; import * as vscode from 'vscode'; +import { TreeDecorationProvider } from './treeDecorationProviders'; import { GitChangeType } from '../common/file'; import { FileChangeNodeUriParams, fromFileChangeNodeUri, fromPRUri, PRUriParams } from '../common/uri'; import { FolderRepositoryManager } from '../github/folderRepositoryManager'; import { PullRequestModel } from '../github/pullRequestModel'; -import { TreeDecorationProvider } from './treeDecorationProviders'; export class FileTypeDecorationProvider extends TreeDecorationProvider { constructor() { diff --git a/src/view/gitContentProvider.ts b/src/view/gitContentProvider.ts index bcfd5dac71..1ed6b4d027 100644 --- a/src/view/gitContentProvider.ts +++ b/src/view/gitContentProvider.ts @@ -6,15 +6,15 @@ import * as pathLib from 'path'; import * as vscode from 'vscode'; +import { GitFileChangeModel } from './fileChangeModel'; +import { RepositoryFileSystemProvider } from './repositoryFileSystemProvider'; +import { ReviewManager } from './reviewManager'; import { Repository } from '../api/api'; import { GitApiImpl } from '../api/api1'; import Logger from '../common/logger'; import { fromReviewUri } from '../common/uri'; import { CredentialStore } from '../github/credentials'; import { getRepositoryForFile } from '../github/utils'; -import { GitFileChangeModel } from './fileChangeModel'; -import { RepositoryFileSystemProvider } from './repositoryFileSystemProvider'; -import { ReviewManager } from './reviewManager'; import { GitFileChangeNode, RemoteFileChangeNode } from './treeNodes/fileChangeNode'; export class GitContentFileSystemProvider extends RepositoryFileSystemProvider { diff --git a/src/view/githubFileContentProvider.ts b/src/view/githubFileContentProvider.ts index f6e8e80c71..059f113840 100644 --- a/src/view/githubFileContentProvider.ts +++ b/src/view/githubFileContentProvider.ts @@ -4,11 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { RepositoryFileSystemProvider } from './repositoryFileSystemProvider'; import { GitApiImpl } from '../api/api1'; import { fromGitHubCommitUri } from '../common/uri'; import { CredentialStore } from '../github/credentials'; import { RepositoriesManager } from '../github/repositoriesManager'; -import { RepositoryFileSystemProvider } from './repositoryFileSystemProvider'; export class GitHubCommitFileSystemProvider extends RepositoryFileSystemProvider { constructor(private readonly repos: RepositoriesManager, gitAPI: GitApiImpl, credentialStore: CredentialStore) { diff --git a/src/view/inMemPRContentProvider.ts b/src/view/inMemPRContentProvider.ts index 80403cced0..ddd7e92a69 100644 --- a/src/view/inMemPRContentProvider.ts +++ b/src/view/inMemPRContentProvider.ts @@ -5,6 +5,8 @@ 'use strict'; import * as vscode from 'vscode'; +import { FileChangeModel, InMemFileChangeModel, RemoteFileChangeModel } from './fileChangeModel'; +import { RepositoryFileSystemProvider } from './repositoryFileSystemProvider'; import { GitApiImpl } from '../api/api1'; import { DiffChangeType, getModifiedContentFromDiffHunk } from '../common/diffHunk'; import { GitChangeType, InMemFileChange, SlimFileChange } from '../common/file'; @@ -14,8 +16,6 @@ import { CredentialStore } from '../github/credentials'; import { FolderRepositoryManager, ReposManagerState } from '../github/folderRepositoryManager'; import { IResolvedPullRequestModel, PullRequestModel } from '../github/pullRequestModel'; import { RepositoriesManager } from '../github/repositoriesManager'; -import { FileChangeModel, InMemFileChangeModel, RemoteFileChangeModel } from './fileChangeModel'; -import { RepositoryFileSystemProvider } from './repositoryFileSystemProvider'; export class InMemPRFileSystemProvider extends RepositoryFileSystemProvider { private _prFileChangeContentProviders: { [key: number]: (uri: vscode.Uri) => Promise } = {}; diff --git a/src/view/prChangesTreeDataProvider.ts b/src/view/prChangesTreeDataProvider.ts index ca5d645d6f..1a6890114b 100644 --- a/src/view/prChangesTreeDataProvider.ts +++ b/src/view/prChangesTreeDataProvider.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { ProgressHelper } from './progress'; +import { ReviewModel } from './reviewModel'; import { GitApiImpl } from '../api/api1'; import { commands, contexts } from '../common/executeCommands'; import { Disposable } from '../common/lifecycle'; @@ -13,8 +15,6 @@ import { isDescendant } from '../common/utils'; import { FolderRepositoryManager } from '../github/folderRepositoryManager'; import { PullRequestModel } from '../github/pullRequestModel'; import { RepositoriesManager } from '../github/repositoriesManager'; -import { ProgressHelper } from './progress'; -import { ReviewModel } from './reviewModel'; import { GitFileChangeNode } from './treeNodes/fileChangeNode'; import { RepositoryChangesNode } from './treeNodes/repositoryChangesNode'; import { BaseTreeNode, TreeNode } from './treeNodes/treeNode'; diff --git a/src/view/prNotificationDecorationProvider.ts b/src/view/prNotificationDecorationProvider.ts deleted file mode 100644 index 08119395d3..0000000000 --- a/src/view/prNotificationDecorationProvider.ts +++ /dev/null @@ -1,47 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { Disposable } from '../common/lifecycle'; -import { fromPRNodeUri } from '../common/uri'; -import { NotificationProvider } from '../github/notifications'; - -export class PRNotificationDecorationProvider extends Disposable implements vscode.FileDecorationProvider { - private _onDidChangeFileDecorations: vscode.EventEmitter = new vscode.EventEmitter< - vscode.Uri | vscode.Uri[] - >(); - onDidChangeFileDecorations: vscode.Event = this._onDidChangeFileDecorations.event; - - - constructor(private readonly _notificationProvider: NotificationProvider) { - super(); - this._register(vscode.window.registerFileDecorationProvider(this)); - this._register( - this._notificationProvider.onDidChangeNotifications(PRNodeUris => this._onDidChangeFileDecorations.fire(PRNodeUris)) - ); - } - - provideFileDecoration( - uri: vscode.Uri, - _token: vscode.CancellationToken, - ): vscode.ProviderResult { - if (!uri.query) { - return; - } - - const prNodeParams = fromPRNodeUri(uri); - - if (prNodeParams && this._notificationProvider.hasNotification(prNodeParams.prIdentifier)) { - return { - propagate: false, - color: new vscode.ThemeColor('pullRequests.notification'), - badge: '●', - tooltip: 'unread notification' - }; - } - - return undefined; - } -} diff --git a/src/view/prStatusDecorationProvider.ts b/src/view/prStatusDecorationProvider.ts index a8024389dd..e9709303f7 100644 --- a/src/view/prStatusDecorationProvider.ts +++ b/src/view/prStatusDecorationProvider.ts @@ -4,12 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { PrsTreeModel } from './prsTreeModel'; import { Disposable } from '../common/lifecycle'; import { Protocol } from '../common/protocol'; +import { NOTIFICATION_SETTING, NotificationVariants, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; +import { EventType } from '../common/timelineEvent'; import { createPRNodeUri, fromPRNodeUri, fromQueryUri, parsePRNodeIdentifier, PRNodeUriParams, Schemes, toQueryUri } from '../common/uri'; import { CopilotRemoteAgentManager } from '../github/copilotRemoteAgent'; import { getStatusDecoration } from '../github/markdownUtils'; -import { PrsTreeModel } from './prsTreeModel'; +import { PullRequestModel } from '../github/pullRequestModel'; +import { NotificationsManager } from '../notifications/notificationsManager'; export class PRStatusDecorationProvider extends Disposable implements vscode.FileDecorationProvider { @@ -18,7 +22,7 @@ export class PRStatusDecorationProvider extends Disposable implements vscode.Fil >(); onDidChangeFileDecorations: vscode.Event = this._onDidChangeFileDecorations.event; - constructor(private readonly _prsTreeModel: PrsTreeModel, private readonly _copilotManager: CopilotRemoteAgentManager) { + constructor(private readonly _prsTreeModel: PrsTreeModel, private readonly _copilotManager: CopilotRemoteAgentManager, private readonly _notificationProvider: NotificationsManager) { super(); this._register(vscode.window.registerFileDecorationProvider(this)); this._register( @@ -36,10 +40,41 @@ export class PRStatusDecorationProvider extends Disposable implements vscode.Fil repoItems.add(queryUri.toString()); uris.push(queryUri); } - uris.push(createPRNodeUri(item)); + uris.push(createPRNodeUri(item, true)); } this._onDidChangeFileDecorations.fire(uris); })); + + const addUriForRefresh = (uris: vscode.Uri[], pullRequest: unknown) => { + if (pullRequest instanceof PullRequestModel) { + uris.push(createPRNodeUri(pullRequest)); + if (pullRequest.timelineEvents.some(t => t.event === EventType.CopilotStarted)) { + // The pr nodes in the Copilot category have a different uri so we need to refresh those too + uris.push(createPRNodeUri(pullRequest, true)); + } + } + }; + + this._register( + this._notificationProvider.onDidChangeNotifications(notifications => { + let uris: vscode.Uri[] = []; + for (const notification of notifications) { + addUriForRefresh(uris, notification.model); + } + this._onDidChangeFileDecorations.fire(uris); + }) + ); + + // if the notification setting changes, refresh the decorations for the nodes with notifications + this._register(vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${NOTIFICATION_SETTING}`)) { + const uris: vscode.Uri[] = []; + for (const pr of this._notificationProvider.getAllNotifications()) { + addUriForRefresh(uris, pr.model); + } + this._onDidChangeFileDecorations.fire(uris); + } + })); } provideFileDecoration( @@ -63,6 +98,11 @@ export class PRStatusDecorationProvider extends Disposable implements vscode.Fil return copilotDecoration; } + const notificationDecoration = this._getNotificationDecoration(params); + if (notificationDecoration) { + return notificationDecoration; + } + const status = this._prsTreeModel.cachedPRStatus(params.prIdentifier); if (!status) { return; @@ -73,14 +113,17 @@ export class PRStatusDecorationProvider extends Disposable implements vscode.Fil } private _getCopilotDecoration(params: PRNodeUriParams): vscode.FileDecoration | undefined { + if (!params.showCopilot) { + return; + } const idParts = parsePRNodeIdentifier(params.prIdentifier); if (!idParts) { return; } const protocol = new Protocol(idParts.remote); - if (this._copilotManager.hasNotification(protocol.owner, protocol.repositoryName, idParts.prNumber)) { + if (this._prsTreeModel.hasCopilotNotification(protocol.owner, protocol.repositoryName, idParts.prNumber)) { return { - badge: new vscode.ThemeIcon('copilot') as any, + badge: new vscode.ThemeIcon('copilot') as unknown as string, color: new vscode.ThemeColor('pullRequests.notification') }; } @@ -91,15 +134,38 @@ export class PRStatusDecorationProvider extends Disposable implements vscode.Fil if (!params?.isCopilot || !params.remote) { return; } - const counts = this._copilotManager.getNotificationsCount(params.remote.owner, params.remote.repositoryName); + const counts = this._prsTreeModel.getCopilotNotificationsCount(params.remote.owner, params.remote.repositoryName); if (counts === 0) { return; } return { tooltip: vscode.l10n.t('Coding agent has made changes'), - badge: new vscode.ThemeIcon('copilot') as any, + badge: new vscode.ThemeIcon('copilot') as unknown as string, color: new vscode.ThemeColor('pullRequests.notification'), }; } + + private _getNotificationDecoration(params: PRNodeUriParams): vscode.FileDecoration | undefined { + if (!this.notificationSettingValue()) { + return; + } + const idParts = parsePRNodeIdentifier(params.prIdentifier); + if (!idParts) { + return; + } + const protocol = new Protocol(idParts.remote); + if (this._notificationProvider.hasNotification({ owner: protocol.owner, repo: protocol.repositoryName, number: idParts.prNumber })) { + return { + propagate: false, + color: new vscode.ThemeColor('pullRequests.notification'), + badge: '●', + tooltip: 'unread notification' + }; + } + } + + private notificationSettingValue(): boolean { + return vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(NOTIFICATION_SETTING, 'off') === 'pullRequests'; + } } \ No newline at end of file diff --git a/src/view/prsTreeDataProvider.ts b/src/view/prsTreeDataProvider.ts index 5ee8f01e84..e433636fca 100644 --- a/src/view/prsTreeDataProvider.ts +++ b/src/view/prsTreeDataProvider.ts @@ -4,6 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { PRStatusDecorationProvider } from './prStatusDecorationProvider'; +import { PrsTreeModel } from './prsTreeModel'; +import { ReviewModel } from './reviewModel'; import { AuthProvider } from '../common/authentication'; import { commands, contexts } from '../common/executeCommands'; import { Disposable } from '../common/lifecycle'; @@ -13,25 +16,21 @@ import { ITelemetry } from '../common/telemetry'; import { createPRNodeIdentifier } from '../common/uri'; import { EXTENSION_ID } from '../constants'; import { CopilotRemoteAgentManager } from '../github/copilotRemoteAgent'; -import { CredentialStore } from '../github/credentials'; import { FolderRepositoryManager, ReposManagerState } from '../github/folderRepositoryManager'; import { PullRequestChangeEvent } from '../github/githubRepository'; import { PRType } from '../github/interface'; import { issueMarkdown } from '../github/markdownUtils'; -import { NotificationProvider } from '../github/notifications'; import { PullRequestModel } from '../github/pullRequestModel'; import { PullRequestOverviewPanel } from '../github/pullRequestOverview'; import { RepositoriesManager } from '../github/repositoriesManager'; import { findDotComAndEnterpriseRemotes } from '../github/utils'; -import { PRStatusDecorationProvider } from './prStatusDecorationProvider'; -import { PrsTreeModel } from './prsTreeModel'; -import { ReviewModel } from './reviewModel'; import { CategoryTreeNode, PRCategoryActionNode, PRCategoryActionType } from './treeNodes/categoryNode'; import { InMemFileChangeNode } from './treeNodes/fileChangeNode'; import { PRNode } from './treeNodes/pullRequestNode'; import { BaseTreeNode, TreeNode } from './treeNodes/treeNode'; import { TreeUtils } from './treeNodes/treeUtils'; import { WorkspaceFolderNode } from './treeNodes/workspaceFolderNode'; +import { NotificationsManager } from '../notifications/notificationsManager'; export class PullRequestsTreeDataProvider extends Disposable implements vscode.TreeDataProvider, BaseTreeNode { private _onDidChangeTreeData = new vscode.EventEmitter(); @@ -46,17 +45,15 @@ export class PullRequestsTreeDataProvider extends Disposable implements vscode.T } private readonly _view: vscode.TreeView; private _initialized: boolean = false; - public notificationProvider: NotificationProvider; - public readonly prsTreeModel: PrsTreeModel; + private _notificationsProvider?: NotificationsManager; private _notificationClearTimeout: NodeJS.Timeout | undefined; get view(): vscode.TreeView { return this._view; } - constructor(private readonly _telemetry: ITelemetry, private readonly _context: vscode.ExtensionContext, private readonly _reposManager: RepositoriesManager, private readonly _copilotManager: CopilotRemoteAgentManager) { + constructor(public readonly prsTreeModel: PrsTreeModel, private readonly _telemetry: ITelemetry, private readonly _context: vscode.ExtensionContext, private readonly _reposManager: RepositoriesManager, private readonly _copilotManager: CopilotRemoteAgentManager) { super(); - this.prsTreeModel = this._register(new PrsTreeModel(this._telemetry, this._reposManager, _context)); this._register(this.prsTreeModel.onDidChangeData(e => { if (e instanceof FolderRepositoryManager) { this.refreshRepo(e); @@ -66,8 +63,8 @@ export class PullRequestsTreeDataProvider extends Disposable implements vscode.T this.refreshAllQueryResults(true); } })); - this._register(new PRStatusDecorationProvider(this.prsTreeModel, this._copilotManager)); this._register(vscode.commands.registerCommand('pr.refreshList', _ => { + this.prsTreeModel.forceClearCache(); this.refreshAllQueryResults(true); })); @@ -105,15 +102,9 @@ export class PullRequestsTreeDataProvider extends Disposable implements vscode.T })); this._register(this._copilotManager.onDidChangeNotifications(() => { - if (this._copilotManager.notificationsCount > 0) { - this._view.badge = { - tooltip: this._copilotManager.notificationsCount === 1 ? vscode.l10n.t('Coding agent has 1 pull request to view') : vscode.l10n.t('Coding agent has {0} pull requests to view', this._copilotManager.notificationsCount), - value: this._copilotManager.notificationsCount - }; - } else { - this._view.badge = undefined; - } + this.updateBadge(); })); + this.updateBadge(); this._register(this._copilotManager.onDidCreatePullRequest(() => this.refreshAllQueryResults(true))); @@ -171,6 +162,80 @@ export class PullRequestsTreeDataProvider extends Disposable implements vscode.T })); } + private filterNotificationsToKnown(notifications: PullRequestModel[]): PullRequestModel[] { + return notifications.filter(notification => { + if (!this.prsTreeModel.hasPullRequest(notification)) { + return false; + } + return !this.prsTreeModel.hasCopilotNotification(notification.remote.owner, notification.remote.repositoryName, notification.number); + }); + } + + private updateBadge() { + const isPRNotificationsOn = this._notificationsProvider?.isPRNotificationsOn(); + + const prNotificationsCount = isPRNotificationsOn ? this.filterNotificationsToKnown(this._notificationsProvider!.prNotifications).length : 0; + const copilotCount = this.prsTreeModel.copilotNotificationsCount; + const totalCount = prNotificationsCount + copilotCount; + + if (totalCount === 0) { + this._view.badge = undefined; + return; + } + + if (prNotificationsCount > 0 && copilotCount > 0) { + if (copilotCount === 1) { + if (prNotificationsCount === 1) { + this._view.badge = { + tooltip: vscode.l10n.t('Coding agent has 1 pull request to view, plus 1 other pull request notification'), + value: totalCount + }; + } else { + this._view.badge = { + tooltip: vscode.l10n.t(`Coding agent has 1 pull request to view, plus {0} other pull request notifications`, prNotificationsCount), + value: totalCount + }; + } + } else { + if (prNotificationsCount === 1) { + this._view.badge = { + tooltip: vscode.l10n.t('Coding agent has {0} pull requests to view, plus 1 other pull request notification', copilotCount), + value: totalCount + }; + } else { + this._view.badge = { + tooltip: vscode.l10n.t(`Coding agent has {0} pull requests to view, plus {1} other pull request notifications`, copilotCount, prNotificationsCount), + value: totalCount + }; + } + } + } else if (copilotCount > 0) { + if (copilotCount === 1) { + this._view.badge = { + tooltip: vscode.l10n.t(`Coding agent has 1 pull request to view`), + value: totalCount + }; + } else { + this._view.badge = { + tooltip: vscode.l10n.t(`Coding agent has {0} pull requests to view`, copilotCount), + value: totalCount + }; + } + } else if (prNotificationsCount > 0) { + if (prNotificationsCount === 1) { + this._view.badge = { + tooltip: vscode.l10n.t(`1 pull request notification`), + value: totalCount + }; + } else { + this._view.badge = { + tooltip: vscode.l10n.t(`{0} pull request notifications`, prNotificationsCount), + value: totalCount + }; + } + } + } + public async expandPullRequest(pullRequest: PullRequestModel) { if (this._children.length === 0) { await this.getChildren(); @@ -262,7 +327,7 @@ export class PullRequestsTreeDataProvider extends Disposable implements vscode.T return undefined; } - initialize(reviewModels: ReviewModel[], credentialStore: CredentialStore) { + initialize(reviewModels: ReviewModel[], notificationsManager: NotificationsManager) { if (this._initialized) { throw new Error('Tree has already been initialized!'); } @@ -279,7 +344,11 @@ export class PullRequestsTreeDataProvider extends Disposable implements vscode.T this._register(model.onDidChangeLocalFileChanges(_ => { this.refreshAllQueryResults(); })); } - this.notificationProvider = this._register(new NotificationProvider(this, credentialStore, this._reposManager)); + this._notificationsProvider = notificationsManager; + this._register(this._notificationsProvider.onDidChangeNotifications(() => { + this.updateBadge(); + })); + this._register(new PRStatusDecorationProvider(this.prsTreeModel, this._copilotManager, this._notificationsProvider)); this.initializeCategories(); this.refreshAll(); @@ -494,7 +563,7 @@ export class PullRequestsTreeDataProvider extends Disposable implements vscode.T gitHubFolderManagers[0], this._telemetry, this, - this.notificationProvider, + this._notificationsProvider!, this._context, this.prsTreeModel, this._copilotManager, @@ -507,7 +576,7 @@ export class PullRequestsTreeDataProvider extends Disposable implements vscode.T folderManager.repository.rootUri, folderManager, this._telemetry, - this.notificationProvider, + this._notificationsProvider!, this._context, this.prsTreeModel, this._copilotManager diff --git a/src/view/prsTreeModel.ts b/src/view/prsTreeModel.ts index 7b03f0a139..2ceec9d84b 100644 --- a/src/view/prsTreeModel.ts +++ b/src/view/prsTreeModel.ts @@ -5,7 +5,10 @@ import * as vscode from 'vscode'; import { RemoteInfo } from '../../common/types'; +import { COPILOT_ACCOUNTS } from '../common/comment'; +import { copilotEventToStatus, CopilotPRStatus } from '../common/copilot'; import { Disposable, disposeAll } from '../common/lifecycle'; +import Logger from '../common/logger'; import { getReviewMode } from '../common/settingsUtils'; import { ITelemetry } from '../common/telemetry'; import { createPRNodeIdentifier } from '../common/uri'; @@ -17,10 +20,11 @@ import { RepositoriesManager } from '../github/repositoriesManager'; import { extractRepoFromQuery, UnsatisfiedChecks } from '../github/utils'; import { CategoryTreeNode } from './treeNodes/categoryNode'; import { TreeNode } from './treeNodes/treeNode'; +import { CodingAgentPRAndStatus, CopilotStateModel, getCopilotQuery } from '../github/copilotPrWatcher'; export const EXPANDED_QUERIES_STATE = 'expandedQueries'; -interface PRStatusChange { +export interface PRStatusChange { pullRequest: PullRequestModel; status: UnsatisfiedChecks; } @@ -32,6 +36,8 @@ interface CachedPRs { } export class PrsTreeModel extends Disposable { + private static readonly ID = 'PrsTreeModel'; + private _activePRDisposables: Map = new Map(); private readonly _onDidChangePrStatus: vscode.EventEmitter = this._register(new vscode.EventEmitter()); public readonly onDidChangePrStatus = this._onDidChangePrStatus.event; @@ -46,12 +52,25 @@ export class PrsTreeModel extends Disposable { private readonly _queriedPullRequests: Map = new Map(); private _cachedPRs: Map> = new Map(); + // For ease of finding which PRs we know about + private _allCachedPRs: Set = new Set(); + private readonly _repoEvents: Map = new Map(); private _getPullRequestsForQueryLock: Promise = Promise.resolve(); private _sentNoRepoTelemetry: boolean = false; + public readonly copilotStateModel: CopilotStateModel; + private readonly _onDidChangeCopilotStates = this._register(new vscode.EventEmitter()); + readonly onDidChangeCopilotStates = this._onDidChangeCopilotStates.event; + private readonly _onDidChangeCopilotNotifications = this._register(new vscode.EventEmitter()); + readonly onDidChangeCopilotNotifications = this._onDidChangeCopilotNotifications.event; + constructor(private _telemetry: ITelemetry, private readonly _reposManager: RepositoriesManager, private readonly _context: vscode.ExtensionContext) { super(); + this.copilotStateModel = new CopilotStateModel(); + this._register(this.copilotStateModel.onDidChangeCopilotStates(() => this._onDidChangeCopilotStates.fire())); + this._register(this.copilotStateModel.onDidChangeCopilotNotifications((prs) => this._onDidChangeCopilotNotifications.fire(prs))); + const repoEvents = (manager: FolderRepositoryManager) => { if (this._repoEvents.has(manager)) { disposeAll(this._repoEvents.get(manager)!); @@ -90,8 +109,15 @@ export class PrsTreeModel extends Disposable { } this._register(this._reposManager.onDidChangeAnyPullRequests((prs) => { - const needsRefresh = prs.filter(pr => pr.event.state || pr.event.title || pr.event.body || pr.event.comments || pr.event.draft || pr.event.timeline); - this.clearQueriesContainingPullRequests(needsRefresh); + const stateChanged: PullRequestChangeEvent[] = []; + const needsRefresh: PullRequestChangeEvent[] = []; + for (const pr of prs) { + if (pr.event.state) { + stateChanged.push(pr); + } + needsRefresh.push(pr); + } + this.forceClearQueriesContainingPullRequests(stateChanged); this._onDidChangeData.fire(needsRefresh); })); @@ -108,6 +134,10 @@ export class PrsTreeModel extends Disposable { } })); + this._register(this._reposManager.onDidChangeAnyGitHubRepository((folderManager) => { + this._onDidChangeData.fire(folderManager); + })); + const expandedQueries = this._context.workspaceState.get(EXPANDED_QUERIES_STATE, undefined); if (expandedQueries) { this._expandedQueries = new Set(expandedQueries); @@ -148,6 +178,16 @@ export class PrsTreeModel extends Disposable { return this._queriedPullRequests.get(identifier); } + public forceClearCache() { + this._cachedPRs.clear(); + this._allCachedPRs.clear(); + this._onDidChangeData.fire(); + } + + public hasPullRequest(pr: PullRequestModel): boolean { + return this._allCachedPRs.has(pr); + } + public clearCache(silent: boolean = false) { if (this._cachedPRs.size === 0) { return; @@ -167,6 +207,16 @@ export class PrsTreeModel extends Disposable { } } + private _clearOneCache(folderRepoManager: FolderRepositoryManager, query: string | PRType.LocalPullRequest | PRType.All) { + const cache = this.getFolderCache(folderRepoManager); + if (cache.has(query)) { + const cachedForQuery = cache.get(query); + if (cachedForQuery) { + cachedForQuery.clearRequested = true; + } + } + } + private async _getChecks(pullRequests: PullRequestModel[]) { // If there are too many pull requests then we could hit our internal rate limit // or even GitHub's secondary rate limit. If there are more than 100 PRs, @@ -244,6 +294,7 @@ export class PrsTreeModel extends Disposable { items: { hasMorePages: false, hasUnsearchedRepositories: false, items: prs, totalCount: prs.length } }; cache.set(PRType.LocalPullRequest, toCache); + prs.forEach(pr => this._allCachedPRs.add(pr)); /* __GDPR__ "pr.expand.local" : {} @@ -294,7 +345,7 @@ export class PrsTreeModel extends Disposable { return repo.getMaxPullRequest(); } - async getPullRequestsForQuery(folderRepoManager: FolderRepositoryManager, fetchNextPage: boolean, query: string): Promise> { + async getPullRequestsForQuery(folderRepoManager: FolderRepositoryManager, fetchNextPage: boolean, query: string, fetchOnePagePerRepo: boolean = false): Promise> { let release: () => void; const lock = new Promise(resolve => { release = resolve; }); const prev = this._getPullRequestsForQueryLock; @@ -304,9 +355,9 @@ export class PrsTreeModel extends Disposable { try { let maxKnownPR: number | undefined; const cache = this.getFolderCache(folderRepoManager); + const cachedPRs = cache.get(query)!; if (!fetchNextPage && cache.has(query)) { const shouldRefresh = await this._testIfRefreshNeeded(cache.get(query)!, query, folderRepoManager); - const cachedPRs = cache.get(query)!; maxKnownPR = cachedPRs.maxKnownPR; if (!shouldRefresh) { cachedPRs.clearRequested = false; @@ -323,10 +374,14 @@ export class PrsTreeModel extends Disposable { const prs = await folderRepoManager.getPullRequests( PRType.Query, - { fetchNextPage }, + { fetchNextPage, fetchOnePagePerRepo }, query, ); + if (fetchNextPage) { + prs.items = cachedPRs?.items.items.concat(prs.items) ?? prs.items; + } cache.set(query, { clearRequested: false, items: prs, maxKnownPR }); + prs.items.forEach(pr => this._allCachedPRs.add(pr)); /* __GDPR__ "pr.expand.query" : {} @@ -352,7 +407,11 @@ export class PrsTreeModel extends Disposable { PRType.All, { fetchNextPage } ); + if (fetchNextPage) { + prs.items = allCache?.items.items.concat(prs.items) ?? prs.items; + } cache.set(PRType.All, { clearRequested: false, items: prs, maxKnownPR: undefined }); + prs.items.forEach(pr => this._allCachedPRs.add(pr)); /* __GDPR__ "pr.expand.all" : {} @@ -364,7 +423,7 @@ export class PrsTreeModel extends Disposable { return prs; } - private clearQueriesContainingPullRequests(pullRequests: PullRequestChangeEvent[]): void { + private forceClearQueriesContainingPullRequests(pullRequests: PullRequestChangeEvent[]): void { const withStateChange = pullRequests.filter(prChange => prChange.event.state); if (!withStateChange || withStateChange.length === 0) { return; @@ -378,12 +437,142 @@ export class PrsTreeModel extends Disposable { cachedPRs.items.items.some(item => item === prChange.model) ); if (hasPR) { - queries.get(queryKey)!.clearRequested = true; + const cachedForQuery = queries.get(queryKey); + if (cachedForQuery) { + cachedForQuery.items.items.forEach(item => this._allCachedPRs.delete(item)); + } + queries.delete(queryKey); } } } } + getCopilotNotificationsCount(owner: string, repo: string): number { + return this.copilotStateModel.getNotificationsCount(owner, repo); + } + + get copilotNotificationsCount(): number { + return this.copilotStateModel.notifications.size; + } + + clearAllCopilotNotifications(owner?: string, repo?: string): void { + this.copilotStateModel.clearAllNotifications(owner, repo); + } + + clearCopilotNotification(owner: string, repo: string, pullRequestNumber: number): void { + this.copilotStateModel.clearNotification(owner, repo, pullRequestNumber); + } + + hasCopilotNotification(owner: string, repo: string, pullRequestNumber?: number): boolean { + if (pullRequestNumber !== undefined) { + const key = this.copilotStateModel.makeKey(owner, repo, pullRequestNumber); + return this.copilotStateModel.notifications.has(key); + } else { + const partialKey = this.copilotStateModel.makeKey(owner, repo); + return Array.from(this.copilotStateModel.notifications.keys()).some(key => { + return key.startsWith(partialKey); + }); + } + } + + getCopilotStateForPR(owner: string, repo: string, prNumber: number): CopilotPRStatus { + return this.copilotStateModel.get(owner, repo, prNumber); + } + + getCopilotCounts(owner: string, repo: string): { total: number; inProgress: number; error: number } { + return this.copilotStateModel.getCounts(owner, repo); + } + + clearCopilotCaches() { + const copilotQuery = getCopilotQuery(); + if (!copilotQuery) { + return false; + } + for (const folderManager of this._reposManager.folderManagers) { + this._clearOneCache(folderManager, copilotQuery); + } + } + + private _getStateChangesPromise: Promise | undefined; + async refreshCopilotStateChanges(clearCache: boolean = false): Promise { + // Return the existing in-flight promise if one exists + if (this._getStateChangesPromise) { + return this._getStateChangesPromise; + } + + if (clearCache) { + this.clearCopilotCaches(); + } + + // Create and store the in-flight promise, and ensure it's cleared when done + this._getStateChangesPromise = (async () => { + try { + const unseenKeys: Set = new Set(this.copilotStateModel.keys()); + let initialized = 0; + + const copilotQuery = getCopilotQuery(); + if (!copilotQuery) { + return false; + } + + const changes: CodingAgentPRAndStatus[] = []; + for (const folderManager of this._reposManager.folderManagers) { + initialized++; + const items: PullRequestModel[] = []; + let hasMore = true; + do { + const prs = await this.getPullRequestsForQuery(folderManager, !this.copilotStateModel.isInitialized, copilotQuery, true); + items.push(...prs.items); + hasMore = prs.hasMorePages; + } while (hasMore); + + for (const pr of items) { + unseenKeys.delete(this.copilotStateModel.makeKey(pr.remote.owner, pr.remote.repositoryName, pr.number)); + const copilotEvents = await pr.getCopilotTimelineEvents(pr, false, !this.copilotStateModel.isInitialized); + let latestEvent = copilotEventToStatus(copilotEvents[copilotEvents.length - 1]); + if (latestEvent === CopilotPRStatus.None) { + if (!COPILOT_ACCOUNTS[pr.author.login]) { + continue; + } + latestEvent = CopilotPRStatus.Started; + } + const lastStatus = this.copilotStateModel.get(pr.remote.owner, pr.remote.repositoryName, pr.number) ?? CopilotPRStatus.None; + if (latestEvent !== lastStatus) { + changes.push({ item: pr, status: latestEvent }); + } + } + } + for (const key of unseenKeys) { + this.copilotStateModel.deleteKey(key); + } + this.copilotStateModel.set(changes); + if (!this.copilotStateModel.isInitialized) { + if ((initialized === this._reposManager.folderManagers.length) && (this._reposManager.folderManagers.length > 0)) { + Logger.debug(`Copilot PR state initialized with ${this.copilotStateModel.keys().length} PRs`, PrsTreeModel.ID); + this.copilotStateModel.setInitialized(); + } + return true; + } else { + return true; + } + } finally { + // Ensure the stored promise is cleared so subsequent calls start a new run + this._getStateChangesPromise = undefined; + } + })(); + + return this._getStateChangesPromise; + } + + async getCopilotPullRequests(clearCache: boolean = false): Promise { + if (clearCache) { + this.clearCopilotCaches(); + } + + await this.refreshCopilotStateChanges(clearCache); + return this.copilotStateModel.all; + } + override dispose() { super.dispose(); disposeAll(Array.from(this._activePRDisposables.values()).flat()); diff --git a/src/view/pullRequestCommentController.ts b/src/view/pullRequestCommentController.ts index 58260edeec..3823de2c6d 100644 --- a/src/view/pullRequestCommentController.ts +++ b/src/view/pullRequestCommentController.ts @@ -6,6 +6,7 @@ import { v4 as uuid } from 'uuid'; import * as vscode from 'vscode'; import { CommentHandler, registerCommentHandler, unregisterCommentHandler } from '../commentHandlerResolver'; +import { CommentControllerBase } from './commentControllBase'; import { DiffSide, IComment, SubjectType } from '../common/comment'; import { disposeAll } from '../common/lifecycle'; import Logger from '../common/logger'; @@ -28,7 +29,6 @@ import { updateThread, updateThreadWithRange, } from '../github/utils'; -import { CommentControllerBase } from './commentControllBase'; export class PullRequestCommentController extends CommentControllerBase implements CommentHandler, CommentReactionHandler { private static ID = 'PullRequestCommentController'; @@ -227,8 +227,8 @@ export class PullRequestCommentController extends CommentControllerBase implemen } } - private onDidChangeReviewThreads(e: ReviewThreadChangeEvent): void { - e.added.forEach(async (thread) => { + private async onDidChangeReviewThreads(e: ReviewThreadChangeEvent): Promise { + for (const thread of e.added) { const fileName = thread.path; const index = this._pendingCommentThreadAdds.findIndex(t => { const samePath = this._folderRepoManager.gitRelativeRootPath(t.uri.path) === thread.path; @@ -278,18 +278,18 @@ export class PullRequestCommentController extends CommentControllerBase implemen } else { this._commentThreadCache[key] = [newThread]; } - }); + } - e.changed.forEach(thread => { + for (const thread of e.changed) { const key = this.getCommentThreadCacheKey(thread.path, thread.diffSide === DiffSide.LEFT); const index = this._commentThreadCache[key] ? this._commentThreadCache[key].findIndex(t => t.gitHubThreadId === thread.id) : -1; if (index > -1) { const matchingThread = this._commentThreadCache[key][index]; updateThread(this._context, matchingThread, thread, this._githubRepositories); } - }); + } - e.removed.forEach(async thread => { + for (const thread of e.removed) { const key = this.getCommentThreadCacheKey(thread.path, thread.diffSide === DiffSide.LEFT); const index = this._commentThreadCache[key].findIndex(t => t.gitHubThreadId === thread.id); if (index > -1) { @@ -297,7 +297,7 @@ export class PullRequestCommentController extends CommentControllerBase implemen this._commentThreadCache[key].splice(index, 1); matchingThread.dispose(); } - }); + } } protected override onDidChangeActiveTextEditor(editor: vscode.TextEditor | undefined) { diff --git a/src/view/pullRequestCommentControllerRegistry.ts b/src/view/pullRequestCommentControllerRegistry.ts index 05b7da4956..e4eeb6ad12 100644 --- a/src/view/pullRequestCommentControllerRegistry.ts +++ b/src/view/pullRequestCommentControllerRegistry.ts @@ -5,6 +5,7 @@ 'use strict'; import * as vscode from 'vscode'; +import { PullRequestCommentController } from './pullRequestCommentController'; import { Disposable } from '../common/lifecycle'; import { ITelemetry } from '../common/telemetry'; import { fromPRUri, Schemes } from '../common/uri'; @@ -12,17 +13,20 @@ import { FolderRepositoryManager } from '../github/folderRepositoryManager'; import { GHPRComment } from '../github/prComment'; import { PullRequestModel } from '../github/pullRequestModel'; import { CommentReactionHandler } from '../github/utils'; -import { PullRequestCommentController } from './pullRequestCommentController'; -interface PullRequestCommentHandlerInfo { +interface PullRequestCommentHandlerInfo extends vscode.Disposable { handler: PullRequestCommentController & CommentReactionHandler; refCount: number; - dispose: () => void; +} + +interface PRCommentingRangeProviderInfo extends vscode.Disposable { + provider: vscode.CommentingRangeProvider2; + refCount: number; } export class PRCommentControllerRegistry extends Disposable implements vscode.CommentingRangeProvider, CommentReactionHandler { private _prCommentHandlers: { [key: number]: PullRequestCommentHandlerInfo } = {}; - private _prCommentingRangeProviders: { [key: number]: vscode.CommentingRangeProvider2 } = {}; + private _prCommentingRangeProviders: { [key: number]: PRCommentingRangeProviderInfo } = {}; private readonly _activeChangeListeners: Map = new Map(); public readonly resourceHints = { schemes: [Schemes.Pr] }; @@ -40,8 +44,8 @@ export class PRCommentControllerRegistry extends Disposable implements vscode.Co return; } - const provideCommentingRanges = this._prCommentingRangeProviders[params.prNumber].provideCommentingRanges.bind( - this._prCommentingRangeProviders[params.prNumber], + const provideCommentingRanges = this._prCommentingRangeProviders[params.prNumber].provider.provideCommentingRanges.bind( + this._prCommentingRangeProviders[params.prNumber].provider, ); return provideCommentingRanges(document, token); @@ -108,13 +112,27 @@ export class PRCommentControllerRegistry extends Disposable implements vscode.Co } public registerCommentingRangeProvider(prNumber: number, provider: vscode.CommentingRangeProvider2): vscode.Disposable { - this._prCommentingRangeProviders[prNumber] = provider; + if (this._prCommentingRangeProviders[prNumber]) { + this._prCommentingRangeProviders[prNumber].refCount += 1; + return this._prCommentingRangeProviders[prNumber]; + } - return { + this._prCommentingRangeProviders[prNumber] = { + provider, + refCount: 1, dispose: () => { - delete this._prCommentingRangeProviders[prNumber]; + if (!this._prCommentingRangeProviders[prNumber]) { + return; + } + + this._prCommentingRangeProviders[prNumber].refCount -= 1; + if (this._prCommentingRangeProviders[prNumber].refCount === 0) { + delete this._prCommentingRangeProviders[prNumber]; + } } }; + + return this._prCommentingRangeProviders[prNumber]; } override dispose() { diff --git a/src/view/repositoryFileSystemProvider.ts b/src/view/repositoryFileSystemProvider.ts index 544b2d82a2..78ab8a849a 100644 --- a/src/view/repositoryFileSystemProvider.ts +++ b/src/view/repositoryFileSystemProvider.ts @@ -4,11 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { ReadonlyFileSystemProvider } from './readonlyFileSystemProvider'; import { GitApiImpl } from '../api/api1'; import Logger from '../common/logger'; import { CredentialStore } from '../github/credentials'; import { RepositoriesManager } from '../github/repositoriesManager'; -import { ReadonlyFileSystemProvider } from './readonlyFileSystemProvider'; export abstract class RepositoryFileSystemProvider extends ReadonlyFileSystemProvider { constructor(protected gitAPI: GitApiImpl, protected credentialStore: CredentialStore) { diff --git a/src/view/reviewCommentController.ts b/src/view/reviewCommentController.ts index 60a3bd7b16..51fe26df56 100644 --- a/src/view/reviewCommentController.ts +++ b/src/view/reviewCommentController.ts @@ -9,6 +9,10 @@ import * as vscode from 'vscode'; import { Repository } from '../api/api'; import { GitApiImpl } from '../api/api1'; import { CommentHandler, registerCommentHandler, unregisterCommentHandler } from '../commentHandlerResolver'; +import { CommentControllerBase } from './commentControllBase'; +import { RemoteFileChangeModel } from './fileChangeModel'; +import { ReviewManager } from './reviewManager'; +import { ReviewModel } from './reviewModel'; import { DiffSide, IReviewThread, SubjectType } from '../common/comment'; import { getCommentingRanges } from '../common/commentingRanges'; import { mapNewPositionToOld, mapOldPositionToNew } from '../common/diffPositionMapping'; @@ -19,7 +23,7 @@ import Logger from '../common/logger'; import { PR_SETTINGS_NAMESPACE, PULL_BRANCH, PULL_PR_BRANCH_BEFORE_CHECKOUT, PullPRBranchVariants } from '../common/settingKeys'; import { ITelemetry } from '../common/telemetry'; import { fromReviewUri, ReviewUriParams, Schemes, toReviewUri } from '../common/uri'; -import { formatError, groupBy, uniqBy } from '../common/utils'; +import { arrayFindIndexAsync, formatError, groupBy, uniqBy } from '../common/utils'; import { FolderRepositoryManager } from '../github/folderRepositoryManager'; import { GHPRComment, GHPRCommentThread, TemporaryComment } from '../github/prComment'; import { PullRequestOverviewPanel } from '../github/pullRequestOverview'; @@ -35,10 +39,6 @@ import { updateThread, updateThreadWithRange, } from '../github/utils'; -import { CommentControllerBase } from './commentControllBase'; -import { RemoteFileChangeModel } from './fileChangeModel'; -import { ReviewManager } from './reviewManager'; -import { ReviewModel } from './reviewModel'; import { GitFileChangeNode, gitFileChangeNodeFilter, RemoteFileChangeNode } from './treeNodes/fileChangeNode'; export interface SuggestionInformation { @@ -275,12 +275,12 @@ export class ReviewCommentController extends CommentControllerBase implements Co ); this._register( - activePullRequest.onDidChangeReviewThreads(e => { + activePullRequest.onDidChangeReviewThreads(async e => { const githubRepositories = this.githubReposForPullRequest(this._folderRepoManager.activePullRequest); - e.added.forEach(async thread => { + for (const thread of e.added) { const { path } = thread; - const index = this._pendingCommentThreadAdds.findIndex(async t => { + const index = await arrayFindIndexAsync(this._pendingCommentThreadAdds, async t => { const fileName = this._folderRepoManager.gitRelativeRootPath(t.uri.path); if (fileName !== thread.path) { return false; @@ -324,24 +324,24 @@ export class ReviewCommentController extends CommentControllerBase implements Co } else { threadMap[path] = [newThread]; } - }); + } - e.changed.forEach(thread => { + for (const thread of e.changed) { const match = this._findMatchingThread(thread); if (match.index > -1) { const matchingThread = match.threadMap[thread.path][match.index]; updateThread(this._context, matchingThread, thread, githubRepositories); } - }); + } - e.removed.forEach(thread => { + for (const thread of e.removed) { const match = this._findMatchingThread(thread); if (match.index > -1) { const matchingThread = match.threadMap[thread.path][match.index]; match.threadMap[thread.path].splice(match.index, 1); matchingThread.dispose(); } - }); + } this.updateResourcesWithCommentingRanges(); }), diff --git a/src/view/reviewManager.ts b/src/view/reviewManager.ts index 5b30eb3418..d553e419d5 100644 --- a/src/view/reviewManager.ts +++ b/src/view/reviewManager.ts @@ -8,6 +8,14 @@ import * as vscode from 'vscode'; import type { Branch, Change, Repository } from '../api/api'; import { GitApiImpl, GitErrorCodes, Status } from '../api/api1'; import { openDescription } from '../commands'; +import { CreatePullRequestHelper } from './createPullRequestHelper'; +import { GitFileChangeModel, InMemFileChangeModel, RemoteFileChangeModel } from './fileChangeModel'; +import { getInMemPRFileSystemProvider, provideDocumentContentForChangeModel } from './inMemPRContentProvider'; +import { PullRequestChangesTreeDataProvider } from './prChangesTreeDataProvider'; +import { ProgressHelper } from './progress'; +import { PullRequestsTreeDataProvider } from './prsTreeDataProvider'; +import { ReviewCommentController, SuggestionInformation } from './reviewCommentController'; +import { ReviewModel } from './reviewModel'; import { DiffChangeType, DiffHunk, parsePatch, splitIntoSmallerHunks } from '../common/diffHunk'; import { commands } from '../common/executeCommands'; import { GitChangeType, InMemFileChange, SlimFileChange } from '../common/file'; @@ -15,6 +23,7 @@ import { Disposable, disposeAll, toDisposable } from '../common/lifecycle'; import Logger from '../common/logger'; import { parseRepositoryRemotes, Remote } from '../common/remote'; import { + AUTO_STASH_ON_CHECKOUT, COMMENTS, FOCUSED_MODE, IGNORE_PR_BRANCHES, @@ -37,14 +46,6 @@ import { GitHubRepository } from '../github/githubRepository'; import { GithubItemStateEnum } from '../github/interface'; import { PullRequestGitHelper, PullRequestMetadata } from '../github/pullRequestGitHelper'; import { IResolvedPullRequestModel, PullRequestModel } from '../github/pullRequestModel'; -import { CreatePullRequestHelper } from './createPullRequestHelper'; -import { GitFileChangeModel, InMemFileChangeModel, RemoteFileChangeModel } from './fileChangeModel'; -import { getInMemPRFileSystemProvider, provideDocumentContentForChangeModel } from './inMemPRContentProvider'; -import { PullRequestChangesTreeDataProvider } from './prChangesTreeDataProvider'; -import { ProgressHelper } from './progress'; -import { PullRequestsTreeDataProvider } from './prsTreeDataProvider'; -import { ReviewCommentController, SuggestionInformation } from './reviewCommentController'; -import { ReviewModel } from './reviewModel'; import { GitFileChangeNode, gitFileChangeNodeFilter, RemoteFileChangeNode } from './treeNodes/fileChangeNode'; import { WebviewViewCoordinator } from './webviewViewCoordinator'; @@ -75,6 +76,11 @@ export class ReviewManager extends Disposable { * explicit user action from something like reloading on an existing PR branch. */ private justSwitchedToReviewMode: boolean = false; + /** + * The last pull request the user explicitly switched to via the switch method. + * Used to enter review mode for this PR regardless of its state (open/closed/merged). + */ + private _switchedToPullRequest?: PullRequestModel; public get switchingToReviewMode(): boolean { return this._switchingToReviewMode; @@ -381,13 +387,13 @@ export class ReviewManager extends Disposable { } } - private async resolvePullRequest(metadata: PullRequestMetadata): Promise<(PullRequestModel & IResolvedPullRequestModel) | undefined> { + private async resolvePullRequest(metadata: PullRequestMetadata, useCache: boolean): Promise<(PullRequestModel & IResolvedPullRequestModel) | undefined> { try { this._prNumber = metadata.prNumber; const { owner, repositoryName } = metadata; Logger.appendLine('Resolving pull request', this.id); - let pr = await this._folderRepoManager.resolvePullRequest(owner, repositoryName, metadata.prNumber); + let pr = await this._folderRepoManager.resolvePullRequest(owner, repositoryName, metadata.prNumber, useCache); if (!pr || !pr.isResolved() || !(await pr.githubRepository.hasBranch(pr.base.name))) { await this.clear(true); @@ -450,7 +456,9 @@ export class ReviewManager extends Disposable { `current branch ${this._repository.state.HEAD.name} is associated with pull request #${matchingPullRequestMetadata.prNumber}`, this.id ); const previousPrNumber = this._prNumber; - let pr = await this.resolvePullRequest(matchingPullRequestMetadata); + // Use the cache if we just checked out the same PR as a small performance optimization. + const justCheckedOutSamePr = this.justSwitchedToReviewMode && (previousPrNumber === matchingPullRequestMetadata.prNumber); + let pr = await this.resolvePullRequest(matchingPullRequestMetadata, justCheckedOutSamePr); if (!pr) { Logger.appendLine(`Unable to resolve PR #${matchingPullRequestMetadata.prNumber}`, this.id); return; @@ -461,7 +469,7 @@ export class ReviewManager extends Disposable { if (pr.state !== GithubItemStateEnum.Open) { const metadataFromGithub = await this.checkGitHubForPrBranch(branch); if (metadataFromGithub && metadataFromGithub?.prNumber !== pr.number) { - const prFromGitHub = await this.resolvePullRequest(metadataFromGithub); + const prFromGitHub = await this.resolvePullRequest(metadataFromGithub, false); if (prFromGitHub) { pr = prFromGitHub; } @@ -469,7 +477,7 @@ export class ReviewManager extends Disposable { } const hasPushedChanges = branch.commit !== oldLastCommitSha && branch.ahead === 0 && branch.behind === 0; - if (previousPrNumber === pr.number && !hasPushedChanges && (this._isShowingLastReviewChanges === pr.showChangesSinceReview)) { + if (!this.justSwitchedToReviewMode && (previousPrNumber === pr.number) && !hasPushedChanges && (this._isShowingLastReviewChanges === pr.showChangesSinceReview)) { return; } this._isShowingLastReviewChanges = pr.showChangesSinceReview; @@ -479,13 +487,16 @@ export class ReviewManager extends Disposable { const useReviewConfiguration = getReviewMode(); - if (pr.isClosed && !useReviewConfiguration.closed) { + // If this is the PR the user explicitly switched to, always use review mode regardless of state + const isSwitchedToPullRequest = this._switchedToPullRequest?.number === pr.number; + + if (pr.isClosed && !useReviewConfiguration.closed && !isSwitchedToPullRequest) { Logger.appendLine('This PR is closed', this.id); await this.clear(true); return; } - if (pr.isMerged && !useReviewConfiguration.merged) { + if (pr.isMerged && !useReviewConfiguration.merged && !isSwitchedToPullRequest) { Logger.appendLine('This PR is merged', this.id); await this.clear(true); return; @@ -541,7 +552,9 @@ export class ReviewManager extends Disposable { }) ); Logger.appendLine(`Register in memory content provider`, this.id); - await this.registerGitHubInMemContentProvider(); + if (previousPrNumber !== pr.number) { + await this.registerGitHubInMemContentProvider(); + } this.statusBarItem.text = '$(git-pull-request) ' + vscode.l10n.t('Pull Request #{0}', pr.number); this.statusBarItem.command = { @@ -1086,6 +1099,36 @@ export class ReviewManager extends Disposable { this.statusBarItem.command = undefined; this.statusBarItem.show(); this.switchingToReviewMode = true; + this._switchedToPullRequest = pr; + + // Check if auto-stash is enabled and there are uncommitted changes + const autoStashEnabled = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(AUTO_STASH_ON_CHECKOUT, false); + this._folderRepoManager.stashedOnCheckout = false; + + if (autoStashEnabled) { + const workingTreeChanges = this._repository.state.workingTreeChanges; + const indexChanges = this._repository.state.indexChanges; + const hasChanges = workingTreeChanges.length > 0 || indexChanges.length > 0; + + if (hasChanges) { + try { + Logger.appendLine('Auto-stashing changes before PR checkout', this.id); + // Add only working tree changes to staging area (indexChanges are already staged) + if (workingTreeChanges.length > 0) { + const workingTreeFiles = workingTreeChanges.map(change => change.uri.fsPath); + await this._repository.add(workingTreeFiles); + } + // Stash the changes + await vscode.commands.executeCommand('git.stash', this._repository); + this._folderRepoManager.stashedOnCheckout = true; + Logger.appendLine('Changes stashed successfully', this.id); + } catch (stashError) { + Logger.error(`Failed to auto-stash changes: ${formatError(stashError)}`, this.id); + // Don't block checkout if stashing fails, but warn the user + vscode.window.showWarningMessage(vscode.l10n.t('Failed to auto-stash changes: {0}', formatError(stashError))); + } + } + } try { await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification }, async (progress) => { @@ -1239,6 +1282,7 @@ export class ReviewManager extends Disposable { this._prNumber = undefined; this._folderRepoManager.activePullRequest = undefined; + this._switchedToPullRequest = undefined; if (this._statusBarItem) { this._statusBarItem.hide(); diff --git a/src/view/reviewsManager.ts b/src/view/reviewsManager.ts index cb83c17278..2995e0c8a8 100644 --- a/src/view/reviewsManager.ts +++ b/src/view/reviewsManager.ts @@ -4,19 +4,24 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { GitContentFileSystemProvider } from './gitContentProvider'; +import { PullRequestChangesTreeDataProvider } from './prChangesTreeDataProvider'; +import { PullRequestsTreeDataProvider } from './prsTreeDataProvider'; +import { PrsTreeModel } from './prsTreeModel'; +import { ReviewManager } from './reviewManager'; import { Repository } from '../api/api'; -import { GitApiImpl } from '../api/api1'; +import { GitApiImpl, Status } from '../api/api1'; import { Disposable } from '../common/lifecycle'; +import * as PersistentState from '../common/persistentState'; import { ITelemetry } from '../common/telemetry'; import { Schemes } from '../common/uri'; -import { isDescendant } from '../common/utils'; +import { formatError, isDescendant } from '../common/utils'; import { CopilotRemoteAgentManager } from '../github/copilotRemoteAgent'; import { CredentialStore } from '../github/credentials'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { PullRequestModel } from '../github/pullRequestModel'; import { RepositoriesManager } from '../github/repositoriesManager'; -import { GitContentFileSystemProvider } from './gitContentProvider'; -import { PullRequestChangesTreeDataProvider } from './prChangesTreeDataProvider'; -import { PullRequestsTreeDataProvider } from './prsTreeDataProvider'; -import { ReviewManager } from './reviewManager'; +import { NotificationsManager } from '../notifications/notificationsManager'; export class ReviewsManager extends Disposable { public static ID = 'Reviews'; @@ -25,12 +30,14 @@ export class ReviewsManager extends Disposable { private _context: vscode.ExtensionContext, private _reposManager: RepositoriesManager, private _reviewManagers: ReviewManager[], + private _prsTreeModel: PrsTreeModel, private _prsTreeDataProvider: PullRequestsTreeDataProvider, private _prFileChangesProvider: PullRequestChangesTreeDataProvider, private _telemetry: ITelemetry, private _credentialStore: CredentialStore, private _gitApi: GitApiImpl, - private _copilotManager: CopilotRemoteAgentManager + private _copilotManager: CopilotRemoteAgentManager, + private _notificationsManager: NotificationsManager, ) { super(); const gitContentProvider = new GitContentFileSystemProvider(_gitApi, _credentialStore, () => this._reviewManagers); @@ -57,8 +64,8 @@ export class ReviewsManager extends Disposable { } this._prsTreeDataProvider.dispose(); - this._prsTreeDataProvider = this._register(new PullRequestsTreeDataProvider(this._telemetry, this._context, this._reposManager, this._copilotManager)); - this._prsTreeDataProvider.initialize(this._reviewManagers.map(manager => manager.reviewModel), this._credentialStore); + this._prsTreeDataProvider = this._register(new PullRequestsTreeDataProvider(this._prsTreeModel, this._telemetry, this._context, this._reposManager, this._copilotManager)); + this._prsTreeDataProvider.initialize(this._reviewManagers.map(manager => manager.reviewModel), this._notificationsManager); } })); } @@ -100,4 +107,112 @@ export class ReviewsManager extends Disposable { manager.dispose(); } } + + async switchToPr(folderManager: FolderRepositoryManager, pullRequestModel: PullRequestModel, repository: Repository | undefined, isFromDescription: boolean) { + // If we don't have a repository from the node, use the one from the folder manager + const repositoryToCheck = repository || folderManager.repository; + + // Check for uncommitted changes before proceeding with checkout + const shouldProceed = await handleUncommittedChanges(repositoryToCheck); + if (!shouldProceed) { + return; // User cancelled or there was an error handling changes + } + + /* __GDPR__ + "pr.checkout" : { + "fromDescriptionPage" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + this._telemetry.sendTelemetryEvent('pr.checkout', { fromDescription: isFromDescription.toString() }); + + return vscode.window.withProgress( + { + location: vscode.ProgressLocation.SourceControl, + title: vscode.l10n.t('Switching to Pull Request #{0}', pullRequestModel.number), + }, + async () => { + await ReviewManager.getReviewManagerForRepository( + this._reviewManagers, + pullRequestModel.githubRepository, + repository + )?.switch(pullRequestModel); + }); + }; +} + + +// Modal dialog options for handling uncommitted changes during PR checkout +const STASH_CHANGES = vscode.l10n.t('Stash changes'); +const DISCARD_CHANGES = vscode.l10n.t('Discard changes'); +const DONT_SHOW_AGAIN = vscode.l10n.t('Try to checkout anyway and don\'t show again'); + +// Constants for persistent state storage +const UNCOMMITTED_CHANGES_SCOPE = vscode.l10n.t('uncommitted changes warning'); +const UNCOMMITTED_CHANGES_STORAGE_KEY = 'showWarning'; + +/** + * Shows a modal dialog when there are uncommitted changes during PR checkout + * @param repository The git repository with uncommitted changes + * @returns Promise true if user chose to proceed (after staging/discarding), false if cancelled + */ +async function handleUncommittedChanges(repository: Repository): Promise { + // Check if user has disabled the warning using persistent state + if (PersistentState.fetch(UNCOMMITTED_CHANGES_SCOPE, UNCOMMITTED_CHANGES_STORAGE_KEY) === false) { + return true; // User has disabled warnings, proceed without showing dialog + } + + // Filter out untracked files as they typically don't conflict with PR checkout + const trackedWorkingTreeChanges = repository.state.workingTreeChanges.filter(change => change.status !== Status.UNTRACKED); + const hasTrackedWorkingTreeChanges = trackedWorkingTreeChanges.length > 0; + const hasIndexChanges = repository.state.indexChanges.length > 0; + + if (!hasTrackedWorkingTreeChanges && !hasIndexChanges) { + return true; // No tracked uncommitted changes, proceed + } + + const modalResult = await vscode.window.showInformationMessage( + vscode.l10n.t('You have uncommitted changes that might be overwritten by checking out this pull request.'), + { + modal: true, + detail: vscode.l10n.t('Choose how to handle your uncommitted changes before checking out the pull request.'), + }, + STASH_CHANGES, + DISCARD_CHANGES, + DONT_SHOW_AGAIN, + ); + + if (!modalResult) { + return false; // User cancelled + } + + if (modalResult === DONT_SHOW_AGAIN) { + // Store preference to never show this dialog again using persistent state + PersistentState.store(UNCOMMITTED_CHANGES_SCOPE, UNCOMMITTED_CHANGES_STORAGE_KEY, false); + return true; // Proceed with checkout + } + + try { + if (modalResult === STASH_CHANGES) { + // Stash all changes (working tree changes + any unstaged changes) + const allChangedFiles = [ + ...trackedWorkingTreeChanges.map(change => change.uri.fsPath), + ...repository.state.indexChanges.map(change => change.uri.fsPath), + ]; + if (allChangedFiles.length > 0) { + await repository.add(allChangedFiles); + await vscode.commands.executeCommand('git.stash', repository); + } + } else if (modalResult === DISCARD_CHANGES) { + // Discard all tracked working tree changes + const trackedWorkingTreeFiles = trackedWorkingTreeChanges.map(change => change.uri.fsPath); + if (trackedWorkingTreeFiles.length > 0) { + await repository.clean(trackedWorkingTreeFiles); + } + } + return true; // Successfully handled changes, proceed with checkout + } catch (error) { + vscode.window.showErrorMessage(vscode.l10n.t('Failed to handle uncommitted changes: {0}', formatError(error))); + return false; + } } + diff --git a/src/view/theme.ts b/src/view/theme.ts index 71562e42b4..825395f094 100644 --- a/src/view/theme.ts +++ b/src/view/theme.ts @@ -73,18 +73,18 @@ function getCurrentThemePaths(themeName: string): vscode.Uri | undefined { } } -export function getIconForeground(themeData: ThemeData, kind: 'light' | 'dark'): string { - return (themeData.colors ? themeData.colors['icon.foreground'] : undefined) ?? (kind === 'dark' ? '#C5C5C5' : '#424242'); +export function getIconForeground(themeData: ThemeData | undefined, kind: 'light' | 'dark'): string { + return themeData?.colors?.['icon.foreground'] ?? (kind === 'dark' ? '#C5C5C5' : '#424242'); } -export function getListWarningForeground(themeData: ThemeData, kind: 'light' | 'dark'): string { - return (themeData.colors ? themeData.colors['list.warningForeground'] : undefined) ?? (kind === 'dark' ? '#CCA700' : '#855F00'); +export function getListWarningForeground(themeData: ThemeData | undefined, kind: 'light' | 'dark'): string { + return themeData?.colors?.['list.warningForeground'] ?? (kind === 'dark' ? '#CCA700' : '#855F00'); } -export function getListErrorForeground(themeData: ThemeData, kind: 'light' | 'dark'): string { - return (themeData.colors ? themeData.colors['list.errorForeground'] : undefined) ?? (kind === 'dark' ? '#F88070' : '#B01011'); +export function getListErrorForeground(themeData: ThemeData | undefined, kind: 'light' | 'dark'): string { + return themeData?.colors?.['list.errorForeground'] ?? (kind === 'dark' ? '#F88070' : '#B01011'); } -export function getNotebookStatusSuccessIconForeground(themeData: ThemeData, kind: 'light' | 'dark'): string { - return (themeData.colors ? themeData.colors['notebookStatusSuccessIcon.foreground'] : undefined) ?? (kind === 'dark' ? '#89D185' : '#388A34'); +export function getNotebookStatusSuccessIconForeground(themeData: ThemeData | undefined, kind: 'light' | 'dark'): string { + return themeData?.colors?.['notebookStatusSuccessIcon.foreground'] ?? (kind === 'dark' ? '#89D185' : '#388A34'); } diff --git a/src/view/treeNodes/categoryNode.ts b/src/view/treeNodes/categoryNode.ts index b04bc2f47a..e2585463e4 100644 --- a/src/view/treeNodes/categoryNode.ts +++ b/src/view/treeNodes/categoryNode.ts @@ -13,13 +13,13 @@ import { isCopilotQuery } from '../../github/copilotPrWatcher'; import { CopilotRemoteAgentManager } from '../../github/copilotRemoteAgent'; import { FolderRepositoryManager, ItemsResponseResult } from '../../github/folderRepositoryManager'; import { PRType } from '../../github/interface'; -import { NotificationProvider } from '../../github/notifications'; import { PullRequestModel } from '../../github/pullRequestModel'; import { extractRepoFromQuery } from '../../github/utils'; import { PrsTreeModel } from '../prsTreeModel'; import { PRNode } from './pullRequestNode'; import { TreeNode, TreeNodeParent } from './treeNode'; import { IQueryInfo } from './workspaceFolderNode'; +import { NotificationsManager } from '../../notifications/notificationsManager'; export enum PRCategoryActionType { Empty, @@ -141,7 +141,7 @@ export class CategoryTreeNode extends TreeNode implements vscode.TreeItem { readonly folderRepoManager: FolderRepositoryManager, private _telemetry: ITelemetry, public readonly type: PRType, - private _notificationProvider: NotificationProvider, + private _notificationProvider: NotificationsManager, private _prsTreeModel: PrsTreeModel, private _copilotManager: CopilotRemoteAgentManager, _categoryLabel?: string, @@ -203,7 +203,7 @@ export class CategoryTreeNode extends TreeNode implements vscode.TreeItem { if (!this.isCopilot || !this._repo) { return undefined; } - const counts = this._copilotManager.getCounts(this._repo.owner, this._repo.repositoryName); + const counts = this._prsTreeModel.getCopilotCounts(this._repo.owner, this._repo.repositoryName); if (counts.total === 0) { return undefined; } else if (counts.error > 0) { @@ -311,7 +311,7 @@ export class CategoryTreeNode extends TreeNode implements vscode.TreeItem { if (this.prs.size > 0) { const nodes: (PRNode | PRCategoryActionNode)[] = Array.from(this.prs.values()).map( - prItem => new PRNode(this, this.folderRepoManager, prItem, this.type === PRType.LocalPullRequest, this._notificationProvider, this._copilotManager), + prItem => new PRNode(this, this.folderRepoManager, prItem, this.type === PRType.LocalPullRequest, this._notificationProvider, this._prsTreeModel), ); if (hasMorePages) { nodes.push(new PRCategoryActionNode(this, PRCategoryActionType.More, this)); @@ -339,7 +339,7 @@ export class CategoryTreeNode extends TreeNode implements vscode.TreeItem { // Update contextValue based on current notification state if (this._categoryQuery) { - const hasNotifications = this.isCopilot && this._repo && this._copilotManager.getNotificationsCount(this._repo.owner, this._repo.repositoryName) > 0; + const hasNotifications = this.isCopilot && this._repo && this._prsTreeModel.getCopilotNotificationsCount(this._repo.owner, this._repo.repositoryName) > 0; this.contextValue = this.isCopilot ? (hasNotifications ? 'copilot-query-with-notifications' : 'copilot-query') : 'query'; diff --git a/src/view/treeNodes/commitsCategoryNode.ts b/src/view/treeNodes/commitsCategoryNode.ts index a0b8b25f62..88adf29b8a 100644 --- a/src/view/treeNodes/commitsCategoryNode.ts +++ b/src/view/treeNodes/commitsCategoryNode.ts @@ -4,11 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { CommitNode } from './commitNode'; +import { TreeNode, TreeNodeParent } from './treeNode'; import Logger, { PR_TREE } from '../../common/logger'; import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; import { PullRequestModel } from '../../github/pullRequestModel'; -import { CommitNode } from './commitNode'; -import { TreeNode, TreeNodeParent } from './treeNode'; export class CommitsNode extends TreeNode implements vscode.TreeItem { public collapsibleState: vscode.TreeItemCollapsibleState; diff --git a/src/view/treeNodes/pullRequestNode.ts b/src/view/treeNodes/pullRequestNode.ts index 0077a67d3f..6ebae209e0 100644 --- a/src/view/treeNodes/pullRequestNode.ts +++ b/src/view/treeNodes/pullRequestNode.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { CategoryTreeNode } from './categoryNode'; import { Repository } from '../../api/api'; import { COPILOT_ACCOUNTS } from '../../common/comment'; import { getCommentingRanges } from '../../common/commentingRanges'; @@ -11,10 +12,8 @@ import { InMemFileChange, SlimFileChange } from '../../common/file'; import Logger from '../../common/logger'; import { FILE_LIST_LAYOUT, PR_SETTINGS_NAMESPACE, SHOW_PULL_REQUEST_NUMBER_IN_TREE } from '../../common/settingKeys'; import { createPRNodeUri, DataUri, fromPRUri, Schemes } from '../../common/uri'; -import { CopilotRemoteAgentManager } from '../../github/copilotRemoteAgent'; import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; import { CopilotWorkingStatus } from '../../github/githubRepository'; -import { NotificationProvider } from '../../github/notifications'; import { IResolvedPullRequestModel, PullRequestModel } from '../../github/pullRequestModel'; import { InMemFileChangeModel, RemoteFileChangeModel } from '../fileChangeModel'; import { getInMemPRFileSystemProvider, provideDocumentContentForChangeModel } from '../inMemPRContentProvider'; @@ -22,6 +21,8 @@ import { getIconForeground, getListErrorForeground, getListWarningForeground, ge import { DirectoryTreeNode } from './directoryTreeNode'; import { InMemFileChangeNode, RemoteFileChangeNode } from './fileChangeNode'; import { TreeNode, TreeNodeParent } from './treeNode'; +import { NotificationsManager } from '../../notifications/notificationsManager'; +import { PrsTreeModel } from '../prsTreeModel'; export class PRNode extends TreeNode implements vscode.CommentingRangeProvider2 { static ID = 'PRNode'; @@ -50,8 +51,8 @@ export class PRNode extends TreeNode implements vscode.CommentingRangeProvider2 private _folderReposManager: FolderRepositoryManager, public pullRequestModel: PullRequestModel, private _isLocal: boolean, - private _notificationProvider: NotificationProvider, - private _codingAgentManager: CopilotRemoteAgentManager, + private _notificationProvider: NotificationsManager, + private _prsTreeModel: PrsTreeModel, ) { super(parent); this.registerSinceReviewChange(); @@ -270,7 +271,7 @@ export class PRNode extends TreeNode implements vscode.CommentingRangeProvider2 private async _getIcon(): Promise { const copilotWorkingStatus = await this.pullRequestModel.copilotWorkingStatus(this.pullRequestModel); const theme = this._folderReposManager.themeWatcher.themeData; - if (!theme || copilotWorkingStatus === CopilotWorkingStatus.NotCopilotIssue) { + if (copilotWorkingStatus === CopilotWorkingStatus.NotCopilotIssue) { return (await DataUri.avatarCirclesAsImageDataUris(this._folderReposManager.context, [this.pullRequestModel.author], 16, 16))[0] ?? new vscode.ThemeIcon('github'); } @@ -296,30 +297,45 @@ export class PRNode extends TreeNode implements vscode.CommentingRangeProvider2 } } - async getTreeItem(): Promise { + private _getLabel(): string { const currentBranchIsForThisPR = this.pullRequestModel.equals(this._folderReposManager.activePullRequest); + const { title, number, author, isDraft } = this.pullRequestModel; + let label = ''; - const { title, number, author, isDraft, html_url } = this.pullRequestModel; - let labelTitle = this.pullRequestModel.title.length > 50 ? `${this.pullRequestModel.title.substring(0, 50)}...` : this.pullRequestModel.title; - if (COPILOT_ACCOUNTS[this.pullRequestModel.author.login]) { - labelTitle = labelTitle.replace('[WIP]', ''); + if (currentBranchIsForThisPR) { + label += '$(check) '; } - const login = author.specialDisplayName ?? author.login; - - const hasNotification = this._notificationProvider.hasNotification(this.pullRequestModel); - - const formattedPRNumber = number.toString(); - let labelPrefix = currentBranchIsForThisPR ? '✓ ' : ''; if ( vscode.workspace .getConfiguration(PR_SETTINGS_NAMESPACE) .get(SHOW_PULL_REQUEST_NUMBER_IN_TREE, false) ) { - labelPrefix += `#${formattedPRNumber}: `; + label += `#${number}: `; + } + + let labelTitle = title.length > 50 ? `${title.substring(0, 50)}...` : title; + if (COPILOT_ACCOUNTS[author.login]) { + labelTitle = labelTitle.replace('[WIP]', ''); } + // Escape any $(...) syntax to avoid rendering PR titles as icons. + label += labelTitle.replace(/\$\([a-zA-Z0-9~-]+\)/g, '\\$&'); - const label = `${labelPrefix}${isDraft ? '[DRAFT] ' : ''}${labelTitle}`; + if (isDraft) { + label = `_${label}_`; + } + + return label; + } + + async getTreeItem(): Promise { + const currentBranchIsForThisPR = this.pullRequestModel.equals(this._folderReposManager.activePullRequest); + const { title, number, author, isDraft, html_url } = this.pullRequestModel; + const login = author.specialDisplayName ?? author.login; + const hasNotification = this._notificationProvider.hasNotification(this.pullRequestModel) || this._prsTreeModel.hasCopilotNotification(this.pullRequestModel.remote.owner, this.pullRequestModel.remote.repositoryName, this.pullRequestModel.number); + const label: vscode.TreeItemLabel2 = { + label: new vscode.MarkdownString(this._getLabel(), true) + }; const description = `by @${login}`; const command = { title: vscode.l10n.t('View Pull Request Description'), @@ -328,7 +344,7 @@ export class PRNode extends TreeNode implements vscode.CommentingRangeProvider2 }; return { - label, + label: label as vscode.TreeItemLabel, id: `${this.parent instanceof TreeNode ? (this.parent.id ?? this.parent.label) : ''}${html_url}${this._isLocal ? this.pullRequestModel.localBranchName : ''}`, // unique id stable across checkout status description, collapsibleState: 1, @@ -340,9 +356,9 @@ export class PRNode extends TreeNode implements vscode.CommentingRangeProvider2 (((this.pullRequestModel.item.isRemoteHeadDeleted && !this._isLocal) || !this._folderReposManager.isPullRequestAssociatedWithOpenRepository(this.pullRequestModel)) ? '' : ':hasHeadRef'), iconPath: await this._getIcon(), accessibilityInformation: { - label: `${isDraft ? 'Draft ' : ''}Pull request number ${formattedPRNumber}: ${title} by ${login}` + label: `${isDraft ? 'Draft ' : ''}Pull request number ${number}: ${title} by ${login}` }, - resourceUri: createPRNodeUri(this.pullRequestModel), + resourceUri: createPRNodeUri(this.pullRequestModel, this.parent instanceof CategoryTreeNode && this.parent.isCopilot ? true : undefined), command }; } diff --git a/src/view/treeNodes/treeNode.ts b/src/view/treeNodes/treeNode.ts index 466b45dcc8..399cbc1dca 100644 --- a/src/view/treeNodes/treeNode.ts +++ b/src/view/treeNodes/treeNode.ts @@ -39,6 +39,7 @@ export abstract class TreeNode extends Disposable { if (this._children && this._children.length) { return this._children; } + return undefined; } async reveal( diff --git a/src/view/treeNodes/workspaceFolderNode.ts b/src/view/treeNodes/workspaceFolderNode.ts index f6571a3012..7963f3b3ee 100644 --- a/src/view/treeNodes/workspaceFolderNode.ts +++ b/src/view/treeNodes/workspaceFolderNode.ts @@ -10,11 +10,11 @@ import { ITelemetry } from '../../common/telemetry'; import { CopilotRemoteAgentManager } from '../../github/copilotRemoteAgent'; import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; import { PRType } from '../../github/interface'; -import { NotificationProvider } from '../../github/notifications'; import { PullRequestModel } from '../../github/pullRequestModel'; import { PrsTreeModel } from '../prsTreeModel'; import { CategoryTreeNode, isAllQuery, isLocalQuery } from './categoryNode'; import { TreeNode, TreeNodeParent } from './treeNode'; +import { NotificationsManager } from '../../notifications/notificationsManager'; export interface IQueryInfo { label: string; @@ -31,7 +31,7 @@ export class WorkspaceFolderNode extends TreeNode implements vscode.TreeItem { uri: vscode.Uri, public readonly folderManager: FolderRepositoryManager, private telemetry: ITelemetry, - private notificationProvider: NotificationProvider, + private notificationProvider: NotificationsManager, private context: vscode.ExtensionContext, private readonly _prsTreeModel: PrsTreeModel, private readonly _copilotMananger: CopilotRemoteAgentManager @@ -76,7 +76,7 @@ export class WorkspaceFolderNode extends TreeNode implements vscode.TreeItem { folderManager: FolderRepositoryManager, telemetry: ITelemetry, parent: TreeNodeParent, - notificationProvider: NotificationProvider, + notificationProvider: NotificationsManager, context: vscode.ExtensionContext, prsTreeModel: PrsTreeModel, copilotManager: CopilotRemoteAgentManager diff --git a/src/view/webviewViewCoordinator.ts b/src/view/webviewViewCoordinator.ts index 9b4f608eab..da40fc4b3e 100644 --- a/src/view/webviewViewCoordinator.ts +++ b/src/view/webviewViewCoordinator.ts @@ -4,11 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { ReviewManager } from './reviewManager'; import { addDisposable, Disposable, disposeAll } from '../common/lifecycle'; import { PullRequestViewProvider } from '../github/activityBarViewProvider'; import { FolderRepositoryManager } from '../github/folderRepositoryManager'; import { PullRequestModel } from '../github/pullRequestModel'; -import { ReviewManager } from './reviewManager'; export class WebviewViewCoordinator extends Disposable { private _webviewViewProvider?: PullRequestViewProvider; diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json deleted file mode 100644 index 186ca56d57..0000000000 --- a/tsconfig.eslint.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.base.json", - "exclude": ["node_modules"] -} diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json new file mode 100644 index 0000000000..09a50b720f --- /dev/null +++ b/tsconfig.scripts.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node", + "types": [ + "node" + ], + "noEmit": true + }, + "include": [ + "build/**/*.ts" + ] +} \ No newline at end of file diff --git a/webviews/activityBarView/app.tsx b/webviews/activityBarView/app.tsx index 5d31ff9574..9d40442273 100644 --- a/webviews/activityBarView/app.tsx +++ b/webviews/activityBarView/app.tsx @@ -5,9 +5,9 @@ import React, { useContext, useEffect, useState } from 'react'; import { render } from 'react-dom'; +import { Overview } from './overview'; import { PullRequest } from '../../src/github/views'; import PullRequestContext from '../common/context'; -import { Overview } from './overview'; export function main() { render({pr => }, document.getElementById('app')); diff --git a/webviews/activityBarView/overview.tsx b/webviews/activityBarView/overview.tsx index f427a792ff..976a9d35fd 100644 --- a/webviews/activityBarView/overview.tsx +++ b/webviews/activityBarView/overview.tsx @@ -4,11 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import React from 'react'; +import { ExitSection } from './exit'; import { PullRequest } from '../../src/github/views'; import { AddCommentSimple } from '../components/comment'; import { StatusChecksSection } from '../components/merge'; -import { ExitSection } from './exit'; export const Overview = (pr: PullRequest) => { return <> diff --git a/webviews/common/cache.ts b/webviews/common/cache.ts index 3b0b393e04..f369e8770c 100644 --- a/webviews/common/cache.ts +++ b/webviews/common/cache.ts @@ -3,8 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { PullRequest } from '../../src/github/views'; import { vscode } from './message'; +import { PullRequest } from '../../src/github/views'; export function getState(): PullRequest { return vscode.getState(); diff --git a/webviews/common/common.css b/webviews/common/common.css index a198813a65..24e4d5a07d 100644 --- a/webviews/common/common.css +++ b/webviews/common/common.css @@ -238,6 +238,13 @@ body img.avatar { gap: 4px; } +.reviewer-icons [role='alert'] { + position: absolute; + width: 0; + height: 0; + overflow: hidden; +} + .push-right { margin-left: auto; } diff --git a/webviews/common/context.tsx b/webviews/common/context.tsx index cf848730f5..dd00c8076f 100644 --- a/webviews/common/context.tsx +++ b/webviews/common/context.tsx @@ -4,13 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import { createContext } from 'react'; +import { getState, setState, updateState } from './cache'; +import { getMessageHandler, MessageHandler } from './message'; import { CloseResult, OpenCommitChangesArgs } from '../../common/views'; import { IComment } from '../../src/common/comment'; import { EventType, ReviewEvent, SessionLinkInfo, TimelineEvent } from '../../src/common/timelineEvent'; import { IProjectItem, MergeMethod, ReadyForReview } from '../../src/github/interface'; -import { CancelCodingAgentReply, ChangeAssigneesReply, MergeArguments, MergeResult, ProjectItemsReply, PullRequest, SubmitReviewReply } from '../../src/github/views'; -import { getState, setState, updateState } from './cache'; -import { getMessageHandler, MessageHandler } from './message'; +import { CancelCodingAgentReply, ChangeAssigneesReply, DeleteReviewResult, MergeArguments, MergeResult, ProjectItemsReply, PullRequest, SubmitReviewReply } from '../../src/github/views'; export class PRContext { constructor( @@ -75,7 +75,7 @@ export class PRContext { public merge = async (args: MergeArguments): Promise => { const result: MergeResult = await this.postMessage({ command: 'pr.merge', args }); return result; - } + }; public openOnGitHub = () => this.postMessage({ command: 'pr.openOnGitHub' }); @@ -89,6 +89,8 @@ export class PRContext { public readyForReview = (): Promise => this.postMessage({ command: 'pr.readyForReview' }); + public readyForReviewAndMerge = (args: { mergeMethod: MergeMethod }): Promise => this.postMessage({ command: 'pr.readyForReviewAndMerge', args }); + public addReviewers = () => this.postMessage({ command: 'pr.change-reviewers' }); public changeProjects = (): Promise => this.postMessage({ command: 'pr.change-projects' }); public removeProject = (project: IProjectItem) => this.postMessage({ command: 'pr.remove-project', args: project }); @@ -158,6 +160,30 @@ export class PRContext { public submit = (body: string) => this.submitReviewCommand('pr.submit', body); + public deleteReview = async () => { + try { + const result: DeleteReviewResult = await this.postMessage({ command: 'pr.delete-review' }); + + const state = this.pr; + const eventsWithoutPendingReview = state?.events.filter(event => + !(event.event === EventType.Reviewed && event.id === result.deletedReviewId) + ) ?? []; + + if (state && (eventsWithoutPendingReview.length < state.events.length)) { + // Update the PR state to reflect the deleted review + state.busy = false; + state.pendingCommentText = ''; + state.pendingCommentDrafts = {}; + // Remove the deleted review from events + state.events = eventsWithoutPendingReview; + this.updatePR(state); + } + return result; + } catch (error) { + return this.updatePR({ busy: false }); + } + }; + public close = async (body?: string) => { const { pr } = this; if (!pr) { @@ -227,7 +253,7 @@ export class PRContext { const { reviewers } = await this.postMessage({ command: 'pr.re-request-review', args: reviewerId }); state.reviewers = reviewers; this.updatePR(state); - } + }; public async updateAutoMerge({ autoMerge, autoMergeMethod }: { autoMerge?: boolean, autoMergeMethod?: MergeMethod }) { const { pr: state } = this; @@ -249,7 +275,7 @@ export class PRContext { state.events = result.events ?? state.events; state.mergeable = result.mergeable ?? state.mergeable; this.updatePR(state); - } + }; public dequeue = async () => { const { pr: state } = this; @@ -261,7 +287,7 @@ export class PRContext { state.mergeQueueEntry = undefined; } this.updatePR(state); - } + }; public enqueue = async () => { const { pr: state } = this; @@ -273,7 +299,7 @@ export class PRContext { state.mergeQueueEntry = result.mergeQueueEntry; } this.updatePR(state); - } + }; public openDiff = (comment: IComment) => this.postMessage({ command: 'pr.open-diff', args: { comment } }); diff --git a/webviews/common/createContextNew.ts b/webviews/common/createContextNew.ts index a715a4dc6c..3189bf4d4f 100644 --- a/webviews/common/createContextNew.ts +++ b/webviews/common/createContextNew.ts @@ -4,11 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import { createContext } from 'react'; +import { getMessageHandler, MessageHandler, vscode } from './message'; import { RemoteInfo } from '../../common/types'; import { ChooseBaseRemoteAndBranchResult, ChooseCompareRemoteAndBranchResult, ChooseRemoteAndBranchArgs, CreateParamsNew, CreatePullRequestNew, ScrollPosition, TitleAndDescriptionArgs, TitleAndDescriptionResult } from '../../common/views'; import { compareIgnoreCase } from '../../src/common/utils'; import { PreReviewState } from '../../src/github/views'; -import { getMessageHandler, MessageHandler, vscode } from './message'; const defaultCreateParams: CreateParamsNew = { canModifyBranches: true, @@ -206,17 +206,17 @@ export class CreatePRContextNew { if (this._descriptionStack.length > 0) { this.updateState({ pendingDescription: this._descriptionStack.pop() }); } - } + }; public preReview = async (): Promise => { this.updateState({ reviewing: true }); const result: PreReviewState = await this.postMessage({ command: 'pr.preReview' }); this.updateState({ preReviewState: result, reviewing: false }); - } + }; public cancelPreReview = async (): Promise => { return this.postMessage({ command: 'pr.cancelPreReview' }); - } + }; public validate = (): boolean => { let isValid = true; diff --git a/webviews/common/errorBoundary.tsx b/webviews/common/errorBoundary.tsx index 879647d953..89e59961b8 100644 --- a/webviews/common/errorBoundary.tsx +++ b/webviews/common/errorBoundary.tsx @@ -5,23 +5,31 @@ import React from 'react'; -export class ErrorBoundary extends React.Component { - constructor(props) { +interface ErrorBoundaryProps { + children?: React.ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; +} + +export class ErrorBoundary extends React.Component { + constructor(props: ErrorBoundaryProps) { super(props); this.state = { hasError: false }; } - static getDerivedStateFromError(_error) { + static getDerivedStateFromError(_error: unknown): ErrorBoundaryState { return { hasError: true }; } - override componentDidCatch(error, errorInfo) { - console.log(error); - console.log(errorInfo); + override componentDidCatch(error: unknown, errorInfo: React.ErrorInfo): void { + console.error(error); + console.error(errorInfo); } - override render() { - if ((this.state as any).hasError) { + override render(): React.ReactNode { + if (this.state.hasError) { return
Something went wrong.
; } diff --git a/webviews/components/automergeSelect.tsx b/webviews/components/automergeSelect.tsx index 07020e8ed2..5f05f68b9c 100644 --- a/webviews/components/automergeSelect.tsx +++ b/webviews/components/automergeSelect.tsx @@ -4,9 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import * as React from 'react'; +import { MergeSelect } from './merge'; import { MergeMethod, MergeMethodsAvailability, MergeQueueEntry, MergeQueueState } from '../../src/github/interface'; import PullRequestContext from '../common/context'; -import { MergeSelect } from './merge'; const AutoMergeLabel = ({ busy, baseHasMergeQueue }: { busy: boolean, baseHasMergeQueue: boolean }) => { if (busy) { diff --git a/webviews/components/comment.tsx b/webviews/components/comment.tsx index 6cb648f94c..c83c74bc31 100644 --- a/webviews/components/comment.tsx +++ b/webviews/components/comment.tsx @@ -4,6 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { ContextDropdown } from './contextDropdown'; +import { editIcon, quoteIcon, trashIcon } from './icon'; +import { nbsp, Spaced } from './space'; +import { Timestamp } from './timestamp'; +import { AuthorLink, Avatar } from './user'; import { IComment } from '../../src/common/comment'; import { CommentEvent, EventType, ReviewEvent } from '../../src/common/timelineEvent'; import { GithubItemStateEnum } from '../../src/github/interface'; @@ -12,11 +17,6 @@ import { ariaAnnouncementForReview } from '../common/aria'; import PullRequestContext from '../common/context'; import emitter from '../common/events'; import { useStateProp } from '../common/hooks'; -import { ContextDropdown } from './contextDropdown'; -import { deleteIcon, editIcon, quoteIcon } from './icon'; -import { nbsp, Spaced } from './space'; -import { Timestamp } from './timestamp'; -import { AuthorLink, Avatar } from './user'; export type Props = { headerInEditMode?: boolean; @@ -107,7 +107,7 @@ export function CommentView(commentProps: Props) { className="icon-button" onClick={() => deleteComment({ id, pullRequestReviewId })} > - {deleteIcon} + {trashIcon} ) : null} @@ -143,13 +143,14 @@ function isIComment(comment: any): comment is IComment { } const DESCRIPTORS = { + REQUESTED: 'will review', PENDING: 'will review', COMMENTED: 'reviewed', CHANGES_REQUESTED: 'requested changes', APPROVED: 'approved', }; -const reviewDescriptor = (state: string) => DESCRIPTORS[state] || 'reviewed'; +const reviewDescriptor = (state: keyof typeof DESCRIPTORS) => DESCRIPTORS[state]; function CommentBox({ for: comment, onFocus, onMouseEnter, onMouseLeave, children }: CommentBoxProps) { const asNotPullRequest = comment as Partial; @@ -246,7 +247,7 @@ function EditComment({ id, body, onCancel, onSave }: EditCommentProps) { const onInput = useCallback( e => { - draftComment.current.body = (e.target as any).value; + draftComment.current.body = e.target.value; draftComment.current.dirty = true; }, [draftComment], @@ -365,7 +366,7 @@ export function AddComment({ textareaRef.current?.focus(); }); - const closeButton = e => { + const closeButton: React.MouseEventHandler = e => { e.preventDefault(); const { value } = textareaRef.current!; close(value); @@ -427,7 +428,7 @@ export function AddComment({ id="comment-textarea" name="body" ref={textareaRef as React.MutableRefObject} - onInput={({ target }) => updatePR({ pendingCommentText: (target as any).value })} + onInput={({ target }) => updatePR({ pendingCommentText: (target as HTMLTextAreaElement).value })} onKeyDown={onKeyDown} value={pendingCommentText} placeholder="Leave a comment" @@ -495,7 +496,7 @@ const COMMENT_METHODS = { }; const makeCommentMenuContext = (availableActions: { comment?: string, approve?: string, requestChanges?: string }, pendingCommentText: string | undefined, shouldDisableNonApproveButtons: boolean) => { - const createMenuContexts = { + const createMenuContexts: Record = { 'preventDefaultContextMenuItems': true, 'github:reviewCommentMenu': true, }; diff --git a/webviews/components/diff.tsx b/webviews/components/diff.tsx index 262b31747e..88e0f28bca 100644 --- a/webviews/components/diff.tsx +++ b/webviews/components/diff.tsx @@ -25,8 +25,8 @@ const Hunk = ({ hunk, maxLines = 8 }: { hunk: DiffHunk; maxLines?: number }) =>
-
{(line as any)._raw.substr(0, 1)}
-
{(line as any)._raw.substr(1)}
+
{line.raw.substr(0, 1)}
+
{line.raw.substr(1)}
))} diff --git a/webviews/components/dropdown.tsx b/webviews/components/dropdown.tsx index a271e8780a..1c4d9b9f13 100644 --- a/webviews/components/dropdown.tsx +++ b/webviews/components/dropdown.tsx @@ -5,7 +5,7 @@ import React, { useState } from 'react'; import { v4 as uuid } from 'uuid'; -import { chevronIcon } from './icon'; +import { chevronDownIcon } from './icon'; const enum KEYCODES { esc = 27, @@ -98,7 +98,7 @@ export const Dropdown = ({ options, defaultOption, disabled, submitAction, chang />
diff --git a/webviews/components/header.tsx b/webviews/components/header.tsx index e8ca1cbd39..9418515e19 100644 --- a/webviews/components/header.tsx +++ b/webviews/components/header.tsx @@ -4,15 +4,15 @@ *--------------------------------------------------------------------------------------------*/ import React, { useContext, useState } from 'react'; +import { ContextDropdown } from './contextDropdown'; +import { copilotErrorIcon, copilotInProgressIcon, copilotSuccessIcon, copyIcon, editIcon, gitMergeIcon, gitPullRequestClosedIcon, gitPullRequestDraftIcon, gitPullRequestIcon, issuescon, loadingIcon, passIcon } from './icon'; +import { AuthorLink, Avatar } from './user'; import { copilotEventToStatus, CopilotPRStatus, mostRecentCopilotEvent } from '../../src/common/copilot'; import { CopilotStartedEvent, TimelineEvent } from '../../src/common/timelineEvent'; import { GithubItemStateEnum, StateReason } from '../../src/github/interface'; import { CodingAgentContext, OverviewContext, PullRequest } from '../../src/github/views'; import PullRequestContext from '../common/context'; import { useStateProp } from '../common/hooks'; -import { ContextDropdown } from './contextDropdown'; -import { copilotErrorIcon, copilotInProgressIcon, copilotSuccessIcon, copyIcon, editIcon, issueClosedIcon, issueIcon, loadingIcon, mergeIcon, prClosedIcon, prDraftIcon, prOpenIcon } from './icon'; -import { AuthorLink, Avatar } from './user'; export function Header({ canEdit, @@ -91,7 +91,9 @@ function Title({ title, titleHTML, number, url, inEditMode, setEditMode, setCurr onSubmit={async evt => { evt.preventDefault(); try { - const txt = (evt.target as any)[0].value; + const form = evt.currentTarget; + const firstElement = form.elements[0] as HTMLInputElement | undefined; + const txt = firstElement ? firstElement.value : ''; await setTitle(txt); setCurrentTitle(txt); } finally { @@ -364,13 +366,13 @@ const CheckoutButton: React.FC = ({ isCurrentlyCheckedOut, }; export function getStatus(state: GithubItemStateEnum, isDraft: boolean, isIssue: boolean, stateReason?: StateReason) { - const closed = isIssue ? issueClosedIcon : prClosedIcon; - const open = isIssue ? issueIcon : prOpenIcon; + const closed = isIssue ? passIcon : gitPullRequestClosedIcon; + const open = isIssue ? issuescon : gitPullRequestIcon; if (state === GithubItemStateEnum.Merged) { - return { text: 'Merged', color: 'merged', icon: mergeIcon }; + return { text: 'Merged', color: 'merged', icon: gitMergeIcon }; } else if (state === GithubItemStateEnum.Open) { - return isDraft ? { text: 'Draft', color: 'draft', icon: prDraftIcon } : { text: 'Open', color: 'open', icon: open }; + return isDraft ? { text: 'Draft', color: 'draft', icon: gitPullRequestDraftIcon } : { text: 'Open', color: 'open', icon: open }; } else { let closedColor: string = 'closed'; if (isIssue) { diff --git a/webviews/components/icon.tsx b/webviews/components/icon.tsx index 8019f9d042..67c7ee53aa 100644 --- a/webviews/components/icon.tsx +++ b/webviews/components/icon.tsx @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable import/order */ + import * as React from 'react'; export const Icon = ({ className = '', src, title }: { className?: string; title?: string; src: string }) => ( @@ -11,46 +11,46 @@ export const Icon = ({ className = '', src, title }: { className?: string; title ); export default Icon; +// Codicons +export const accountIcon = ; +export const addIcon = ; +export const checkIcon = ; +export const checkAllIcon = ; +export const chevronDownIcon = ; +export const circleFilledIcon = ; +export const closeIcon = ; +export const commentIcon = ; +export const copilotIcon = ; +export const copyIcon = ; +export const editIcon = ; +export const errorIcon = ; +export const feedbackIcon = ; +export const gitCommitIcon = ; +export const gitCompareIcon = ; +export const gitMergeIcon = ; +export const gitPullRequestClosedIcon = ; +export const gitPullRequestDraftIcon = ; +export const gitPullRequestIcon = ; +export const issuescon = ; +export const loadingIcon = ; +export const milestoneIcon = ; +export const passIcon = ; +export const projectIcon = ; +export const quoteIcon = ; +export const requestChangesIcon = ; +export const settingsIcon = ; +export const sparkleIcon = ; +export const stopCircleIcon = ; +export const syncIcon = ; +export const tagIcon = ; +export const tasklistIcon = ; +export const threeBars = ; +export const trashIcon = ; +export const warningIcon = ; +export const prMergeIcon = ; +export const skipIcon = ; -export const alertIcon = ; -export const checkIcon = ; -export const skipIcon = ; -export const chevronIcon = ; -export const chevronDownIcon = ; -export const commentIcon = ; -export const quoteIcon = ; -export const commitIcon = ; -export const copyIcon = ; -export const deleteIcon = ; -export const mergeIcon = ; -export const mergeMethodIcon = ; -export const prClosedIcon = ; -export const prOpenIcon = ; -export const prDraftIcon = ; -export const editIcon = ; -export const plusIcon = ; -export const pendingIcon = ; -export const requestChanges = ; -export const settingsIcon = ; -export const closeIcon = ; -export const syncIcon = ; -export const prBaseIcon = ; -export const prMergeIcon = ; -export const gearIcon = ; -export const assigneeIcon = ; -export const reviewerIcon = ; -export const labelIcon = ; -export const milestoneIcon = ; -export const projectIcon = ; -export const sparkleIcon = ; -export const stopCircleIcon = ; -export const issueIcon = ; -export const issueClosedIcon = ; -export const copilotIcon = ; -export const threeBars = ; -export const tasklistIcon = ; -export const errorIcon = ; -export const loadingIcon = ; -export const copilotSuccessIcon = ; +// Other icons export const copilotErrorIcon = ; export const copilotInProgressIcon = ; +export const copilotSuccessIcon = ; \ No newline at end of file diff --git a/webviews/components/merge.tsx b/webviews/components/merge.tsx index adcbe38d3d..46d5d758cf 100644 --- a/webviews/components/merge.tsx +++ b/webviews/components/merge.tsx @@ -12,6 +12,11 @@ import React, { useRef, useState, } from 'react'; +import { AutoMerge, QueuedToMerge } from './automergeSelect'; +import { Dropdown } from './dropdown'; +import { checkAllIcon, checkIcon, circleFilledIcon, closeIcon, gitMergeIcon, loadingIcon, requestChangesIcon, skipIcon, warningIcon } from './icon'; +import { nbsp } from './space'; +import { Avatar } from './user'; import { EventType, ReviewEvent } from '../../src/common/timelineEvent'; import { groupBy } from '../../src/common/utils'; import { @@ -27,16 +32,11 @@ import { import { PullRequest } from '../../src/github/views'; import PullRequestContext from '../common/context'; import { Reviewer } from '../components/reviewer'; -import { AutoMerge, QueuedToMerge } from './automergeSelect'; -import { Dropdown } from './dropdown'; -import { alertIcon, checkIcon, closeIcon, mergeIcon, pendingIcon, requestChanges, skipIcon } from './icon'; -import { nbsp } from './space'; -import { Avatar } from './user'; const PRStatusMessage = ({ pr, isSimple }: { pr: PullRequest; isSimple: boolean }) => { return pr.state === GithubItemStateEnum.Merged ? (
-
{isSimple ? mergeIcon : null}
{' '} +
{isSimple ? gitMergeIcon : null}
{' '} {'Pull request successfully merged.'}
) : pr.state === GithubItemStateEnum.Closed ? ( @@ -218,7 +218,7 @@ export const MergeStatus = ({ mergeable, isSimple, canUpdateBranch }: { mergeabl updateBranch().finally(() => setBusy(false)); }; - let icon: JSX.Element | null = pendingIcon; + let icon: JSX.Element | null = circleFilledIcon; let summary: string = 'Checking if this branch can be merged...'; let action: string | null = null; if (mergeable === PullRequestMergeability.Mergeable) { @@ -244,18 +244,20 @@ export const MergeStatus = ({ mergeable, isSimple, canUpdateBranch }: { mergeabl } } return ( -
- {icon} -

- {summary} -

- {(action && canUpdateBranch) ? -
- -
- : null} +
+
+ {icon} +

+ {summary} +

+ {(action && canUpdateBranch) ? +
+ +
+ : null} +
); }; @@ -273,7 +275,7 @@ export const OfferToUpdate = ({ mergeable, isSimple, isCurrentlyCheckedOut, canU } return (
- {alertIcon} + {warningIcon}

This branch is out-of-date with the base branch.

@@ -281,9 +283,10 @@ export const OfferToUpdate = ({ mergeable, isSimple, isCurrentlyCheckedOut, canU }; -export const ReadyForReview = ({ isSimple }: { isSimple: boolean }) => { +export const ReadyForReview = ({ isSimple, isCopilotOnMyBehalf, mergeMethod }: { isSimple: boolean; isCopilotOnMyBehalf?: boolean; mergeMethod: MergeMethod }) => { const [isBusy, setBusy] = useState(false); - const { readyForReview, updatePR } = useContext(PullRequestContext); + const [isMergeBusy, setMergeBusy] = useState(false); + const { readyForReview, readyForReviewAndMerge, updatePR } = useContext(PullRequestContext); const markReadyForReview = useCallback(async () => { try { @@ -295,16 +298,39 @@ export const ReadyForReview = ({ isSimple }: { isSimple: boolean }) => { } }, [setBusy, readyForReview, updatePR]); + const markReadyAndMerge = useCallback(async () => { + try { + setBusy(true); + setMergeBusy(true); + const result = await readyForReviewAndMerge({ mergeMethod: mergeMethod }); + updatePR(result); + } finally { + setBusy(false); + setMergeBusy(false); + } + }, [readyForReviewAndMerge, updatePR, mergeMethod]); + return (
-
{isSimple ? null : alertIcon}
+
{isSimple ? null : warningIcon}
This pull request is still a work in progress.
Draft pull requests cannot be merged.
+ {isCopilotOnMyBehalf && ( + + )}
@@ -338,10 +364,14 @@ export const Merge = (pr: PullRequest) => { }; export const PrActions = ({ pr, isSimple }: { pr: PullRequest; isSimple: boolean }) => { - const { hasWritePermission, canEdit, isDraft, mergeable } = pr; + const { hasWritePermission, canEdit, isDraft, mergeable, isCopilotOnMyBehalf, defaultMergeMethod } = pr; if (isDraft) { // Only PR author and users with push rights can mark draft as ready for review - return canEdit ? : null; + if (!canEdit) { + return null; + } + + return ; } if (mergeable === PullRequestMergeability.Mergeable && hasWritePermission && !pr.mergeQueueEntry) { @@ -592,13 +622,13 @@ function StateIcon({ state }: { state: CheckState }) { case CheckState.Failure: return closeIcon; } - return pendingIcon; + return circleFilledIcon; } function RequiredReviewStateIcon({ state }: { state: CheckState }) { switch (state) { case CheckState.Pending: - return requestChanges; + return requestChangesIcon; case CheckState.Failure: return closeIcon; } diff --git a/webviews/components/reviewer.tsx b/webviews/components/reviewer.tsx index 74d7e18bfa..be8c02be01 100644 --- a/webviews/components/reviewer.tsx +++ b/webviews/components/reviewer.tsx @@ -3,12 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import React, { cloneElement, useContext } from 'react'; +import { checkIcon, circleFilledIcon, commentIcon, requestChangesIcon, syncIcon } from './icon'; +import { AuthorLink, Avatar } from './user'; import { ReviewEvent } from '../../src/common/timelineEvent'; import { AccountType, isITeam, ReviewState } from '../../src/github/interface'; import { ariaAnnouncementForReview } from '../common/aria'; import PullRequestContext from '../common/context'; -import { checkIcon, commentIcon, pendingIcon, requestChanges, syncIcon } from './icon'; -import { AuthorLink, Avatar } from './user'; export function Reviewer(reviewInfo: { reviewState: ReviewState, event?: ReviewEvent }) { const { reviewer, state } = reviewInfo.reviewState; @@ -37,8 +37,8 @@ export function Reviewer(reviewInfo: { reviewState: ReviewState, event?: ReviewE } const REVIEW_STATE: { [state: string]: React.ReactElement } = { - REQUESTED: cloneElement(pendingIcon, { className: 'section-icon requested', title: 'Awaiting requested review' }), + REQUESTED: cloneElement(circleFilledIcon, { className: 'section-icon requested', title: 'Awaiting requested review' }), COMMENTED: cloneElement(commentIcon, { className: 'section-icon commented', Root: 'div', title: 'Left review comments' }), APPROVED: cloneElement(checkIcon, { className: 'section-icon approved', title: 'Approved these changes' }), - CHANGES_REQUESTED: cloneElement(requestChanges, { className: 'section-icon changes', title: 'Requested changes' }), + CHANGES_REQUESTED: cloneElement(requestChangesIcon, { className: 'section-icon changes', title: 'Requested changes' }), }; diff --git a/webviews/components/sidebar.tsx b/webviews/components/sidebar.tsx index 0327aa84e0..84f0c6ab09 100644 --- a/webviews/components/sidebar.tsx +++ b/webviews/components/sidebar.tsx @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import React, { useContext, useEffect, useRef, useState } from 'react'; +import { closeIcon, copilotIcon, settingsIcon } from './icon'; +import { Reviewer } from './reviewer'; import { COPILOT_LOGINS } from '../../src/common/copilot'; import { gitHubLabelColor } from '../../src/common/utils'; import { IAccount, IMilestone, IProjectItem, reviewerId, reviewerLabel, ReviewState } from '../../src/github/interface'; @@ -11,8 +13,6 @@ import { PullRequest } from '../../src/github/views'; import PullRequestContext from '../common/context'; import { Label } from '../common/label'; import { AuthorLink, Avatar } from '../components/user'; -import { closeIcon, copilotIcon, settingsIcon } from './icon'; -import { Reviewer } from './reviewer'; function Section({ id, @@ -288,12 +288,14 @@ function CollapsedLabel(props: PullRequest) { ); - const PillContainer = ({ items, getKey, getColor, getText }: { - items: any[], - getKey: (item: any) => string, - getColor: (item: any) => { backgroundColor: string; textColor: string; borderColor: string }, - getText: (item: any) => string - }) => { + interface PillContainerProps { + items: T[]; + getKey: (item: T) => string; + getColor: (item: T) => { backgroundColor: string; textColor: string; borderColor: string }; + getText: (item: T) => string; + } + + const PillContainer = ({ items, getKey, getColor, getText }: PillContainerProps) => { const containerRef = useRef(null); const [visibleCount, setVisibleCount] = useState(items.length); @@ -381,7 +383,7 @@ function CollapsedLabel(props: PullRequest) { items={labels} getKey={l => l.name} getColor={l => gitHubLabelColor(l.color, props?.isDarkTheme, false)} - getText={l => l.name} + getText={l => l.displayName} /> ), count: labels.length diff --git a/webviews/components/timeline.tsx b/webviews/components/timeline.tsx index 25c0682236..193828756f 100644 --- a/webviews/components/timeline.tsx +++ b/webviews/components/timeline.tsx @@ -4,6 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import React, { useContext, useRef, useState } from 'react'; +import { CommentView } from './comment'; +import Diff from './diff'; +import { addIcon, errorIcon, gitCommitIcon, gitMergeIcon, loadingIcon, tasklistIcon, threeBars } from './icon'; +import { nbsp } from './space'; +import { Timestamp } from './timestamp'; +import { AuthorLink, Avatar } from './user'; import { IComment } from '../../src/common/comment'; import { AssignEvent, @@ -26,12 +32,6 @@ import { groupBy, UnreachableCaseError } from '../../src/common/utils'; import { IAccount, IActor } from '../../src/github/interface'; import { ReviewType } from '../../src/github/views'; import PullRequestContext from '../common/context'; -import { CommentView } from './comment'; -import Diff from './diff'; -import { commitIcon, errorIcon, loadingIcon, mergeIcon, plusIcon, tasklistIcon, threeBars } from './icon'; -import { nbsp } from './space'; -import { Timestamp } from './timestamp'; -import { AuthorLink, Avatar } from './user'; function isAssignUnassignEvent(event: TimelineEvent | ConsolidatedAssignUnassignEvent): event is AssignEvent | UnassignEvent { return event.event === EventType.Assigned || event.event === EventType.Unassigned; @@ -122,7 +122,7 @@ const CommitEventView = (event: CommitEvent) => { return (
- {commitIcon} + {gitCommitIcon} {nbsp}
@@ -169,7 +169,7 @@ const NewCommitsSinceReviewEventView = () => { return (
- {plusIcon} + {addIcon} {nbsp} New changes since your last Review
@@ -269,7 +269,7 @@ function CommentThread({ thread, event }: { thread: IComment[]; event: ReviewEve } function AddReviewSummaryComment() { - const { requestChanges, approve, submit, pr } = useContext(PullRequestContext); + const { requestChanges, approve, submit, deleteReview, pr } = useContext(PullRequestContext); const isAuthor = pr?.isAuthor; const comment = useRef(); const [isBusy, setBusy] = useState(false); @@ -292,6 +292,13 @@ function AddReviewSummaryComment() { setBusy(false); } + async function cancelReview(event: React.MouseEvent): Promise { + event.preventDefault(); + setBusy(true); + await deleteReview(); + setBusy(false); + } + const onKeyDown = (event: React.KeyboardEvent) => { if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { submitAction(event, ReviewType.Comment); @@ -317,6 +324,14 @@ function AddReviewSummaryComment() { value={commentText} >
+ {isAuthor ? null : (